accounting workflows

This commit is contained in:
Thanakorn S
2026-05-20 10:17:02 +07:00
parent 04a683bd02
commit 7396db6ffc
199 changed files with 23539 additions and 5345 deletions
+132 -22
View File
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
* WarehouseManager
@@ -42,6 +43,18 @@ class WarehouseManager {
return 'td_stock_' . $warehouse_id;
}
public function assertStockMovementWindow(?string $date, string $context = 'Stock movement'): void
{
global $pdo1;
if (!isset($pdo1) || !($pdo1 instanceof PDO)) {
throw new Exception("Posting-window validation is unavailable.");
}
$guard = new PostingWindowGuard($pdo1, $this->company_id);
$guard->assertOpenDate($date ?: date('Y-m-d'), $context);
}
private function resolveWarehouseTable(int $warehouse_id): ?string
{
$sth = $this->pdo->prepare(
@@ -307,7 +320,7 @@ class WarehouseManager {
*/
private function insertRackLog(int $rack_id, string $action, array $extra = []): void {
$sql = "INSERT INTO md_rack_log
$sql = "INSERT INTO td_rack_log
(company_id, md_rack_id, user_id, dt, login, action, product_sku, td_stock_id)
VALUES
(:company_id, :md_rack_id, :user_id, :dt, :login, :action, :product_sku, :td_stock_id)";
@@ -330,7 +343,7 @@ class WarehouseManager {
/**
* Soft-delete rack logs created by a stock movement and its reversal.
*
* md_rack_log follows the same soft-delete convention as other tenant rows:
* td_rack_log follows the same soft-delete convention as other tenant rows:
* negate company_id so normal company_id filters hide the records.
*/
private function softDeleteRackLogs(
@@ -399,7 +412,7 @@ class WarehouseManager {
$params[':since'] = $since;
}
$sql = "UPDATE md_rack_log
$sql = "UPDATE td_rack_log
SET company_id = company_id * -1
WHERE company_id = :company_id
AND md_rack_id = :md_rack_id
@@ -893,7 +906,7 @@ class WarehouseManager {
* Soft-delete a storage record and its associated rack rows.
*
* Blocks deletion if any rack under this storage_id is occupied.
* Also hard-deletes md_rack_log entries for those racks before soft-deleting
* Also hard-deletes td_rack_log entries for those racks before soft-deleting
* the md_rack rows themselves, then soft-deletes md_storage.
*
* Must be called inside dbTransaction() by the caller.
@@ -948,11 +961,11 @@ class WarehouseManager {
]);
$rack_ids = $sth->fetchAll(PDO::FETCH_COLUMN);
// Hard-delete md_rack_log for these racks (log records have no independent value)
// Hard-delete td_rack_log for these racks (log records have no independent value)
if (!empty($rack_ids)) {
$placeholders = implode(',', array_fill(0, count($rack_ids), '?'));
$this->pdo->prepare(
"DELETE FROM md_rack_log WHERE md_rack_id IN ($placeholders)"
"DELETE FROM td_rack_log WHERE md_rack_id IN ($placeholders)"
)->execute($rack_ids);
}
@@ -1328,6 +1341,88 @@ class WarehouseManager {
$this->insertRacksInRange($storage_id, $range, $advanced_location);
}
private function getRackRow(int $warehouse_id, string $zone, string $aisle, string $rack): array|false
{
$sql = "SELECT id, product_sku, td_stock_id
FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
AND aisle = :aisle
AND rack = :rack
LIMIT 1";
$sth = $this->pdo->prepare($sql);
$sth->execute([
':company_id' => $this->company_id,
':warehouse' => $warehouse_id,
':zone' => $zone,
':aisle' => $aisle,
':rack' => $rack,
]);
return $sth->fetch(PDO::FETCH_ASSOC);
}
public function validateScanLocation(
string $mode,
int $warehouse_id,
string $zone,
string $aisle,
string $rack,
string $product_sku = '',
string $lot_number = '',
string $serial_number = ''
): void {
if (!$warehouse_id || $zone === '' || $aisle === '' || $rack === '') {
throw new Exception('Location label is incomplete.');
}
$rack_row = $this->getRackRow($warehouse_id, $zone, $aisle, $rack);
if (!$rack_row) {
throw new Exception("Location {$zone}-{$aisle}-{$rack} does not exist.");
}
if ($mode === 'in' || $mode === 'to') {
if ($rack_row['product_sku'] !== null) {
throw new Exception("Location {$zone}-{$aisle}-{$rack} is already occupied by {$rack_row['product_sku']}.");
}
if ($mode === 'in' && $product_sku !== '') {
$this->validateStockUnique($product_sku, $lot_number ?: null, $serial_number ?: null);
}
return;
}
if ($mode === 'out' || $mode === 'from') {
if ($product_sku === '') {
throw new Exception('Scan the SKU label first.');
}
$stock = $this->getRackStock($warehouse_id, $zone, $aisle, $rack);
if (!$stock) {
throw new Exception("Location {$zone}-{$aisle}-{$rack} has no approved stock to move out.");
}
if ($stock['product_sku'] !== $product_sku) {
throw new Exception("Location holds {$stock['product_sku']}, not {$product_sku}.");
}
if ($lot_number !== '' && (string)$stock['lot_number'] !== $lot_number) {
throw new Exception("Location holds lot '{$stock['lot_number']}', not '{$lot_number}'.");
}
if ($serial_number !== '' && (string)$stock['serial_number'] !== $serial_number) {
throw new Exception("Location holds serial '{$stock['serial_number']}', not '{$serial_number}'.");
}
return;
}
throw new Exception('Invalid scan validation mode.');
}
/**
* Fetch the stock record currently occupying a rack.
*
@@ -1754,27 +1849,29 @@ class WarehouseManager {
* @param int|float $old_qty Previous quantity (0 for new records).
* @param int|float $new_qty New quantity (0 to reverse/delete).
*/
public function adjustBalance($type, $warehouse_id, $product_sku, $old_qty, $new_qty) {
public function adjustBalance(
$type, $warehouse_id, $product_sku, $old_qty, $new_qty,
int $stock_id = 0, string $source = '', int $source_id = 0
): void {
$column = $type === 'in' ? 'total_in' : 'total_out';
$delta = $new_qty - $old_qty;
$month = date('Y-m');
$sql = "INSERT INTO warehouse_balance
(company_id, warehouse_id, product_sku, month, `$column`)
VALUES
(:company_id, :warehouse_id, :product_sku, :month, :delta)
ON DUPLICATE KEY UPDATE
`$column` = `$column` + VALUES(`$column`)";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":warehouse_id" => $warehouse_id,
":product_sku" => $product_sku,
":month" => $month,
":delta" => $delta
$this->pdo->prepare(
"INSERT INTO warehouse_balance
(company_id, warehouse_id, product_sku, month, `{$column}`)
VALUES
(:company_id, :warehouse_id, :product_sku, :month, :delta)
ON DUPLICATE KEY UPDATE
`{$column}` = `{$column}` + VALUES(`{$column}`)"
)->execute([
':company_id' => $this->company_id,
':warehouse_id' => $warehouse_id,
':product_sku' => $product_sku,
':month' => $month,
':delta' => $delta,
]);
}
// ─────────────────────────────────────────────────────────────
@@ -2050,6 +2147,10 @@ class WarehouseManager {
$product_sku = $row['product_sku'];
$product_name = $this->getProductName($product_sku);
if ((int)$row['status'] === 1) {
$this->assertStockMovementWindow($row['date'] ?? null, 'Stock-in deletion');
}
$this->validateLatestTransaction($product_sku, $row['date'], $product_name);
// Soft-delete: negate company_id so row is hidden but recoverable
@@ -2104,6 +2205,10 @@ class WarehouseManager {
$product_sku = $row['product_sku'];
$product_name = $this->getProductName($product_sku);
if ((int)$row['status'] === 1) {
$this->assertStockMovementWindow($row['date'] ?? null, 'Stock-out deletion');
}
$this->validateLatestTransaction($product_sku, $row['date'], $product_name);
// Soft-delete: negate company_id so row is hidden but recoverable
@@ -2174,6 +2279,11 @@ class WarehouseManager {
throw new Exception("Paired destination record missing — data integrity issue.");
}
if ((int)$from_row['status'] === 1 || (int)$to_row['status'] === 1) {
$this->assertStockMovementWindow($from_row['date'] ?? null, 'Stock transfer cancellation');
$this->assertStockMovementWindow($to_row['date'] ?? null, 'Stock transfer cancellation');
}
// Single global validation covers both warehouses (same SKU, same date)
$this->validateLatestTransaction($product_sku, $from_row['date'], $product_name);