Complete lot activation and virtual shortage reconciliation

This commit is contained in:
gitea_admin committed 2026-09-08 17:45:19 +02:00
1 parent 400b352522
commit 8f7fb9875a
3 files changed
+276 -34

No files matched your search

+244 -27
View File
@@ -452,7 +452,7 @@ function update_stock_lot_traceability(
]);
if ($receiptQty > 0 || $adjustmentQty !== 0.0 || $outQty > 0) {
book_stock_lot_movements($pdo, (int) $before['product_id'], $lotId, $receiptQty, $adjustmentQty, $outQty);
book_stock_lot_movements($pdo, (int) $before['product_id'], $lotId, $receiptQty, $adjustmentQty, $outQty, $changedBy);
}
if ($startedTransaction) {
@@ -598,24 +598,6 @@ function promote_open_lot_when_virtual_shortage_is_current(PDO $pdo, int $produc
}
try {
$currentStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'virtual_shortage'
ORDER BY id
LIMIT 1
FOR UPDATE"
);
$currentStmt->execute([':product_id' => $productId]);
$virtualShortageLotId = $currentStmt->fetchColumn();
if ($virtualShortageLotId === false || !is_qualified_open_lot($pdo, $lotId)) {
if ($startedTransaction) {
$pdo->commit();
}
return false;
}
$realCurrentStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
@@ -644,6 +626,24 @@ function promote_open_lot_when_virtual_shortage_is_current(PDO $pdo, int $produc
return false;
}
$currentStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'virtual_shortage'
ORDER BY id
LIMIT 1
FOR UPDATE"
);
$currentStmt->execute([':product_id' => $productId]);
$virtualShortageLotId = $currentStmt->fetchColumn();
if (!is_qualified_open_lot($pdo, $lotId)) {
if ($startedTransaction) {
$pdo->commit();
}
return false;
}
$lotStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
@@ -676,13 +676,15 @@ function promote_open_lot_when_virtual_shortage_is_current(PDO $pdo, int $produc
);
$promoteStmt->execute([':lot_id' => $lotId]);
reconcile_virtual_shortage_allocations(
$pdo,
(int) $virtualShortageLotId,
$lotId,
$changedBy,
'Automatische Verrechnung bei Aktivierung einer echten Charge'
);
if ($virtualShortageLotId !== false) {
reconcile_virtual_shortage_allocations(
$pdo,
(int) $virtualShortageLotId,
$lotId,
$changedBy,
'Automatische Verrechnung bei Aktivierung einer echten Charge'
);
}
if ($startedTransaction) {
$pdo->commit();
@@ -768,6 +770,156 @@ function reconcile_virtual_shortage_allocations(
]);
}
function ensure_current_lot_and_reconcile_virtual_shortage(
PDO $pdo,
int $productId,
string $changedBy,
string $reason
): void {
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$currentStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'current'
LIMIT 1
FOR UPDATE"
);
$currentStmt->execute([':product_id' => $productId]);
$currentLotId = $currentStmt->fetchColumn();
if ($currentLotId === false) {
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
}
$balanceStmt = $pdo->prepare(
"SELECT COALESCE(qty_net, 0)
FROM v_stock_lot_balance
WHERE stock_lot_id = :lot_id"
);
$balanceStmt->execute([':lot_id' => (int) $currentLotId]);
$currentQty = (float) ($balanceStmt->fetchColumn() ?: 0);
if ($currentQty <= 0.0000001) {
sync_open_lot_activation_selection($pdo, $productId);
$selectedStmt = $pdo->prepare(
"SELECT sl.id
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status = 'open'
AND sl.next_active = TRUE
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0
LIMIT 1"
);
$selectedStmt->execute([':product_id' => $productId]);
$nextLotId = $selectedStmt->fetchColumn();
if ($nextLotId === false) {
$countStmt = $pdo->prepare(
"SELECT COUNT(*), MIN(sl.id)
FROM stock_lot sl
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status = 'open'
AND NULLIF(TRIM(sl.lot_number), '') IS NOT NULL
AND sl.manufacturer_party_id IS NOT NULL
AND NULLIF(TRIM(sl.manufacturer_lot_number), '') IS NOT NULL
AND sl.mfg_date IS NOT NULL
AND sl.expiry_date IS NOT NULL
AND sl.purchase_date IS NOT NULL
AND v.qty_net > 0"
);
$countStmt->execute([':product_id' => $productId]);
$candidate = $countStmt->fetch(PDO::FETCH_NUM);
if (is_array($candidate) && (int) $candidate[0] === 1) {
$nextLotId = $candidate[1];
}
}
if ($nextLotId !== false && $nextLotId !== null) {
promote_open_lot_when_virtual_shortage_is_current(
$pdo,
$productId,
(int) $nextLotId,
$changedBy
);
}
}
for ($iteration = 0; $iteration < 100; $iteration++) {
$currentStmt->execute([':product_id' => $productId]);
$currentLotId = $currentStmt->fetchColumn();
if ($currentLotId === false) {
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
}
$balanceStmt->execute([':lot_id' => (int) $currentLotId]);
$currentQty = (float) ($balanceStmt->fetchColumn() ?: 0);
if ($currentQty <= 0.0000001) {
break;
}
$virtualStmt = $pdo->prepare(
"SELECT id
FROM stock_lot
WHERE product_id = :product_id
AND status = 'virtual_shortage'
ORDER BY id
LIMIT 1
FOR UPDATE"
);
$virtualStmt->execute([':product_id' => $productId]);
$virtualLotId = $virtualStmt->fetchColumn();
if ($virtualLotId === false) {
break;
}
reconcile_virtual_shortage_allocations(
$pdo,
(int) $virtualLotId,
(int) $currentLotId,
$changedBy,
$reason
);
$remainingStmt = $pdo->prepare(
"SELECT COUNT(*)
FROM sales_order_line_lot_allocation
WHERE lot_id = :lot_id
AND allocation_status = 'reserved'"
);
$remainingStmt->execute([':lot_id' => (int) $virtualLotId]);
if ((int) $remainingStmt->fetchColumn() === 0) {
break;
}
}
if ($iteration >= 100) {
throw new RuntimeException("Virtuelle Fehlbestandscharge fuer Produkt {$productId} konnte nicht vollstaendig verrechnet werden");
}
if ($startedTransaction) {
$pdo->commit();
}
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
}
function select_open_lot_for_activation(PDO $pdo, int $productId, int $lotId): void
{
if ($productId <= 0 || $lotId <= 0) {
@@ -869,6 +1021,46 @@ function get_stock_lot_balance(PDO $pdo, int $lotId): array
];
}
function get_current_lot_snapshot(PDO $pdo, int $productId): array
{
$stmt = $pdo->prepare(
"SELECT sl.id, sl.product_id, sl.lot_number, sl.status, sl.next_active,
sl.manufacturer_party_id, sl.manufacturer_lot_number,
mp.name AS manufacturer_name, sl.mfg_date, sl.expiry_date, sl.purchase_date,
COALESCE(v.qty_net, 0) AS qty_net,
COALESCE(v.qty_in, 0) AS qty_in,
COALESCE(v.qty_out, 0) AS qty_out
FROM stock_lot sl
LEFT JOIN party mp ON mp.id = sl.manufacturer_party_id
LEFT JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
WHERE sl.product_id = :product_id
AND sl.status = 'current'
LIMIT 1"
);
$stmt->execute([':product_id' => $productId]);
$row = $stmt->fetch();
if (!is_array($row)) {
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
}
return [
'id' => (int) $row['id'],
'product_id' => (int) $row['product_id'],
'lot_number' => $row['lot_number'],
'status' => (string) $row['status'],
'next_active' => (bool) $row['next_active'],
'manufacturer_party_id' => $row['manufacturer_party_id'] === null ? null : (int) $row['manufacturer_party_id'],
'manufacturer_lot_number' => $row['manufacturer_lot_number'],
'manufacturer_name' => $row['manufacturer_name'],
'mfg_date' => $row['mfg_date'],
'expiry_date' => $row['expiry_date'],
'purchase_date' => $row['purchase_date'],
'qty_net' => (float) $row['qty_net'],
'qty_in' => (float) $row['qty_in'],
'qty_out' => (float) $row['qty_out'],
];
}
function insert_stock_move_out(
PDO $pdo,
int $productId,
@@ -977,7 +1169,8 @@ function book_stock_lot_movements(
int $lotId,
float $receiptQty,
float $adjustmentQty,
float $outQty
float $outQty,
string $changedBy
): void {
$locations = get_default_location_ids($pdo);
$balanceStmt = $pdo->prepare(
@@ -1012,6 +1205,12 @@ function book_stock_lot_movements(
insert_stock_move_adjustment($pdo, $productId, $lotId, $correctionQty, $locations['storage'], null, 'Manuelle Bestandskorrektur via Chargenformular');
}
$balance += $adjustmentQty;
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
$changedBy,
'Verrechnung nach manueller Bestandskorrektur'
);
}
if ($outQty > $balance) {
@@ -1019,6 +1218,12 @@ function book_stock_lot_movements(
}
if ($outQty > 0) {
insert_stock_move_out($pdo, $productId, $lotId, $outQty, $locations['storage'], $locations['dispatch'], 'Manueller Lagerabgang via Chargenformular');
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
$changedBy,
'Verrechnung nach manuellem Lagerabgang'
);
}
}
@@ -1421,6 +1626,12 @@ function allocate_components_for_line(
throw new RuntimeException("Allokationsschutz ausgelost fuer Produkt {$productId}");
}
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
'system',
"Verrechnung vor Verkaufsallokation: order={$orderId}:line={$lineNo}"
);
$current = get_current_lot_balance_for_update($pdo, $productId);
$lotId = $current['lot_id'];
$available = $current['qty_net'];
@@ -1442,6 +1653,12 @@ function allocate_components_for_line(
(int) $locations['dispatch'],
"{$movementSource}:order={$orderId}:line={$lineNo}:product={$productId}"
);
ensure_current_lot_and_reconcile_virtual_shortage(
$pdo,
$productId,
'system',
"Verrechnung nach Verkaufsabgang: order={$orderId}:line={$lineNo}"
);
$allocationStatus = 'allocated';
}