From c2445b72a86ae2eae4e874b63c277d24f8079788 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Mon, 21 Sep 2026 17:47:57 +0800 Subject: [PATCH] Lock PS stock to the current stock-unit bucket and return Chinese SIGNAL reasons. Co-authored-by: Cursor #22 no longer joins every inventory row on the PS list. #36 maps SQLSTATE 45000 to HTTP 400 with the trigger message. --- .../com/ffii/core/support/ErrorHandler.java | 35 ++ .../modules/jobOrder/service/PSService.kt | 27 +- .../06_signal_messages_zh.sql | 380 ++++++++++++++++++ 3 files changed, 439 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/06_signal_messages_zh.sql diff --git a/src/main/java/com/ffii/core/support/ErrorHandler.java b/src/main/java/com/ffii/core/support/ErrorHandler.java index c8f5c10..de86756 100644 --- a/src/main/java/com/ffii/core/support/ErrorHandler.java +++ b/src/main/java/com/ffii/core/support/ErrorHandler.java @@ -1,9 +1,11 @@ package com.ffii.core.support; +import java.sql.SQLException; import java.util.UUID; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.dao.DataAccessException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; @@ -33,6 +35,39 @@ public class ErrorHandler extends ResponseEntityExceptionHandler { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } + /** MySQL SIGNAL SQLSTATE 45000 is a rejected business write, not an unknown crash. */ + @ExceptionHandler(DataAccessException.class) + public ResponseEntity sqlSignal(final DataAccessException ex) { + String signal = sqlSignalMessage(ex); + if (signal != null) { + logger.warn("SQLSTATE 45000: " + signal); + return new ResponseEntity<>(new FailureRes(signal), HttpStatus.BAD_REQUEST); + } + return error500(ex); + } + + private String sqlSignalMessage(Throwable ex) { + String shortest = null; + Throwable current = ex; + while (current != null) { + if (current instanceof SQLException sql && "45000".equals(sql.getSQLState())) { + String message = sql.getMessage(); + if (message != null && !message.isBlank()) { + String trimmed = message.trim(); + if (shortest == null || trimmed.length() < shortest.length()) { + shortest = trimmed; + } + } + } + Throwable next = current.getCause(); + if (next == current) { + break; + } + current = next; + } + return shortest; + } + @ExceptionHandler({ InternalServerErrorException.class, Exception.class }) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ResponseEntity error500(final Exception ex) { diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt index f4cba6b..27d606c 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt @@ -77,10 +77,31 @@ open class PSService( FROM bom LEFT JOIN items ON bom.itemId = items.id - LEFT JOIN inventory ON items.id = inventory.itemId - LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode - LEFT JOIN item_uom iu ON iu.itemId = items.id AND iu.stockUnit = 1 + LEFT JOIN item_uom iu + ON iu.itemId = items.id + AND iu.stockUnit = 1 + AND IFNULL(iu.deleted, 0) = 0 + AND iu.id = ( + SELECT MIN(iu2.id) + FROM item_uom iu2 + WHERE iu2.itemId = items.id + AND iu2.stockUnit = 1 + AND IFNULL(iu2.deleted, 0) = 0 + ) LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu.uomId + LEFT JOIN inventory ON inventory.id = ( + SELECT i2.id + FROM inventory i2 + WHERE i2.itemId = items.id + AND IFNULL(i2.deleted, 0) = 0 + AND ( + i2.stockUomId = iu.uomId + OR (i2.stockUomId IS NULL AND i2.uomId = iu.uomId) + ) + ORDER BY CASE WHEN i2.stockUomId IS NULL THEN 1 ELSE 0 END, i2.id + LIMIT 1 + ) + LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode WHERE bom.deleted = 0 and bom.description = 'FG' """.trimIndent() return jdbcDao.queryForList(sql, args) diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/06_signal_messages_zh.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/06_signal_messages_zh.sql new file mode 100644 index 0000000..faa525f --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/06_signal_messages_zh.sql @@ -0,0 +1,380 @@ +-- liquibase formatted sql + +-- changeset kelvin:20260921-drop-ill-triggers-for-zh +-- comment: #36 Drop stock-UOM triggers so the next changesets can recreate them with Chinese SIGNAL text. +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_insert`; +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_update`; + +-- changeset kelvin:20260921-ill-after-insert-signal-zh splitStatements:false +-- comment: #36 Recreate AFTER INSERT trigger. Same bucket logic as 20260910-ill-after-insert-stock-uom. SIGNAL text is Chinese for API/UI. +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:20260921-ill-after-update-signal-zh splitStatements:false +-- comment: #36 Recreate AFTER UPDATE trigger. Same bucket logic as 20260910-ill-after-update-stock-uom. SIGNAL text is Chinese for API/UI. +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;