소스 검색

Lock PS stock to the current stock-unit bucket and return Chinese SIGNAL reasons.

Co-authored-by: Cursor <[email protected]>

#22 no longer joins every inventory row on the PS list. #36 maps SQLSTATE 45000 to HTTP 400 with the trigger message.
fix負數倉
kelvin.yau 8 시간 전
부모
커밋
c2445b72a8
3개의 변경된 파일439개의 추가작업 그리고 3개의 파일을 삭제
  1. +35
    -0
      src/main/java/com/ffii/core/support/ErrorHandler.java
  2. +24
    -3
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt
  3. +380
    -0
      src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/06_signal_messages_zh.sql

+ 35
- 0
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<ErrorRes> error500(final Exception ex) {


+ 24
- 3
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)


+ 380
- 0
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;

불러오는 중...
취소
저장