accounting workflows
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user