diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index a5b3437b..a242b0e5 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -2736,7 +2736,7 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse if (lotLine != null && inventoryBeforeUpdate == null) { throw ResponseStatusException( HttpStatus.BAD_REQUEST, - "找不到對應庫存單位的庫存桶,無法提交不良品/短少。", + "此批次的庫存單位與貨品不符,無法提交不良品/短少。", ) } diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/08_signal_stock_account_wording.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/08_signal_stock_account_wording.sql new file mode 100644 index 00000000..4b2dcd1c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/08_signal_stock_account_wording.sql @@ -0,0 +1,380 @@ +-- liquibase formatted sql + +-- changeset kelvin:20261013-drop-ill-triggers-stock-account +-- comment: Recreate lot-line triggers so the customer-facing SIGNAL names the lot and stock unit, without 庫存桶. +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_insert`; +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_update`; + +-- changeset kelvin:20261013-ill-after-insert-stock-account splitStatements:false +-- comment: AFTER INSERT trigger. Same logic as 20260921-ill-after-insert-signal-zh. SIGNAL names the lot and stock unit. +CREATE DEFINER = CURRENT_USER TRIGGER `inventory_lot_line_AFTER_insert` + AFTER INSERT + ON `inventory_lot_line` + FOR EACH ROW +BEGIN + DECLARE v_itemId INT; + DECLARE v_stockUomId INT; -- bucket key = item_uom.uomId for NEW.stockItemUomId + DECLARE v_baseUomId INT; -- baseUnit uomId; written to inventory.uomId only on CREATE + DECLARE v_inventoryId INT DEFAULT NULL; + DECLARE v_onHand DECIMAL(14, 2) DEFAULT 0; + DECLARE v_unavailable DECIMAL(14, 2) DEFAULT 0; + DECLARE v_currencyId INT DEFAULT NULL; + DECLARE v_currencyName VARCHAR(30) DEFAULT 'HKD'; + DECLARE v_price DECIMAL(14, 2) DEFAULT 0; + + -- Step 1: stockItemUomId is required to know which stock-UOM bucket this line belongs to. + IF NEW.stockItemUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '入倉明細缺少庫存單位,無法入帳'; + END IF; + + -- Step 2: Resolve bucket (itemId, stockUomId) from lot + item_uom. + -- stockItemUomId -> item_uom.id; stockUomId := item_uom.uomId (must belong to the same item). + SELECT il.itemId, iu.uomId + INTO v_itemId, v_stockUomId + FROM `inventory_lot` il + INNER JOIN `item_uom` iu + ON iu.id = NEW.stockItemUomId + AND iu.itemId = il.itemId + AND IFNULL(iu.deleted, 0) = 0 + WHERE il.id = NEW.inventoryLotId + AND IFNULL(il.deleted, 0) = 0 + LIMIT 1; + + IF v_itemId IS NULL OR v_stockUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '此批次的庫存單位與貨品不符,請檢查貨品單位與批次'; + END IF; + + -- Step 3: Resolve base UOM for inventory.uomId snapshot (used only if we INSERT a new inventory row). + SELECT iu.uomId + INTO v_baseUomId + FROM `item_uom` iu + WHERE iu.itemId = v_itemId + AND iu.baseUnit = TRUE + AND IFNULL(iu.deleted, 0) = 0 + LIMIT 1; + + IF v_baseUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '此貨品未設定基本單位,無法建立庫存'; + END IF; + + -- Step 4: Find existing inventory row for this stock-UOM bucket. + SELECT i.id + INTO v_inventoryId + FROM `inventory` i + WHERE i.itemId = v_itemId + AND i.stockUomId = v_stockUomId + AND IFNULL(i.deleted, 0) = 0 + LIMIT 1; + + -- Step 5: Create inventory row if missing (qty starts at 0; Step 6 overwrites from lot lines). + -- uomId = base snapshot once; stockUomId = bucket key. onHoldQty left at 0 and never maintained by this trigger. + IF v_inventoryId IS NULL THEN + -- Prefer currency/price from the lot's stock-in / PO / quotation path; fall back to HKD / first currency. + SELECT c.id, COALESCE(c.name, 'HKD'), COALESCE(pql.price, 0) + INTO v_currencyId, v_currencyName, v_price + FROM `inventory_lot` il + LEFT JOIN `stock_in_line` sil ON sil.id = il.stockInLineId + LEFT JOIN `purchase_order_line` pol ON pol.id = sil.purchaseOrderLineId + LEFT JOIN `purchase_quotation_line` pql + ON pql.itemId = il.itemId AND pql.uomId = pol.uomId + LEFT JOIN `purchase_quotation` pq ON pq.id = pql.purchaseQuotationId + LEFT JOIN `currency` c ON c.id = pq.currencyId + WHERE il.id = NEW.inventoryLotId + LIMIT 1; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE c.code = 'HKD' + AND IFNULL(c.deleted, 0) = 0 + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE IFNULL(c.deleted, 0) = 0 + ORDER BY c.id + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '無法取得幣別,無法建立庫存'; + END IF; + + INSERT INTO `inventory` (`itemId`, + `onHandQty`, + `onHoldQty`, + `unavailableQty`, + `price`, + `currencyId`, + `cpu`, + `cpuUnit`, + `cpm`, + `cpmUnit`, + `uomId`, + `stockUomId`, + `status`) + VALUES (v_itemId, + 0, + 0, + 0, + COALESCE(v_price, 0), + v_currencyId, + 0, + COALESCE(v_currencyName, 'HKD'), + 0, + COALESCE(v_currencyName, 'HKD'), + v_baseUomId, + v_stockUomId, + 'unavailable'); + + SET v_inventoryId = LAST_INSERT_ID(); + END IF; + + -- Step 6: Full recompute for THIS bucket only (not a delta on existing inventory qty). + -- onHand = SUM(inQty - outQty) for all statuses + -- unavailable = SUM(inQty - outQty) for status = unavailable only (holdQty ignored) + SELECT COALESCE(SUM(ill.inQty - ill.outQty), 0), + COALESCE(SUM(CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN ill.inQty - ill.outQty + ELSE 0 END), 0) + INTO v_onHand, v_unavailable + FROM `inventory_lot_line` ill + INNER JOIN `inventory_lot` il ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN `item_uom` iu ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 + WHERE il.itemId = v_itemId + AND iu.uomId = v_stockUomId + AND IFNULL(ill.deleted, 0) = 0; + + -- Step 7: Overwrite inventory qty/status from the SUM (source of truth = lot lines). + -- Do not touch onHoldQty, uomId, or stockUomId on an existing row. + UPDATE `inventory` + SET `onHandQty` = v_onHand, + `unavailableQty` = v_unavailable, + `status` = IF(v_onHand - v_unavailable > 0, 'available', 'unavailable'), + `modified` = CURRENT_TIMESTAMP + WHERE `id` = v_inventoryId; +END; + +-- changeset kelvin:20261013-ill-after-update-stock-account splitStatements:false +-- comment: AFTER UPDATE trigger. Same logic as 20260921-ill-after-update-signal-zh. SIGNAL names the lot and stock unit. +CREATE DEFINER = CURRENT_USER TRIGGER `inventory_lot_line_AFTER_update` + AFTER UPDATE + ON `inventory_lot_line` + FOR EACH ROW +BEGIN + DECLARE v_itemId INT; + DECLARE v_stockUomId INT; -- NEW bucket key + DECLARE v_baseUomId INT; -- baseUnit uomId; used only when creating inventory + DECLARE v_inventoryId INT DEFAULT NULL; + DECLARE v_onHand DECIMAL(14, 2) DEFAULT 0; + DECLARE v_unavailable DECIMAL(14, 2) DEFAULT 0; + DECLARE v_currencyId INT DEFAULT NULL; + DECLARE v_currencyName VARCHAR(30) DEFAULT 'HKD'; + DECLARE v_price DECIMAL(14, 2) DEFAULT 0; + + DECLARE v_oldItemId INT DEFAULT NULL; + DECLARE v_oldStockUomId INT DEFAULT NULL; + DECLARE v_oldInventoryId INT DEFAULT NULL; + DECLARE v_oldOnHand DECIMAL(14, 2) DEFAULT 0; + DECLARE v_oldUnavailable DECIMAL(14, 2) DEFAULT 0; + + -- Step 1: NEW.stockItemUomId is required. + IF NEW.stockItemUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '入倉明細缺少庫存單位,無法入帳'; + END IF; + + -- Step 2: Resolve NEW bucket (itemId, stockUomId). + SELECT il.itemId, iu.uomId + INTO v_itemId, v_stockUomId + FROM `inventory_lot` il + INNER JOIN `item_uom` iu + ON iu.id = NEW.stockItemUomId + AND iu.itemId = il.itemId + AND IFNULL(iu.deleted, 0) = 0 + WHERE il.id = NEW.inventoryLotId + AND IFNULL(il.deleted, 0) = 0 + LIMIT 1; + + IF v_itemId IS NULL OR v_stockUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '此批次的庫存單位與貨品不符,請檢查貨品單位與批次'; + END IF; + + -- Step 3: If stock UOM or lot changed, recompute the OLD bucket so qty does not stick on the wrong row. + -- After this UPDATE, the line already belongs to NEW; OLD-bucket SUM naturally excludes it. + IF OLD.stockItemUomId IS NOT NULL + AND (OLD.stockItemUomId <> NEW.stockItemUomId + OR OLD.inventoryLotId <> NEW.inventoryLotId) THEN + + SELECT il.itemId, iu.uomId + INTO v_oldItemId, v_oldStockUomId + FROM `inventory_lot` il + INNER JOIN `item_uom` iu + ON iu.id = OLD.stockItemUomId + AND iu.itemId = il.itemId + AND IFNULL(iu.deleted, 0) = 0 + WHERE il.id = OLD.inventoryLotId + AND IFNULL(il.deleted, 0) = 0 + LIMIT 1; + + IF v_oldItemId IS NOT NULL + AND v_oldStockUomId IS NOT NULL + AND (v_oldItemId <> v_itemId OR v_oldStockUomId <> v_stockUomId) THEN + + SELECT i.id + INTO v_oldInventoryId + FROM `inventory` i + WHERE i.itemId = v_oldItemId + AND i.stockUomId = v_oldStockUomId + AND IFNULL(i.deleted, 0) = 0 + LIMIT 1; + + IF v_oldInventoryId IS NOT NULL THEN + -- Full SUM for OLD bucket (same formulas as NEW). + SELECT COALESCE(SUM(ill.inQty - ill.outQty), 0), + COALESCE(SUM(CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN ill.inQty - ill.outQty + ELSE 0 END), 0) + INTO v_oldOnHand, v_oldUnavailable + FROM `inventory_lot_line` ill + INNER JOIN `inventory_lot` il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN `item_uom` iu + ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 + WHERE il.itemId = v_oldItemId + AND iu.uomId = v_oldStockUomId + AND IFNULL(ill.deleted, 0) = 0; + + UPDATE `inventory` + SET `onHandQty` = v_oldOnHand, + `unavailableQty` = v_oldUnavailable, + `status` = IF(v_oldOnHand - v_oldUnavailable > 0, 'available', 'unavailable'), + `modified` = CURRENT_TIMESTAMP + WHERE `id` = v_oldInventoryId; + END IF; + END IF; + END IF; + + -- Step 4: Resolve base UOM snapshot (only needed when creating a new inventory row). + SELECT iu.uomId + INTO v_baseUomId + FROM `item_uom` iu + WHERE iu.itemId = v_itemId + AND iu.baseUnit = TRUE + AND IFNULL(iu.deleted, 0) = 0 + LIMIT 1; + + IF v_baseUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '此貨品未設定基本單位,無法建立庫存'; + END IF; + + -- Step 5: Find or create inventory row for the NEW bucket. + SELECT i.id + INTO v_inventoryId + FROM `inventory` i + WHERE i.itemId = v_itemId + AND i.stockUomId = v_stockUomId + AND IFNULL(i.deleted, 0) = 0 + LIMIT 1; + + IF v_inventoryId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD'), COALESCE(pql.price, 0) + INTO v_currencyId, v_currencyName, v_price + FROM `inventory_lot` il + LEFT JOIN `stock_in_line` sil ON sil.id = il.stockInLineId + LEFT JOIN `purchase_order_line` pol ON pol.id = sil.purchaseOrderLineId + LEFT JOIN `purchase_quotation_line` pql + ON pql.itemId = il.itemId AND pql.uomId = pol.uomId + LEFT JOIN `purchase_quotation` pq ON pq.id = pql.purchaseQuotationId + LEFT JOIN `currency` c ON c.id = pq.currencyId + WHERE il.id = NEW.inventoryLotId + LIMIT 1; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE c.code = 'HKD' + AND IFNULL(c.deleted, 0) = 0 + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE IFNULL(c.deleted, 0) = 0 + ORDER BY c.id + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '無法取得幣別,無法建立庫存'; + END IF; + + INSERT INTO `inventory` (`itemId`, + `onHandQty`, + `onHoldQty`, + `unavailableQty`, + `price`, + `currencyId`, + `cpu`, + `cpuUnit`, + `cpm`, + `cpmUnit`, + `uomId`, + `stockUomId`, + `status`) + VALUES (v_itemId, + 0, + 0, + 0, + COALESCE(v_price, 0), + v_currencyId, + 0, + COALESCE(v_currencyName, 'HKD'), + 0, + COALESCE(v_currencyName, 'HKD'), + v_baseUomId, + v_stockUomId, + 'unavailable'); + + SET v_inventoryId = LAST_INSERT_ID(); + END IF; + + -- Step 6: Full SUM for NEW bucket + overwrite. + -- onHand = all statuses; unavailable = unavailable lines only; holdQty ignored. + SELECT COALESCE(SUM(ill.inQty - ill.outQty), 0), + COALESCE(SUM(CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN ill.inQty - ill.outQty + ELSE 0 END), 0) + INTO v_onHand, v_unavailable + FROM `inventory_lot_line` ill + INNER JOIN `inventory_lot` il ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN `item_uom` iu ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 + WHERE il.itemId = v_itemId + AND iu.uomId = v_stockUomId + AND IFNULL(ill.deleted, 0) = 0; + + -- Do not touch onHoldQty, uomId, or stockUomId on an existing row. + UPDATE `inventory` + SET `onHandQty` = v_onHand, + `unavailableQty` = v_unavailable, + `status` = IF(v_onHand - v_unavailable > 0, 'available', 'unavailable'), + `modified` = CURRENT_TIMESTAMP + WHERE `id` = v_inventoryId; +END;