1302 lines
46 KiB
PHP
1302 lines
46 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/forecast.php';
|
|
require_once __DIR__ . '/../../system/audit.php';
|
|
|
|
function get_default_location_ids(PDO $pdo): array
|
|
{
|
|
$row = $pdo->query(
|
|
"SELECT
|
|
(SELECT id FROM location WHERE type = 'storage' ORDER BY id LIMIT 1) AS storage_id,
|
|
(SELECT id FROM location WHERE type = 'dispatch' ORDER BY id LIMIT 1) AS dispatch_id"
|
|
)->fetch();
|
|
|
|
if (!is_array($row) || $row['storage_id'] === null || $row['dispatch_id'] === null) {
|
|
throw new RuntimeException('Erforderliche Lagerorte fehlen');
|
|
}
|
|
|
|
return [
|
|
'storage' => (int) $row['storage_id'],
|
|
'dispatch' => (int) $row['dispatch_id'],
|
|
];
|
|
}
|
|
|
|
function get_otc_order_form_products(PDO $pdo): array
|
|
{
|
|
$stmt = $pdo->query(
|
|
"SELECT
|
|
p.id,
|
|
p.name,
|
|
p.sku,
|
|
p.uom,
|
|
COALESCE(SUM(v.qty_net), 0) AS available_qty
|
|
FROM product p
|
|
LEFT JOIN stock_lot sl
|
|
ON sl.product_id = p.id
|
|
AND sl.status = 'current'
|
|
LEFT JOIN v_stock_lot_balance v
|
|
ON v.stock_lot_id = sl.id
|
|
WHERE p.status = 'active'
|
|
GROUP BY p.id, p.name, p.sku, p.uom
|
|
ORDER BY p.name ASC, p.id ASC"
|
|
);
|
|
|
|
$products = [];
|
|
foreach ($stmt->fetchAll() as $row) {
|
|
$products[] = [
|
|
'id' => (int) $row['id'],
|
|
'name' => (string) $row['name'],
|
|
'sku' => (string) $row['sku'],
|
|
'uom' => (string) $row['uom'],
|
|
'available_qty' => (float) $row['available_qty'],
|
|
'has_sellable_mapping' => false,
|
|
];
|
|
}
|
|
|
|
$mappingStmt = $pdo->query(
|
|
'SELECT DISTINCT product_id
|
|
FROM sellable_item_component'
|
|
);
|
|
$mappedProductIds = array_fill_keys(array_map('intval', $mappingStmt->fetchAll(PDO::FETCH_COLUMN)), true);
|
|
foreach ($products as &$product) {
|
|
$product['has_sellable_mapping'] = isset($mappedProductIds[$product['id']]);
|
|
}
|
|
unset($product);
|
|
|
|
return $products;
|
|
}
|
|
|
|
function get_lager_product_overview(PDO $pdo): array
|
|
{
|
|
$stmt = $pdo->query(
|
|
"SELECT
|
|
p.id,
|
|
p.name,
|
|
p.sku,
|
|
p.uom,
|
|
COALESCE(SUM(v.qty_net), 0) + COALESCE((
|
|
SELECT SUM(open_v.qty_net)
|
|
FROM stock_lot open_sl
|
|
INNER JOIN v_stock_lot_balance open_v ON open_v.stock_lot_id = open_sl.id
|
|
WHERE open_sl.product_id = p.id
|
|
AND open_sl.status = 'open'
|
|
), 0) AS available_qty,
|
|
COALESCE(v.qty_net, 0) AS current_qty,
|
|
COALESCE(v.qty_in, 0) AS qty_in,
|
|
sl.lot_number,
|
|
sl.id AS lot_id,
|
|
sl.manufacturer_party_id,
|
|
sl.manufacturer_lot_number,
|
|
mp.name AS manufacturer_name,
|
|
sl.mfg_date,
|
|
sl.expiry_date,
|
|
sl.purchase_date,
|
|
sl.sellout_date,
|
|
sl.sellout_forecast_status,
|
|
COALESCE((
|
|
SELECT SUM(a.qty)
|
|
FROM sales_order_line_lot_allocation a
|
|
INNER JOIN stock_lot virtual_lot ON virtual_lot.id = a.lot_id
|
|
WHERE virtual_lot.product_id = p.id
|
|
AND virtual_lot.status = 'virtual_shortage'
|
|
AND a.allocation_status = 'reserved'
|
|
), 0) AS virtual_shortage_qty,
|
|
(
|
|
SELECT virtual_lot.lot_number
|
|
FROM stock_lot virtual_lot
|
|
WHERE virtual_lot.product_id = p.id
|
|
AND virtual_lot.status = 'virtual_shortage'
|
|
ORDER BY virtual_lot.id
|
|
LIMIT 1
|
|
) AS virtual_lot_number
|
|
FROM product p
|
|
LEFT JOIN stock_lot sl
|
|
ON sl.product_id = p.id
|
|
AND sl.status = 'current'
|
|
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 p.status = 'active'
|
|
GROUP BY p.id, p.name, p.sku, p.uom, sl.id, sl.lot_number, sl.manufacturer_party_id,
|
|
sl.manufacturer_lot_number, mp.name, sl.mfg_date, sl.expiry_date, sl.purchase_date,
|
|
sl.sellout_date, sl.sellout_forecast_status, v.qty_net, v.qty_in
|
|
ORDER BY p.name ASC, p.id ASC"
|
|
);
|
|
|
|
$products = array_map(
|
|
static fn (array $row): array => [
|
|
'id' => (int) $row['id'],
|
|
'name' => (string) $row['name'],
|
|
'sku' => (string) $row['sku'],
|
|
'uom' => (string) $row['uom'],
|
|
'available_qty' => (float) $row['available_qty'],
|
|
'current_qty' => (float) $row['current_qty'],
|
|
'qty_in' => (float) $row['qty_in'],
|
|
'lot_id' => $row['lot_id'] === null ? null : (int) $row['lot_id'],
|
|
'lot_number' => $row['lot_number'],
|
|
'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'],
|
|
'sellout_date' => $row['sellout_date'],
|
|
'sellout_forecast_status' => (string) ($row['sellout_forecast_status'] ?? 'no_demand'),
|
|
'virtual_shortage_qty' => (float) ($row['virtual_shortage_qty'] ?? 0),
|
|
'virtual_lot_number' => $row['virtual_lot_number'],
|
|
],
|
|
$stmt->fetchAll()
|
|
);
|
|
|
|
$historicalStmt = $pdo->query(
|
|
"SELECT
|
|
sl.product_id,
|
|
sl.id AS lot_id,
|
|
sl.lot_number,
|
|
sl.mfg_date,
|
|
sl.expiry_date,
|
|
sl.purchase_date,
|
|
sl.manufacturer_lot_number,
|
|
mp.name AS manufacturer_name,
|
|
COALESCE(v.qty_in, 0) AS qty_in,
|
|
COALESCE(v.qty_out, 0) AS qty_out,
|
|
COALESCE(v.qty_net, 0) AS qty_net,
|
|
MIN(sm.move_date) FILTER (WHERE sm.move_type = 'in') AS first_in_at,
|
|
MAX(sm.move_date) FILTER (WHERE sm.move_type = 'out') AS last_out_at
|
|
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
|
|
LEFT JOIN stock_move sm ON sm.lot_id = sl.id
|
|
WHERE sl.status = 'closed'
|
|
AND sl.lot_number NOT LIKE 'INIT-%'
|
|
GROUP BY sl.product_id, sl.id, sl.lot_number, sl.mfg_date, sl.expiry_date,
|
|
sl.purchase_date, sl.manufacturer_lot_number, mp.name,
|
|
v.qty_in, v.qty_out, v.qty_net
|
|
ORDER BY sl.product_id, sl.id DESC"
|
|
);
|
|
|
|
$historicalLotsByProduct = [];
|
|
foreach ($historicalStmt->fetchAll() as $row) {
|
|
$historicalLotsByProduct[(int) $row['product_id']][] = [
|
|
'lot_id' => (int) $row['lot_id'],
|
|
'lot_number' => $row['lot_number'],
|
|
'mfg_date' => $row['mfg_date'],
|
|
'expiry_date' => $row['expiry_date'],
|
|
'purchase_date' => $row['purchase_date'],
|
|
'manufacturer_lot_number' => $row['manufacturer_lot_number'],
|
|
'manufacturer_name' => $row['manufacturer_name'],
|
|
'qty_in' => (float) $row['qty_in'],
|
|
'qty_out' => (float) $row['qty_out'],
|
|
'qty_net' => (float) $row['qty_net'],
|
|
'first_in_at' => $row['first_in_at'],
|
|
'last_out_at' => $row['last_out_at'],
|
|
];
|
|
}
|
|
|
|
$openStmt = $pdo->query(
|
|
"SELECT
|
|
sl.product_id,
|
|
sl.id AS lot_id,
|
|
sl.lot_number,
|
|
sl.manufacturer_party_id,
|
|
sl.manufacturer_lot_number,
|
|
mp.name AS manufacturer_name,
|
|
sl.mfg_date,
|
|
sl.expiry_date,
|
|
sl.purchase_date,
|
|
sl.next_active,
|
|
COALESCE(v.qty_in, 0) AS qty_in,
|
|
COALESCE(v.qty_net, 0) AS qty_net
|
|
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.status = 'open'
|
|
ORDER BY sl.product_id, sl.id"
|
|
);
|
|
$openLotsByProduct = [];
|
|
foreach ($openStmt->fetchAll() as $openLot) {
|
|
$openLotsByProduct[(int) $openLot['product_id']][] = [
|
|
'lot_id' => (int) $openLot['lot_id'],
|
|
'lot_number' => $openLot['lot_number'],
|
|
'manufacturer_party_id' => $openLot['manufacturer_party_id'] === null ? null : (int) $openLot['manufacturer_party_id'],
|
|
'manufacturer_lot_number' => $openLot['manufacturer_lot_number'],
|
|
'manufacturer_name' => $openLot['manufacturer_name'],
|
|
'mfg_date' => $openLot['mfg_date'],
|
|
'expiry_date' => $openLot['expiry_date'],
|
|
'purchase_date' => $openLot['purchase_date'],
|
|
'qty_in' => (float) $openLot['qty_net'],
|
|
'qty_net' => (float) $openLot['qty_net'],
|
|
'next_active' => (bool) $openLot['next_active'],
|
|
];
|
|
}
|
|
|
|
foreach ($products as &$product) {
|
|
$product['historical_lots'] = $historicalLotsByProduct[$product['id']] ?? [];
|
|
$product['open_lots'] = $openLotsByProduct[$product['id']] ?? [];
|
|
}
|
|
unset($product);
|
|
|
|
return $products;
|
|
}
|
|
|
|
function get_lager_product_available_qty(PDO $pdo, int $productId): float
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"SELECT COALESCE(SUM(v.qty_net), 0)
|
|
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 IN ('current', 'open')"
|
|
);
|
|
$stmt->execute([':product_id' => $productId]);
|
|
return (float) $stmt->fetchColumn();
|
|
}
|
|
|
|
function get_lager_product_forecast(PDO $pdo, int $productId): array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"SELECT sellout_date, sellout_forecast_status, expiry_date
|
|
FROM stock_lot
|
|
WHERE product_id = :product_id
|
|
AND status = 'current'
|
|
LIMIT 1"
|
|
);
|
|
$stmt->execute([':product_id' => $productId]);
|
|
$forecast = $stmt->fetch();
|
|
if (!is_array($forecast)) {
|
|
throw new RuntimeException('Keine aktuelle Charge fuer Forecast vorhanden');
|
|
}
|
|
return [
|
|
'date' => $forecast['sellout_date'],
|
|
'status' => (string) $forecast['sellout_forecast_status'],
|
|
'expiryDate' => $forecast['expiry_date'],
|
|
];
|
|
}
|
|
|
|
function update_stock_lot_traceability(
|
|
PDO $pdo,
|
|
int $lotId,
|
|
?int $manufacturerPartyId,
|
|
?string $manufacturerLotNumber,
|
|
?string $mfgDate,
|
|
?string $expiryDate,
|
|
?string $purchaseDate,
|
|
string $changedBy,
|
|
float $receiptQty = 0.0,
|
|
float $adjustmentQty = 0.0,
|
|
float $outQty = 0.0,
|
|
?string $lotNumber = null,
|
|
?float $targetQty = null,
|
|
bool $clearLotNumber = false
|
|
): array {
|
|
if ($lotId <= 0) {
|
|
throw new InvalidArgumentException('Chargen-ID fehlt');
|
|
}
|
|
|
|
$manufacturerLotNumber = $manufacturerLotNumber !== null ? trim($manufacturerLotNumber) : null;
|
|
if ($manufacturerLotNumber === '') {
|
|
$manufacturerLotNumber = null;
|
|
}
|
|
$lotNumber = $lotNumber !== null ? trim($lotNumber) : null;
|
|
if ($lotNumber === '' && !$clearLotNumber) {
|
|
throw new InvalidArgumentException('Naurua-Chargennummer fehlt');
|
|
}
|
|
if ($lotNumber === '') {
|
|
$lotNumber = null;
|
|
}
|
|
|
|
if (!is_finite($receiptQty) || !is_finite($adjustmentQty) || !is_finite($outQty)) {
|
|
throw new InvalidArgumentException('Ungueltige Bewegungsmenge');
|
|
}
|
|
if ($targetQty !== null && (!is_finite($targetQty) || $targetQty < 0)) {
|
|
throw new InvalidArgumentException('Ungueltige Chargenmenge');
|
|
}
|
|
if ($receiptQty < 0 || $outQty < 0) {
|
|
throw new InvalidArgumentException('Zugang und Abgang duerfen nicht negativ sein');
|
|
}
|
|
if (fmod($adjustmentQty, 1.0) !== 0.0) {
|
|
throw new InvalidArgumentException('Bestandskorrekturen muessen ganzzahlig sein');
|
|
}
|
|
if ($adjustmentQty !== 0.0 && abs($adjustmentQty) < 0.0001) {
|
|
$adjustmentQty = 0.0;
|
|
}
|
|
|
|
$dates = [
|
|
'mfg_date' => $mfgDate,
|
|
'expiry_date' => $expiryDate,
|
|
'purchase_date' => $purchaseDate,
|
|
];
|
|
foreach ($dates as $field => $value) {
|
|
if ($value !== null && $value !== '') {
|
|
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
|
$errors = DateTimeImmutable::getLastErrors();
|
|
if ($parsed === false || (is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0))) {
|
|
throw new InvalidArgumentException("Ungueltiges Datum fuer {$field}");
|
|
}
|
|
}
|
|
$dates[$field] = $value !== null && $value !== '' ? $value : null;
|
|
}
|
|
|
|
$startedTransaction = !$pdo->inTransaction();
|
|
if ($startedTransaction) {
|
|
$pdo->beginTransaction();
|
|
}
|
|
|
|
try {
|
|
$lotStmt = $pdo->prepare(
|
|
"SELECT id, product_id, lot_number, manufacturer_party_id, manufacturer_lot_number,
|
|
mfg_date, expiry_date, purchase_date, status
|
|
FROM stock_lot
|
|
WHERE id = :id
|
|
FOR UPDATE"
|
|
);
|
|
$lotStmt->execute([':id' => $lotId]);
|
|
$before = $lotStmt->fetch();
|
|
if (!is_array($before)) {
|
|
throw new RuntimeException('Charge nicht gefunden');
|
|
}
|
|
if ((string) $before['status'] === 'virtual_shortage') {
|
|
throw new InvalidArgumentException('Virtuelle Fehlbestandschargen koennen nicht gepflegt werden');
|
|
}
|
|
if ($targetQty !== null && (string) $before['status'] !== 'open') {
|
|
throw new InvalidArgumentException('Nur offene Chargen koennen mit einer Zielmenge bearbeitet werden');
|
|
}
|
|
if ($lotNumber !== null && (string) $before['status'] !== 'open') {
|
|
throw new InvalidArgumentException('Nur offene Chargen koennen neu angelegt werden');
|
|
}
|
|
|
|
if ($manufacturerPartyId !== null) {
|
|
$manufacturerStmt = $pdo->prepare(
|
|
"SELECT p.id
|
|
FROM party p
|
|
INNER JOIN party_role pr ON pr.party_id = p.id AND pr.role_code = 'manufacturer'
|
|
WHERE p.id = :id AND p.status = 'active'
|
|
LIMIT 1"
|
|
);
|
|
$manufacturerStmt->execute([':id' => $manufacturerPartyId]);
|
|
if ($manufacturerStmt->fetchColumn() === false) {
|
|
throw new InvalidArgumentException('Hersteller ist nicht aktiv oder nicht als Hersteller gepflegt');
|
|
}
|
|
}
|
|
|
|
if ($manufacturerPartyId === null && $manufacturerLotNumber !== null) {
|
|
throw new InvalidArgumentException('Hersteller-Chargennummer benoetigt einen Hersteller');
|
|
}
|
|
if ($manufacturerPartyId !== null && $manufacturerLotNumber === null && (string) $before['status'] !== 'open') {
|
|
throw new InvalidArgumentException('Hersteller-Chargennummer fehlt');
|
|
}
|
|
if ($targetQty !== null) {
|
|
$balanceStmt = $pdo->prepare(
|
|
"SELECT COALESCE(SUM(CASE
|
|
WHEN move_type = 'in' THEN qty
|
|
WHEN move_type = 'out' THEN -qty
|
|
WHEN move_type = 'adjustment' AND to_location_id IS NOT NULL AND from_location_id IS NULL THEN qty
|
|
WHEN move_type = 'adjustment' AND from_location_id IS NOT NULL AND to_location_id IS NULL THEN -qty
|
|
ELSE 0
|
|
END), 0)
|
|
FROM stock_move
|
|
WHERE lot_id = :lot_id"
|
|
);
|
|
$balanceStmt->execute([':lot_id' => $lotId]);
|
|
$quantityDelta = $targetQty - (float) $balanceStmt->fetchColumn();
|
|
$receiptQty = $quantityDelta > 0 ? $quantityDelta : 0.0;
|
|
$outQty = 0.0;
|
|
$adjustmentQty = $quantityDelta < 0 ? $quantityDelta : 0.0;
|
|
}
|
|
|
|
if (($adjustmentQty !== 0.0 || $outQty > 0) && (string) $before['status'] !== 'current' && $targetQty === null) {
|
|
throw new InvalidArgumentException('Korrekturen und Abgaenge koennen nur auf der aktiven Charge gebucht werden');
|
|
}
|
|
|
|
$lotNumberAssignment = $clearLotNumber
|
|
? 'NULL'
|
|
: ($lotNumber === null ? 'lot_number' : 'COALESCE(:lot_number, lot_number)');
|
|
$updateStmt = $pdo->prepare(
|
|
"UPDATE stock_lot
|
|
SET lot_number = {$lotNumberAssignment},
|
|
manufacturer_party_id = :manufacturer_party_id,
|
|
manufacturer_lot_number = :manufacturer_lot_number,
|
|
mfg_date = :mfg_date,
|
|
expiry_date = :expiry_date,
|
|
purchase_date = :purchase_date,
|
|
updated_at = NOW()
|
|
WHERE id = :id"
|
|
);
|
|
$updateParams = [
|
|
':manufacturer_party_id' => $manufacturerPartyId,
|
|
':manufacturer_lot_number' => $manufacturerLotNumber,
|
|
':mfg_date' => $dates['mfg_date'],
|
|
':expiry_date' => $dates['expiry_date'],
|
|
':purchase_date' => $dates['purchase_date'],
|
|
':id' => $lotId,
|
|
];
|
|
if (!$clearLotNumber && $lotNumber !== null) {
|
|
$updateParams[':lot_number'] = $lotNumber;
|
|
}
|
|
$updateStmt->execute($updateParams);
|
|
|
|
$after = $before;
|
|
if ($clearLotNumber) {
|
|
$after['lot_number'] = null;
|
|
} elseif ($lotNumber !== null) {
|
|
$after['lot_number'] = $lotNumber;
|
|
}
|
|
$after['manufacturer_party_id'] = $manufacturerPartyId;
|
|
$after['manufacturer_lot_number'] = $manufacturerLotNumber;
|
|
$after['mfg_date'] = $dates['mfg_date'];
|
|
$after['expiry_date'] = $dates['expiry_date'];
|
|
$after['purchase_date'] = $dates['purchase_date'];
|
|
system_write_audit_log($pdo, 'stock_lot', (string) $lotId, 'traceability_updated', $changedBy, $before, $after, [
|
|
'module' => 'erp.lager',
|
|
]);
|
|
|
|
if ($receiptQty > 0 || $adjustmentQty !== 0.0 || $outQty > 0) {
|
|
book_stock_lot_movements($pdo, (int) $before['product_id'], $lotId, $receiptQty, $adjustmentQty, $outQty);
|
|
}
|
|
|
|
if ($startedTransaction) {
|
|
$pdo->commit();
|
|
}
|
|
|
|
return $after;
|
|
} catch (Throwable $error) {
|
|
if ($startedTransaction && $pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $error;
|
|
}
|
|
}
|
|
|
|
function is_qualified_open_lot(PDO $pdo, int $lotId): bool
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"SELECT EXISTS (
|
|
SELECT 1
|
|
FROM stock_lot sl
|
|
INNER JOIN v_stock_lot_balance v ON v.stock_lot_id = sl.id
|
|
WHERE sl.id = :lot_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
|
|
)"
|
|
);
|
|
$stmt->execute([':lot_id' => $lotId]);
|
|
return (bool) $stmt->fetchColumn();
|
|
}
|
|
|
|
function sync_open_lot_activation_selection(PDO $pdo, int $productId): void
|
|
{
|
|
$qualifiedStmt = $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 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
|
|
ORDER BY sl.id
|
|
FOR UPDATE"
|
|
);
|
|
$qualifiedStmt->execute([':product_id' => $productId]);
|
|
$qualifiedIds = array_map('intval', $qualifiedStmt->fetchAll(PDO::FETCH_COLUMN));
|
|
|
|
if (count($qualifiedIds) === 1) {
|
|
$selectedId = $qualifiedIds[0];
|
|
$clearStmt = $pdo->prepare(
|
|
"UPDATE stock_lot
|
|
SET next_active = FALSE, updated_at = NOW()
|
|
WHERE product_id = :product_id AND status = 'open'"
|
|
);
|
|
$clearStmt->execute([':product_id' => $productId]);
|
|
$selectStmt = $pdo->prepare(
|
|
"UPDATE stock_lot
|
|
SET next_active = TRUE, updated_at = NOW()
|
|
WHERE id = :lot_id"
|
|
);
|
|
$selectStmt->execute([':lot_id' => $selectedId]);
|
|
return;
|
|
}
|
|
|
|
if ($qualifiedIds === []) {
|
|
$clearStmt = $pdo->prepare(
|
|
"UPDATE stock_lot
|
|
SET next_active = FALSE, updated_at = NOW()
|
|
WHERE product_id = :product_id AND status = 'open'"
|
|
);
|
|
$clearStmt->execute([':product_id' => $productId]);
|
|
return;
|
|
}
|
|
|
|
$selectedStmt = $pdo->prepare(
|
|
"SELECT id
|
|
FROM stock_lot
|
|
WHERE product_id = :product_id
|
|
AND status = 'open'
|
|
AND next_active = TRUE
|
|
LIMIT 1
|
|
FOR UPDATE"
|
|
);
|
|
$selectedStmt->execute([':product_id' => $productId]);
|
|
$selectedId = $selectedStmt->fetchColumn();
|
|
if ($selectedId !== false && in_array((int) $selectedId, $qualifiedIds, true)) {
|
|
return;
|
|
}
|
|
|
|
$clearStmt = $pdo->prepare(
|
|
"UPDATE stock_lot
|
|
SET next_active = FALSE, updated_at = NOW()
|
|
WHERE product_id = :product_id AND status = 'open'"
|
|
);
|
|
$clearStmt->execute([':product_id' => $productId]);
|
|
}
|
|
|
|
function select_open_lot_for_activation(PDO $pdo, int $productId, int $lotId): void
|
|
{
|
|
if ($productId <= 0 || $lotId <= 0 || !is_qualified_open_lot($pdo, $lotId)) {
|
|
throw new InvalidArgumentException('Nur eine qualifizierte offene Charge kann ausgewaehlt werden');
|
|
}
|
|
|
|
$lotStmt = $pdo->prepare(
|
|
"SELECT product_id, status
|
|
FROM stock_lot
|
|
WHERE id = :lot_id
|
|
FOR UPDATE"
|
|
);
|
|
$lotStmt->execute([':lot_id' => $lotId]);
|
|
$lot = $lotStmt->fetch();
|
|
if (!is_array($lot) || (int) $lot['product_id'] !== $productId || (string) $lot['status'] !== 'open') {
|
|
throw new InvalidArgumentException('Offene Charge nicht gefunden');
|
|
}
|
|
|
|
$startedTransaction = !$pdo->inTransaction();
|
|
if ($startedTransaction) {
|
|
$pdo->beginTransaction();
|
|
}
|
|
try {
|
|
$clearStmt = $pdo->prepare(
|
|
"UPDATE stock_lot
|
|
SET next_active = FALSE, updated_at = NOW()
|
|
WHERE product_id = :product_id AND status = 'open'"
|
|
);
|
|
$clearStmt->execute([':product_id' => $productId]);
|
|
$selectStmt = $pdo->prepare(
|
|
"UPDATE stock_lot
|
|
SET next_active = TRUE, updated_at = NOW()
|
|
WHERE id = :lot_id"
|
|
);
|
|
$selectStmt->execute([':lot_id' => $lotId]);
|
|
if ($startedTransaction) {
|
|
$pdo->commit();
|
|
}
|
|
} catch (Throwable $error) {
|
|
if ($startedTransaction && $pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $error;
|
|
}
|
|
}
|
|
|
|
function get_current_lot_balance_for_update(PDO $pdo, int $productId): array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"SELECT
|
|
sl.id AS lot_id,
|
|
COALESCE((
|
|
SELECT v.qty_net
|
|
FROM v_stock_lot_balance v
|
|
WHERE v.stock_lot_id = sl.id
|
|
), 0) AS qty_net
|
|
FROM stock_lot sl
|
|
WHERE sl.product_id = :product_id
|
|
AND sl.status = 'current'
|
|
LIMIT 1
|
|
FOR UPDATE"
|
|
);
|
|
$stmt->execute([':product_id' => $productId]);
|
|
$row = $stmt->fetch();
|
|
|
|
if ($row === false) {
|
|
throw new RuntimeException("Keine aktuelle Charge fuer Produkt {$productId} vorhanden");
|
|
}
|
|
|
|
return [
|
|
'lot_id' => (int) $row['lot_id'],
|
|
'qty_net' => (float) $row['qty_net'],
|
|
];
|
|
}
|
|
|
|
function get_stock_lot_balance(PDO $pdo, int $lotId): array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"SELECT
|
|
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 v_stock_lot_balance v ON v.stock_lot_id = sl.id
|
|
WHERE sl.id = :lot_id"
|
|
);
|
|
$stmt->execute([':lot_id' => $lotId]);
|
|
$row = $stmt->fetch();
|
|
if (!is_array($row)) {
|
|
throw new RuntimeException('Charge nicht gefunden');
|
|
}
|
|
|
|
return [
|
|
'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,
|
|
int $lotId,
|
|
float $qty,
|
|
int $fromLocationId,
|
|
int $toLocationId,
|
|
string $note
|
|
): int {
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO stock_move (
|
|
product_id, lot_id, from_location_id, to_location_id, qty, move_type, move_date, note, created_at, updated_at
|
|
) VALUES (
|
|
:product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'out', NOW(), :note, NOW(), NOW()
|
|
)
|
|
RETURNING id"
|
|
);
|
|
$stmt->execute([
|
|
':product_id' => $productId,
|
|
':lot_id' => $lotId,
|
|
':from_location_id' => $fromLocationId,
|
|
':to_location_id' => $toLocationId,
|
|
':qty' => $qty,
|
|
':note' => $note,
|
|
]);
|
|
|
|
$id = $stmt->fetchColumn();
|
|
if ($id === false) {
|
|
throw new RuntimeException('Konnte Lagerabgang nicht schreiben');
|
|
}
|
|
|
|
return (int) $id;
|
|
}
|
|
|
|
function insert_stock_move_in(
|
|
PDO $pdo,
|
|
int $productId,
|
|
int $lotId,
|
|
float $qty,
|
|
int $fromLocationId,
|
|
int $toLocationId,
|
|
string $note
|
|
): int {
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO stock_move (
|
|
product_id, lot_id, from_location_id, to_location_id, qty, move_type, move_date, note, created_at, updated_at
|
|
) VALUES (
|
|
:product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'in', NOW(), :note, NOW(), NOW()
|
|
)
|
|
RETURNING id"
|
|
);
|
|
$stmt->execute([
|
|
':product_id' => $productId,
|
|
':lot_id' => $lotId,
|
|
':from_location_id' => $fromLocationId,
|
|
':to_location_id' => $toLocationId,
|
|
':qty' => $qty,
|
|
':note' => $note,
|
|
]);
|
|
|
|
$id = $stmt->fetchColumn();
|
|
if ($id === false) {
|
|
throw new RuntimeException('Konnte Lagerzugang nicht schreiben');
|
|
}
|
|
|
|
return (int) $id;
|
|
}
|
|
|
|
function insert_stock_move_adjustment(
|
|
PDO $pdo,
|
|
int $productId,
|
|
int $lotId,
|
|
float $qty,
|
|
?int $fromLocationId,
|
|
?int $toLocationId,
|
|
string $note
|
|
): int {
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO stock_move (
|
|
product_id, lot_id, from_location_id, to_location_id, qty, move_type, move_date, note, created_at, updated_at
|
|
) VALUES (
|
|
:product_id, :lot_id, :from_location_id, :to_location_id, :qty, 'adjustment', NOW(), :note, NOW(), NOW()
|
|
)
|
|
RETURNING id"
|
|
);
|
|
$stmt->execute([
|
|
':product_id' => $productId,
|
|
':lot_id' => $lotId,
|
|
':from_location_id' => $fromLocationId,
|
|
':to_location_id' => $toLocationId,
|
|
':qty' => $qty,
|
|
':note' => $note,
|
|
]);
|
|
|
|
$id = $stmt->fetchColumn();
|
|
if ($id === false) {
|
|
throw new RuntimeException('Konnte Bestandskorrektur nicht schreiben');
|
|
}
|
|
|
|
return (int) $id;
|
|
}
|
|
|
|
function book_stock_lot_movements(
|
|
PDO $pdo,
|
|
int $productId,
|
|
int $lotId,
|
|
float $receiptQty,
|
|
float $adjustmentQty,
|
|
float $outQty
|
|
): void {
|
|
$locations = get_default_location_ids($pdo);
|
|
$balanceStmt = $pdo->prepare(
|
|
"SELECT COALESCE(
|
|
SUM(CASE
|
|
WHEN move_type = 'in' THEN qty
|
|
WHEN move_type = 'out' THEN -qty
|
|
WHEN move_type = 'adjustment' AND to_location_id IS NOT NULL AND from_location_id IS NULL THEN qty
|
|
WHEN move_type = 'adjustment' AND from_location_id IS NOT NULL AND to_location_id IS NULL THEN -qty
|
|
ELSE 0
|
|
END), 0
|
|
)
|
|
FROM stock_move
|
|
WHERE lot_id = :lot_id"
|
|
);
|
|
$balanceStmt->execute([':lot_id' => $lotId]);
|
|
$balance = (float) $balanceStmt->fetchColumn();
|
|
|
|
if ($receiptQty > 0) {
|
|
insert_stock_move_in($pdo, $productId, $lotId, $receiptQty, $locations['storage'], $locations['storage'], 'Manueller Lagerzugang via Chargenformular');
|
|
$balance += $receiptQty;
|
|
}
|
|
|
|
if ($adjustmentQty !== 0.0) {
|
|
if ($adjustmentQty > 0) {
|
|
insert_stock_move_adjustment($pdo, $productId, $lotId, $adjustmentQty, null, $locations['storage'], 'Manuelle Bestandskorrektur via Chargenformular');
|
|
} else {
|
|
$correctionQty = abs($adjustmentQty);
|
|
if ($correctionQty > $balance) {
|
|
throw new InvalidArgumentException('Bestandskorrektur wuerde den Chargensaldo negativ machen');
|
|
}
|
|
insert_stock_move_adjustment($pdo, $productId, $lotId, $correctionQty, $locations['storage'], null, 'Manuelle Bestandskorrektur via Chargenformular');
|
|
}
|
|
$balance += $adjustmentQty;
|
|
}
|
|
|
|
if ($outQty > $balance) {
|
|
throw new InvalidArgumentException('Lagerabgang ist groesser als der aktuelle Chargenbestand');
|
|
}
|
|
if ($outQty > 0) {
|
|
insert_stock_move_out($pdo, $productId, $lotId, $outQty, $locations['storage'], $locations['dispatch'], 'Manueller Lagerabgang via Chargenformular');
|
|
}
|
|
}
|
|
|
|
function switch_current_lot(PDO $pdo, int $productId, int $oldCurrentLotId, int $storageLocationId): int
|
|
{
|
|
$closeStmt = $pdo->prepare(
|
|
"UPDATE stock_lot
|
|
SET status = 'closed', updated_at = NOW()
|
|
WHERE id = :id"
|
|
);
|
|
$closeStmt->execute([':id' => $oldCurrentLotId]);
|
|
|
|
$openStmt = $pdo->prepare(
|
|
"SELECT id
|
|
FROM stock_lot
|
|
WHERE product_id = :product_id
|
|
AND status = 'open'
|
|
AND lot_number IS NOT NULL
|
|
ORDER BY id
|
|
LIMIT 1
|
|
FOR UPDATE"
|
|
);
|
|
$openStmt->execute([':product_id' => $productId]);
|
|
$newCurrentLotId = $openStmt->fetchColumn();
|
|
|
|
if ($newCurrentLotId === false) {
|
|
throw new RuntimeException("Keine offene Platzhalter-Charge fuer Produkt {$productId} vorhanden");
|
|
}
|
|
|
|
$promoteStmt = $pdo->prepare(
|
|
"UPDATE stock_lot
|
|
SET status = 'current',
|
|
updated_at = NOW()
|
|
WHERE id = :id"
|
|
);
|
|
$promoteStmt->execute([':id' => (int) $newCurrentLotId]);
|
|
|
|
$createOpenStmt = $pdo->prepare(
|
|
"INSERT INTO stock_lot (product_id, status, created_at, updated_at)
|
|
VALUES (:product_id, 'open', NOW(), NOW())
|
|
RETURNING id"
|
|
);
|
|
$createOpenStmt->execute([':product_id' => $productId]);
|
|
$createdOpenLotId = $createOpenStmt->fetchColumn();
|
|
if ($createdOpenLotId === false) {
|
|
throw new RuntimeException("Konnte keine neue offene Platzhalter-Charge fuer Produkt {$productId} anlegen");
|
|
}
|
|
|
|
return (int) $newCurrentLotId;
|
|
}
|
|
|
|
function reverse_existing_allocations_for_order(PDO $pdo, int $orderId, int $fallbackStorageLocationId): array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"SELECT
|
|
a.stock_move_id,
|
|
a.product_id,
|
|
a.lot_id,
|
|
a.qty,
|
|
sm.from_location_id,
|
|
sm.to_location_id
|
|
FROM sales_order_line_lot_allocation a
|
|
LEFT JOIN stock_move sm ON sm.id = a.stock_move_id
|
|
INNER JOIN sales_order_line sol ON sol.id = a.sales_order_line_id
|
|
WHERE sol.sales_order_id = :order_id
|
|
ORDER BY a.id DESC"
|
|
);
|
|
$stmt->execute([':order_id' => $orderId]);
|
|
$rows = $stmt->fetchAll();
|
|
|
|
$reverseCount = 0;
|
|
$reverseQty = 0.0;
|
|
|
|
foreach ($rows as $row) {
|
|
$productId = (int) $row['product_id'];
|
|
$lotId = (int) $row['lot_id'];
|
|
$qty = (float) $row['qty'];
|
|
$fromLocationId = (int) ($row['from_location_id'] ?? $fallbackStorageLocationId);
|
|
$toLocationId = (int) ($row['to_location_id'] ?? $fallbackStorageLocationId);
|
|
|
|
insert_stock_move_in(
|
|
$pdo,
|
|
$productId,
|
|
$lotId,
|
|
$qty,
|
|
$toLocationId,
|
|
$fromLocationId,
|
|
"reverse-order={$orderId}"
|
|
);
|
|
|
|
$reverseCount++;
|
|
$reverseQty += $qty;
|
|
}
|
|
|
|
return [
|
|
'reversedMoves' => $reverseCount,
|
|
'reversedQty' => $reverseQty,
|
|
];
|
|
}
|
|
|
|
function has_available_stock_for_product(PDO $pdo, int $productId, float $epsilon = 0.0000001): bool
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
"SELECT COALESCE(SUM(v.qty_net), 0) AS qty_net
|
|
FROM v_stock_lot_balance v
|
|
INNER JOIN stock_lot sl ON sl.id = v.stock_lot_id
|
|
WHERE sl.product_id = :product_id
|
|
AND sl.status = 'current'"
|
|
);
|
|
$stmt->execute([':product_id' => $productId]);
|
|
$qty = (float) ($stmt->fetchColumn() ?: 0);
|
|
return $qty > $epsilon;
|
|
}
|
|
|
|
function get_or_create_virtual_shortage_lot(PDO $pdo, int $productId): int
|
|
{
|
|
$lotNumber = "VIRT-{$productId}-UNBEKANNT";
|
|
$select = $pdo->prepare(
|
|
"SELECT id
|
|
FROM stock_lot
|
|
WHERE product_id = :product_id
|
|
AND status = 'virtual_shortage'
|
|
AND lot_number = :lot_number
|
|
FOR UPDATE"
|
|
);
|
|
$select->execute([
|
|
':product_id' => $productId,
|
|
':lot_number' => $lotNumber,
|
|
]);
|
|
$lotId = $select->fetchColumn();
|
|
if ($lotId !== false) {
|
|
return (int) $lotId;
|
|
}
|
|
|
|
$insert = $pdo->prepare(
|
|
"INSERT INTO stock_lot (product_id, lot_number, status, created_at, updated_at)
|
|
VALUES (:product_id, :lot_number, 'virtual_shortage', NOW(), NOW())
|
|
RETURNING id"
|
|
);
|
|
$insert->execute([
|
|
':product_id' => $productId,
|
|
':lot_number' => $lotNumber,
|
|
]);
|
|
$newLotId = $insert->fetchColumn();
|
|
if ($newLotId === false) {
|
|
throw new RuntimeException("Virtuelle Fehlbestandscharge fuer Produkt {$productId} konnte nicht angelegt werden");
|
|
}
|
|
|
|
return (int) $newLotId;
|
|
}
|
|
|
|
function create_open_lot(PDO $pdo, int $productId): int
|
|
{
|
|
$insert = $pdo->prepare(
|
|
"INSERT INTO stock_lot (product_id, status, created_at, updated_at)
|
|
VALUES (:product_id, 'open', NOW(), NOW())
|
|
RETURNING id"
|
|
);
|
|
$insert->execute([':product_id' => $productId]);
|
|
$newLotId = $insert->fetchColumn();
|
|
if ($newLotId === false) {
|
|
throw new RuntimeException('Offene Charge konnte nicht angelegt werden');
|
|
}
|
|
|
|
return (int) $newLotId;
|
|
}
|
|
|
|
function delete_open_stock_lot(PDO $pdo, int $lotId, string $changedBy): int
|
|
{
|
|
if ($lotId <= 0 || trim($changedBy) === '') {
|
|
throw new InvalidArgumentException('Offene Charge und Bediener sind erforderlich');
|
|
}
|
|
|
|
$startedTransaction = !$pdo->inTransaction();
|
|
if ($startedTransaction) {
|
|
$pdo->beginTransaction();
|
|
}
|
|
|
|
try {
|
|
$lotStmt = $pdo->prepare(
|
|
"SELECT id, product_id, lot_number, manufacturer_party_id, manufacturer_lot_number,
|
|
mfg_date, expiry_date, purchase_date, status
|
|
FROM stock_lot
|
|
WHERE id = :id
|
|
FOR UPDATE"
|
|
);
|
|
$lotStmt->execute([':id' => $lotId]);
|
|
$lot = $lotStmt->fetch();
|
|
if (!is_array($lot)) {
|
|
throw new InvalidArgumentException('Charge nicht gefunden');
|
|
}
|
|
if ((string) $lot['status'] !== 'open') {
|
|
throw new InvalidArgumentException('Nur offene Chargen koennen geloescht werden');
|
|
}
|
|
|
|
system_write_audit_log($pdo, 'stock_lot', (string) $lotId, 'deleted', $changedBy, $lot, [], [
|
|
'module' => 'erp.lager',
|
|
]);
|
|
|
|
$moveStmt = $pdo->prepare('DELETE FROM stock_move WHERE lot_id = :lot_id');
|
|
$moveStmt->execute([':lot_id' => $lotId]);
|
|
|
|
$deleteStmt = $pdo->prepare('DELETE FROM stock_lot WHERE id = :id AND status = \'open\'');
|
|
$deleteStmt->execute([':id' => $lotId]);
|
|
if ($deleteStmt->rowCount() !== 1) {
|
|
throw new RuntimeException('Offene Charge konnte nicht geloescht werden');
|
|
}
|
|
|
|
$productId = (int) $lot['product_id'];
|
|
if ($startedTransaction) {
|
|
$pdo->commit();
|
|
}
|
|
return $productId;
|
|
} catch (Throwable $error) {
|
|
if ($startedTransaction && $pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $error;
|
|
}
|
|
}
|
|
|
|
function replace_virtual_lot_allocation(
|
|
PDO $pdo,
|
|
int $allocationId,
|
|
int $realLotId,
|
|
float $qty,
|
|
string $changedBy,
|
|
string $reason
|
|
): int {
|
|
if ($qty <= 0 || trim($changedBy) === '' || trim($reason) === '') {
|
|
throw new InvalidArgumentException('Menge, Bediener und Grund sind erforderlich');
|
|
}
|
|
|
|
$startedTransaction = !$pdo->inTransaction();
|
|
if ($startedTransaction) {
|
|
$pdo->beginTransaction();
|
|
}
|
|
|
|
try {
|
|
$allocationStmt = $pdo->prepare(
|
|
"SELECT a.id, a.sales_order_line_id, a.product_id, a.lot_id, a.qty, a.allocation_status,
|
|
sol.sales_order_id, sol.line_no,
|
|
source_lot.status AS source_lot_status,
|
|
target_lot.product_id AS target_product_id,
|
|
target_lot.status AS target_lot_status
|
|
FROM sales_order_line_lot_allocation a
|
|
INNER JOIN sales_order_line sol ON sol.id = a.sales_order_line_id
|
|
INNER JOIN stock_lot source_lot ON source_lot.id = a.lot_id
|
|
INNER JOIN stock_lot target_lot ON target_lot.id = :target_lot_id
|
|
WHERE a.id = :allocation_id
|
|
FOR UPDATE"
|
|
);
|
|
$allocationStmt->execute([
|
|
':allocation_id' => $allocationId,
|
|
':target_lot_id' => $realLotId,
|
|
]);
|
|
$allocation = $allocationStmt->fetch();
|
|
if (!is_array($allocation)) {
|
|
throw new RuntimeException("Chargenzuordnung {$allocationId} fehlt");
|
|
}
|
|
|
|
if (($allocation['allocation_status'] ?? null) !== 'reserved' || $allocation['source_lot_status'] !== 'virtual_shortage') {
|
|
throw new RuntimeException('Nur reservierte virtuelle Chargenzuordnungen koennen ersetzt werden');
|
|
}
|
|
if ((int) $allocation['product_id'] !== (int) $allocation['target_product_id']) {
|
|
throw new RuntimeException('Echte Charge gehoert zu einem anderen Produkt');
|
|
}
|
|
if ($allocation['target_lot_status'] !== 'current') {
|
|
throw new RuntimeException('Echte Zielcharge muss aktuell sein');
|
|
}
|
|
|
|
$sourceQty = (float) $allocation['qty'];
|
|
if ($qty > $sourceQty + 0.0000001) {
|
|
throw new RuntimeException('Ersetzungsmenge uebersteigt virtuelle Zuordnung');
|
|
}
|
|
|
|
$balanceStmt = $pdo->prepare(
|
|
"SELECT COALESCE(v.qty_net, 0)
|
|
FROM v_stock_lot_balance v
|
|
WHERE v.stock_lot_id = :lot_id
|
|
FOR UPDATE"
|
|
);
|
|
$balanceStmt->execute([':lot_id' => $realLotId]);
|
|
$available = (float) ($balanceStmt->fetchColumn() ?: 0);
|
|
if ($available + 0.0000001 < $qty) {
|
|
throw new RuntimeException('Nicht genuegend Bestand in der echten Zielcharge');
|
|
}
|
|
|
|
$locations = get_default_location_ids($pdo);
|
|
$stockMoveId = insert_stock_move_out(
|
|
$pdo,
|
|
(int) $allocation['product_id'],
|
|
$realLotId,
|
|
$qty,
|
|
(int) $locations['storage'],
|
|
(int) $locations['dispatch'],
|
|
"lot-replacement:order={$allocation['sales_order_id']}:line={$allocation['line_no']}:product={$allocation['product_id']}"
|
|
);
|
|
|
|
$releaseStmt = $pdo->prepare(
|
|
"UPDATE sales_order_line_lot_allocation
|
|
SET allocation_status = 'released', released_at = NOW(), updated_at = NOW()
|
|
WHERE id = :id"
|
|
);
|
|
$releaseStmt->execute([':id' => $allocationId]);
|
|
|
|
$remainingQty = $sourceQty - $qty;
|
|
if ($remainingQty > 0.0000001) {
|
|
$residualStmt = $pdo->prepare(
|
|
"INSERT INTO sales_order_line_lot_allocation (
|
|
sales_order_line_id, product_id, lot_id, qty, allocation_status, created_at, updated_at
|
|
) VALUES (
|
|
:sales_order_line_id, :product_id, :lot_id, :qty, 'reserved', NOW(), NOW()
|
|
)"
|
|
);
|
|
$residualStmt->execute([
|
|
':sales_order_line_id' => (int) $allocation['sales_order_line_id'],
|
|
':product_id' => (int) $allocation['product_id'],
|
|
':lot_id' => (int) $allocation['lot_id'],
|
|
':qty' => $remainingQty,
|
|
]);
|
|
}
|
|
|
|
$newAllocationStmt = $pdo->prepare(
|
|
"INSERT INTO sales_order_line_lot_allocation (
|
|
sales_order_line_id, product_id, lot_id, qty, allocation_status, stock_move_id, created_at, updated_at
|
|
) VALUES (
|
|
:sales_order_line_id, :product_id, :lot_id, :qty, 'allocated', :stock_move_id, NOW(), NOW()
|
|
) RETURNING id"
|
|
);
|
|
$newAllocationStmt->execute([
|
|
':sales_order_line_id' => (int) $allocation['sales_order_line_id'],
|
|
':product_id' => (int) $allocation['product_id'],
|
|
':lot_id' => $realLotId,
|
|
':qty' => $qty,
|
|
':stock_move_id' => $stockMoveId,
|
|
]);
|
|
$newAllocationId = $newAllocationStmt->fetchColumn();
|
|
if ($newAllocationId === false) {
|
|
throw new RuntimeException('Neue reale Chargenzuordnung konnte nicht angelegt werden');
|
|
}
|
|
|
|
$replacementStmt = $pdo->prepare(
|
|
"INSERT INTO stock_lot_allocation_replacement (
|
|
from_allocation_id, to_allocation_id, from_lot_id, to_lot_id,
|
|
product_id, qty, changed_by, reason, created_at
|
|
) VALUES (
|
|
:from_allocation_id, :to_allocation_id, :from_lot_id, :to_lot_id,
|
|
:product_id, :qty, :changed_by, :reason, NOW()
|
|
)"
|
|
);
|
|
$replacementStmt->execute([
|
|
':from_allocation_id' => $allocationId,
|
|
':to_allocation_id' => (int) $newAllocationId,
|
|
':from_lot_id' => (int) $allocation['lot_id'],
|
|
':to_lot_id' => $realLotId,
|
|
':product_id' => (int) $allocation['product_id'],
|
|
':qty' => $qty,
|
|
':changed_by' => trim($changedBy),
|
|
':reason' => trim($reason),
|
|
]);
|
|
|
|
if ($startedTransaction) {
|
|
$pdo->commit();
|
|
}
|
|
return (int) $newAllocationId;
|
|
} catch (Throwable $exception) {
|
|
if ($startedTransaction && $pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
function allocate_components_for_line(
|
|
PDO $pdo,
|
|
int $orderId,
|
|
int $lineId,
|
|
int $lineNo,
|
|
array $components,
|
|
float $lineQty,
|
|
array $locations,
|
|
string $movementSource = 'otc-order'
|
|
): array {
|
|
if ($components === []) {
|
|
throw new RuntimeException("Keine Komponenten fuer Verkaufsposition {$lineNo} gefunden");
|
|
}
|
|
|
|
$allocationCount = 0;
|
|
$virtualAllocationCount = 0;
|
|
$forecastProductIds = [];
|
|
foreach ($components as $component) {
|
|
$productId = (int) $component['product_id'];
|
|
$forecastProductIds[$productId] = true;
|
|
$remaining = $lineQty * (float) $component['qty_per_item'];
|
|
$guard = 0;
|
|
|
|
while ($remaining > 0.0000001) {
|
|
$guard++;
|
|
if ($guard > 100) {
|
|
throw new RuntimeException("Allokationsschutz ausgelost fuer Produkt {$productId}");
|
|
}
|
|
|
|
$current = get_current_lot_balance_for_update($pdo, $productId);
|
|
$lotId = $current['lot_id'];
|
|
$available = $current['qty_net'];
|
|
|
|
if ($available <= 0.0000001) {
|
|
$take = $remaining;
|
|
$lotId = get_or_create_virtual_shortage_lot($pdo, $productId);
|
|
$stockMoveId = null;
|
|
$allocationStatus = 'reserved';
|
|
$virtualAllocationCount++;
|
|
} else {
|
|
$take = min($remaining, $available);
|
|
$stockMoveId = insert_stock_move_out(
|
|
$pdo,
|
|
$productId,
|
|
$lotId,
|
|
$take,
|
|
(int) $locations['storage'],
|
|
(int) $locations['dispatch'],
|
|
"{$movementSource}:order={$orderId}:line={$lineNo}:product={$productId}"
|
|
);
|
|
$allocationStatus = 'allocated';
|
|
}
|
|
|
|
$allocationInsert = $pdo->prepare(
|
|
"INSERT INTO sales_order_line_lot_allocation (
|
|
sales_order_line_id, product_id, lot_id, qty, allocation_status, stock_move_id, created_at, updated_at
|
|
) VALUES (
|
|
:sales_order_line_id, :product_id, :lot_id, :qty, :allocation_status, :stock_move_id, NOW(), NOW()
|
|
)"
|
|
);
|
|
|
|
$allocationInsert->execute([
|
|
':sales_order_line_id' => $lineId,
|
|
':product_id' => $productId,
|
|
':lot_id' => $lotId,
|
|
':qty' => $take,
|
|
':allocation_status' => $allocationStatus,
|
|
':stock_move_id' => $stockMoveId,
|
|
]);
|
|
$allocationCount++;
|
|
|
|
$remaining -= $take;
|
|
}
|
|
}
|
|
|
|
foreach (array_keys($forecastProductIds) as $productId) {
|
|
refresh_lager_stock_lot_forecast_for_product($pdo, (int) $productId);
|
|
}
|
|
|
|
return [
|
|
'allocated' => true,
|
|
'allocationCount' => $allocationCount,
|
|
'virtualAllocationCount' => $virtualAllocationCount,
|
|
];
|
|
}
|
|
|
|
function allocate_line_inventory(
|
|
PDO $pdo,
|
|
int $orderId,
|
|
int $lineId,
|
|
int $lineNo,
|
|
float $lineQty,
|
|
int $sellableItemId,
|
|
array $locations,
|
|
string $movementSource = 'otc-order'
|
|
): array {
|
|
$components = get_item_components($pdo, $sellableItemId);
|
|
return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations, $movementSource);
|
|
}
|
|
|
|
function allocate_line_inventory_fallback_product(
|
|
PDO $pdo,
|
|
int $orderId,
|
|
int $lineId,
|
|
int $lineNo,
|
|
float $lineQty,
|
|
int $productId,
|
|
array $locations
|
|
): array {
|
|
$components = [[
|
|
'product_id' => $productId,
|
|
'qty_per_item' => 1.0,
|
|
]];
|
|
return allocate_components_for_line($pdo, $orderId, $lineId, $lineNo, $components, $lineQty, $locations);
|
|
}
|