From 1541c5d1b47560939b0ff44638e0d5efa13859ed Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Sat, 12 Sep 2026 00:24:48 +0800 Subject: [PATCH] Add inventory stock-UOM bucket foundation (schema, triggers, resolver, API). Co-authored-by: Cursor --- .../service/DoWorkbenchMainService.kt | 8 +- .../modules/master/service/ItemUomService.kt | 28 ++- .../fpsms/modules/stock/entity/Inventory.kt | 9 + .../stock/entity/InventoryRepository.kt | 91 ++++---- .../stock/entity/projection/InventoryInfo.kt | 33 +-- .../stock/service/InventoryBucketResolver.kt | 56 +++++ .../modules/stock/service/InventoryService.kt | 40 +--- .../stock/service/StockInLineService.kt | 5 +- .../stock/service/StockOutLineService.kt | 33 +-- .../service/StockOutLineWorkbenchService.kt | 7 +- .../modules/stock/web/InventoryController.kt | 10 +- .../01_add_inventory_stock_uom_id.sql | 17 ++ .../02_add_stock_uom_indexes.sql | 16 ++ .../03_drop_inventory_lot_line_triggers.sql | 10 + ...rite_inventory_lot_line_trigger_insert.sql | 161 +++++++++++++ ...rite_inventory_lot_line_trigger_update.sql | 217 ++++++++++++++++++ 16 files changed, 606 insertions(+), 135 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/InventoryBucketResolver.kt create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/01_add_inventory_stock_uom_id.sql create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/02_add_stock_uom_indexes.sql create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/03_drop_inventory_lot_line_triggers.sql create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/04_rewrite_inventory_lot_line_trigger_insert.sql create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/05_rewrite_inventory_lot_line_trigger_update.sql diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index e1563c5..dba9441 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -46,6 +46,7 @@ import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot +import com.ffii.fpsms.modules.stock.service.InventoryBucketResolver import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus import org.springframework.stereotype.Service @@ -115,6 +116,7 @@ open class DoWorkbenchMainService( private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, private val stockLedgerRepository: StockLedgerRepository, private val itemUomService: ItemUomService, + private val inventoryBucketResolver: InventoryBucketResolver, private val itemUomRespository: ItemUomRespository, private val bagRepository: BagRepository, private val bagService: BagService, @@ -2595,15 +2597,15 @@ return MessageResponse( private fun createWorkbenchPickLedger(sol: StockOutLine, deltaQty: BigDecimal) { if (deltaQty <= BigDecimal.ZERO) return val solItem = sol.item ?: return - val inventory = itemUomService.findInventoryForItemBaseUom(solItem.id!!) ?: return + val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } + ?: sol.inventoryLotLine + val inventory = inventoryBucketResolver.findInventoryBucket(solItem.id!!, ill) ?: return // Fast path (batch-submit style): avoid querying latest ledger. // At this point inventory.onHandQty has already been updated by DB triggers after lot outQty change. // So previousBalance can be reconstructed as (onHandAfter + deltaQty). val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val previousBalance = onHandAfter + deltaQty.toDouble() val newBalance = previousBalance - deltaQty.toDouble() - val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } - ?: sol.inventoryLotLine val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val ledger = StockLedger().apply { this.stockOutLine = sol diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index 275b80c..dd414ba 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -70,19 +70,29 @@ open class ItemUomService( } /** - * 同一 [itemId] 可能對應多筆 [Inventory](不同 uom)。 - * 優先取 `inventory.uomId` 與 `item_uom` 中 `baseUnit = true` 之列的 `uomId` 一致的那一筆; - * 若無 base 設定或無匹配列,則回退為 `findFirstByItemIdAndDeletedIsFalseOrderByIdAsc`。 + * @deprecated ChangeList #4 — do not use. Multi stock-UOM buckets cannot be keyed by base UOM. + * Use [com.ffii.fpsms.modules.stock.service.InventoryBucketResolver.findInventoryBucket] with + * lot-line stockItemUomId / stockUomId instead. + * + * Temporary fallback: looks up by the item's current stockUnit uomId (not base). */ + @Deprecated( + message = "Use InventoryBucketResolver.findInventoryBucket(itemId, lotLine)", + replaceWith = ReplaceWith( + "inventoryBucketResolver.findInventoryBucket(itemId, lotLine)", + "com.ffii.fpsms.modules.stock.service.InventoryBucketResolver", + ), + ) open fun findInventoryForItemBaseUom(itemId: Long): Inventory? { - val base = findBaseUnitByItemId(itemId) - val uomId = base?.uom?.id - if (uomId != null) { - val match = inventoryRepository.findFirstByItemIdAndUomIdAndDeletedIsFalseOrderByIdAsc(itemId, uomId) - if (match != null) { - return match + val stockUnitUomId = findStockUnitByItemId(itemId)?.uom?.id + if (stockUnitUomId != null) { + inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(itemId, stockUnitUomId)?.let { + return it } + inventoryRepository.findFirstByItemIdAndStockUomIdAndDeletedIsFalseOrderByIdAsc(itemId, stockUnitUomId) + ?.let { return it } } + // Last resort only while stockUomId is still null on old rows (pre data patch). return inventoryRepository.findFirstByItemIdAndDeletedIsFalseOrderByIdAsc(itemId) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/Inventory.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/Inventory.kt index 503d478..2a67e76 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/Inventory.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/Inventory.kt @@ -54,10 +54,19 @@ open class Inventory: BaseEntity(){ open var cpmUnit: String? = null // @NotNull + /** Base UOM snapshot only (set when the inventory row is created; not the bucket key). */ @ManyToOne @JoinColumn(name = "uomId") open var uom: UomConversion? = null + /** + * Stock-UOM bucket key = uom_conversion.id. + * Unique with itemId: uk_inventory_item_stock_uom. Nullable until data patch. + */ + @ManyToOne + @JoinColumn(name = "stockUomId") + open var stockUom: UomConversion? = null + // @NotNull @Column(name = "status") open var status: String? = null diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt index 89d7754..21b8fc0 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt @@ -14,43 +14,49 @@ import java.util.Optional interface InventoryRepository: AbstractRepository { fun findInventoryInfoByDeletedIsFalse(): List + /** + * All non-deleted inventory rows matching filters (one row per stock-UOM bucket). + * Does not require baseUnit match — that hid non-base buckets. + */ @Query( - """ - SELECT i FROM Inventory i - WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) - AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) - AND (:type IS NULL OR :type = '' OR i.item.type = :type) - AND i.deleted = false - AND EXISTS ( - SELECT 1 FROM ItemUom iu - WHERE iu.item.id = i.item.id - AND iu.deleted = false - AND iu.baseUnit = true - AND iu.uom.id = i.uom.id - ) - """ -) - fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(code: String, name: String, type: String, pageable: Pageable): Page + """ + SELECT i FROM Inventory i + WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) + AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) + AND (:type IS NULL OR :type = '' OR i.item.type = :type) + AND i.deleted = false + """ + ) + fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( + code: String, + name: String, + type: String, + pageable: Pageable, + ): Page - @Query("SELECT i FROM Inventory i " + - "WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) " + - "AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) " + - "AND (:type IS NULL OR :type = '' OR i.item.type = :type) " + - "AND i.item.id IN :itemIds " + - "AND i.deleted = false") + @Query( + """ + SELECT i FROM Inventory i + WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) + AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) + AND (:type IS NULL OR :type = '' OR i.item.type = :type) + AND i.item.id IN :itemIds + AND i.deleted = false + """ + ) fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( code: String, name: String, type: String, itemIds: List, - pageable: Pageable + pageable: Pageable, ): Page /** - * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 - * Inventory search page only: no baseUnit/uomId match required; - * one row per item = latest inventory id (no larger id for same item). + * Formerly "latest id per item" — that hid older stock-UOM buckets. + * Now returns all matching rows (same as non-latest search). Prefer the non-Latest methods. */ + @Deprecated("Use findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse — returns all stock-UOM buckets") @Query( """ SELECT i FROM Inventory i @@ -58,12 +64,6 @@ interface InventoryRepository: AbstractRepository { AND (:name IS NULL OR :name = '' OR LOWER(i.item.name) LIKE LOWER(CONCAT('%', :name, '%'))) AND (:type IS NULL OR :type = '' OR i.item.type = :type) AND i.deleted = false - AND NOT EXISTS ( - SELECT 1 FROM Inventory i2 - WHERE i2.item.id = i.item.id - AND i2.deleted = false - AND i2.id > i.id - ) """ ) fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( @@ -73,7 +73,7 @@ interface InventoryRepository: AbstractRepository { pageable: Pageable, ): Page - /** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 */ + @Deprecated("Use findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse") @Query( """ SELECT i FROM Inventory i @@ -82,12 +82,6 @@ interface InventoryRepository: AbstractRepository { AND (:type IS NULL OR :type = '' OR i.item.type = :type) AND i.item.id IN :itemIds AND i.deleted = false - AND NOT EXISTS ( - SELECT 1 FROM Inventory i2 - WHERE i2.item.id = i.item.id - AND i2.deleted = false - AND i2.id > i.id - ) """ ) fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( @@ -102,7 +96,13 @@ interface InventoryRepository: AbstractRepository { fun findInventoryInfoByItemInAndDeletedIsFalse(items: List): List + /** + * Unsafe when an item has multiple stock-UOM buckets (NonUniqueResultException). + * Prefer [findByItemIdAndStockUomIdAndDeletedIsFalse] or [findAllByItemIdAndDeletedIsFalse]. + */ + @Deprecated("Multiple stock-UOM buckets per item — use findByItemIdAndStockUomIdAndDeletedIsFalse") fun findByItemId(itemId: Long): Optional + fun findAllByItemIdInAndDeletedIsFalse(itemIds: Collection): List @Query("SELECT i FROM Inventory i WHERE i.item.id = :itemId AND i.deleted = false") @@ -110,6 +110,15 @@ interface InventoryRepository: AbstractRepository { fun findFirstByItemIdAndDeletedIsFalseOrderByIdAsc(itemId: Long): Inventory? - /** 與 item_uom 中 baseUnit=true 之 uomId 對齊時使用,避免同一 item 多筆 inventory 時 NonUniqueResult */ + /** + * Legacy base-UOM lookup. Prefer [findByItemIdAndStockUomIdAndDeletedIsFalse]. + * Shared base uomId across buckets can still collide until callers migrate. + */ + @Deprecated("Prefer findByItemIdAndStockUomIdAndDeletedIsFalse") fun findFirstByItemIdAndUomIdAndDeletedIsFalseOrderByIdAsc(itemId: Long, uomId: Long): Inventory? -} \ No newline at end of file + + /** Resolve one inventory bucket by (itemId, stockUomId). */ + fun findByItemIdAndStockUomIdAndDeletedIsFalse(itemId: Long, stockUomId: Long): Inventory? + + fun findFirstByItemIdAndStockUomIdAndDeletedIsFalseOrderByIdAsc(itemId: Long, stockUomId: Long): Inventory? +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt index be8d777..f623933 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt @@ -14,41 +14,28 @@ interface InventoryInfo{ val itemName: String? @get:Value("#{target.item.type}") val itemType: String? - // @get:Value("#{target.qty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") -// @get:Value("#{target.onHandQty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") @get:Value("#{target.onHandQty}") val onHandQty: BigDecimal? - // @get:Value("#{target.onHoldQty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") @get:Value("#{target.onHoldQty}") val onHoldQty: BigDecimal? - // @get:Value("#{target.unavailableQty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") @get:Value("#{target.unavailableQty}") val unavailableQty: BigDecimal? - // @get:Value("#{(target.onHandQty - target.onHoldQty - target.unavailableQty) / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") - @get:Value("#{(target.onHandQty - target.onHoldQty - target.unavailableQty)}") + /** Available = onHand - unavailable (onHold is not maintained by stock-UOM triggers). */ + @get:Value("#{(target.onHandQty - target.unavailableQty)}") val availableQty: BigDecimal? - @get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom.code}") + @get:Value("#{target.stockUom?.id}") + val stockUomId: Long? + @get:Value("#{target.stockUom?.code}") val uomCode: String? - @get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.udfudesc}") + @get:Value("#{target.stockUom?.udfudesc}") val uomUdfudesc: String? - @get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.udfShortDesc}") + @get:Value("#{target.stockUom?.udfShortDesc}") val uomShortDesc: String? - // @get:Value("#{target.qty * target.uom.gramPerSmallestUnit}") -// val germPerSmallestUnit: BigDecimal? - @get:Value("#{(target.onHandQty - target.onHoldQty - target.unavailableQty)}") + @get:Value("#{(target.onHandQty - target.unavailableQty)}") val qtyPerSmallestUnit: BigDecimal? - @get:Value("#{target.item.itemUoms.^[baseUnit == true && deleted == false]?.uom?.udfudesc}") + /** Base UOM snapshot from inventory.uomId (not current item baseUnit master). */ + @get:Value("#{target.uom?.udfudesc}") val baseUom: String? - // @get:Value("#{target.qty * (target.uom.unit4 != '' ? target.uom.unit4Qty " + -// ": target.uom.unit3 != '' ? target.uom.unit3Qty " + -// ": target.uom.unit2 != '' ? target.uom.unit2Qty " + -// ": target.uom.unit1Qty)}") -// val qtyPerSmallestUnit: BigDecimal? -// @get:Value("#{target.uom.unit4 != '' ? target.uom.unit4 " + -// ": target.uom.unit3 != '' ? target.uom.unit3 " + -// ": target.uom.unit2 != '' ? target.uom.unit2 " + -// ": target.uom.unit1}") -// val smallestUnit: String? val price: BigDecimal? @get:Value("#{target.currency?.name}") val currencyName: String? diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryBucketResolver.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryBucketResolver.kt new file mode 100644 index 0000000..c3d529c --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryBucketResolver.kt @@ -0,0 +1,56 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.master.entity.ItemUom +import com.ffii.fpsms.modules.master.entity.ItemUomRespository +import com.ffii.fpsms.modules.stock.entity.Inventory +import com.ffii.fpsms.modules.stock.entity.InventoryLotLine +import com.ffii.fpsms.modules.stock.entity.InventoryRepository +import org.springframework.stereotype.Service + +/** + * ChangeList #34 — single door for inventory bucket lookup by (itemId, stockUomId). + * + * stockUomId = item_uom.uomId for the lot-line's stockItemUomId (same as DB trigger). + * Modules must call this instead of findByItemId / findInventoryForItemBaseUom. + */ +@Service +open class InventoryBucketResolver( + private val itemUomRepository: ItemUomRespository, + private val inventoryRepository: InventoryRepository, +) { + /** item_uom.id → uom_conversion.id used as inventory.stockUomId. */ + open fun resolveStockUomId(stockItemUomId: Long): Long? { + val itemUom = itemUomRepository.findByIdAndDeletedIsFalse(stockItemUomId) ?: return null + return resolveStockUomId(itemUom) + } + + open fun resolveStockUomId(itemUom: ItemUom?): Long? = itemUom?.uom?.id + + open fun resolveStockUomId(lotLine: InventoryLotLine?): Long? = + resolveStockUomId(lotLine?.stockUom) ?: lotLine?.stockUom?.id?.let { resolveStockUomId(it) } + + open fun findInventoryBucket(itemId: Long, stockUomId: Long): Inventory? = + inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(itemId, stockUomId) + ?: inventoryRepository.findFirstByItemIdAndStockUomIdAndDeletedIsFalseOrderByIdAsc(itemId, stockUomId) + + /** + * Preferred path: resolve stockUomId from the lot line, then find the bucket. + * If the lot line has no stock UOM, falls back to the item's current stockUnit uomId + * (transition only — callers with a lot line should always set stockItemUomId). + */ + open fun findInventoryBucket(itemId: Long, lotLine: InventoryLotLine?): Inventory? { + val fromLot = resolveStockUomId(lotLine) + if (fromLot != null) { + return findInventoryBucket(itemId, fromLot) + } + val stockUnitUomId = + itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id + ?: return null + return findInventoryBucket(itemId, stockUnitUomId) + } + + open fun findInventoryBucketByStockItemUomId(itemId: Long, stockItemUomId: Long): Inventory? { + val stockUomId = resolveStockUomId(stockItemUomId) ?: return null + return findInventoryBucket(itemId, stockUomId) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt index d909d74..ae0e4cc 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt @@ -89,41 +89,13 @@ open class InventoryService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 - * Inventory search page only: no baseUnit/uomId filter; - * one inventory row per item (latest id). Shared getRecordByPage is unchanged. + * ChangeList #8 — deprecated alias of [allInventoriesByPage] (all stock-UOM buckets). */ + @Deprecated("Prefer allInventoriesByPage — both return all stock-UOM rows") open fun searchInventoriesByLatestInventory( request: SearchInventoryRequest, ): RecordsRes { - val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10) - val code = request.code?.trim().orEmpty() - val name = request.name?.trim().orEmpty() - val type = request.type?.trim().orEmpty() - val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } - - val response = if (lotNo != null) { - val itemIds = inventoryLotLineRepository.findDistinctItemIdsByLotNo(lotNo) - if (itemIds.isEmpty()) { - return RecordsRes(emptyList(), 0) - } - inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( - code = code, - name = name, - type = type, - itemIds = itemIds, - pageable = pageable, - ) - } else { - inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( - code = code, - name = name, - type = type, - pageable = pageable, - ) - } - - return RecordsRes(response.content, response.totalElements.toInt()) + return allInventoriesByPage(request) } open fun allInventoriesByItems(items: List): List{ @@ -366,17 +338,13 @@ open class InventoryService( val warehouse = warehouseRepository.findAll().find { it.code == request.warehouseCode }!! // val salesUnit = itemUomService.findSalesUnitByItemId(itemId = inventoryLot.item!!.id!!) val stockUnit = itemUomService.findStockUnitByItemId(itemId = inventoryLot.item!!.id!!) -// val zero = BigDecimal.ZERO -// val one = BigDecimal.ONE -// val ratio = (salesUnit?.ratioN ?: zero).divide(salesUnit?.ratioD ?: one) - + // ChangeList #8: lot-line stockItemUomId must be set so trigger writes inventory.stockUomId. InventoryLotLine().apply { this.inventoryLot = inventoryLot this.warehouse = warehouse this.inQty = request.qty this.status = InventoryLotLineStatus.AVAILABLE this.stockUom = stockUnit -// this.stockUom = salesUnit } } val savedInventoryLotLine = inventoryLotLineRepository.saveAllAndFlush(inventoryLotLineEntries) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index fa4c1ab..ebae233 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -103,6 +103,7 @@ open class StockInLineService( private val deliveryOrderRepository: DeliveryOrderRepository, private val stockLedgerRepository: StockLedgerRepository, private val inventoryRepository: InventoryRepository, + private val inventoryBucketResolver: InventoryBucketResolver, private val m18GoodsReceiptNoteService: M18GoodsReceiptNoteService, private val m18GoodsReceiptNoteLogRepository: M18GoodsReceiptNoteLogRepository, /** Lazy to avoid circular dependency: M18PurchaseOrderService → … → StockInLineService. */ @@ -1389,8 +1390,8 @@ open class StockInLineService( val item = stockInLine.item ?: return val _tInv = System.nanoTime() - val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return - _logStep("inventoryRepository.findInventoryForItemBaseUom", _tInv) + val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockInLine.inventoryLotLine) ?: return + _logStep("inventoryBucketResolver.findInventoryBucket", _tInv) // ✅ 修复:查询最新的 stock_ledger 记录,基于前一笔 balance 计算 val _tLatest = System.nanoTime() diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index e32492d..759abbb 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -81,6 +81,7 @@ private val inventoryLotLineService: InventoryLotLineService, private val inventoryRepository: InventoryRepository, private val pickExecutionIssueRepository: PickExecutionIssueRepository, private val itemUomService: ItemUomService, + private val inventoryBucketResolver: InventoryBucketResolver, @Lazy private val doReplenishmentService: DoReplenishmentService, ): AbstractBaseEntityService(jdbcDao, stockOutLineRepository) { @@ -1359,7 +1360,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { } val item = savedSol.item val inventoryBeforeUpdate = item?.id?.let { itemId -> - itemUomService.findInventoryForItemBaseUom(itemId) + inventoryBucketResolver.findInventoryBucket(itemId, ill) } val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() @@ -1374,7 +1375,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { } else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) { val item = savedSol.item val inventoryBeforeUpdate = item?.id?.let { itemId -> - itemUomService.findInventoryForItemBaseUom(itemId) + inventoryBucketResolver.findInventoryBucket(itemId, savedSol.inventoryLotLine) } val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() @@ -1512,7 +1513,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */ private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) { val item = stockOutLine.item ?: return - val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return + val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockOutLine.inventoryLotLine) ?: return val outQty = stockOutLine.qty?.toDouble() ?: 0.0 // Use latest ledger balance (same pattern as createStockLedgerForStockIn) so balance is correct when multiple actions run in one transaction @@ -1782,10 +1783,10 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { return } - val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) + val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockOutLine.inventoryLotLine) if (inventory == null) { if (flushAfterSave) { - println("${tracePrefix}Skip ledger creation: inventory not found by itemId=${item.id}") + println("${tracePrefix}Skip ledger creation: inventory bucket not found by itemId=${item.id}") } return } @@ -2198,7 +2199,7 @@ fun applyStockOutLineDelta( val itemId = savedSol.item?.id if (itemId != null) { - val inv = itemUomService.findInventoryForItemBaseUom(itemId) + val inv = inventoryBucketResolver.findInventoryBucket(itemId, savedSol.inventoryLotLine) if (inv != null) { val zero = BigDecimal.ZERO inv.onHandQty = (inv.onHandQty ?: zero).minus(deltaQty) @@ -2213,7 +2214,7 @@ fun applyStockOutLineDelta( // 3) stock_ledger (same ledger shape as createStockLedgerForStockOut / completeStockOutAfterLotLineSave) if (!skipLedgerWrite) { val item = savedSol.item ?: return savedSol - val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return savedSol + val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, savedSol.inventoryLotLine) ?: return savedSol val previousBalance = resolvePreviousBalance( itemId = item.id!!, @@ -2349,18 +2350,22 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult val savedLines = stockOutLineRepository.saveAll(stockOutLinesToInsert) // 5) 批量组装 ledger(避免每笔 createStockLedgerForStockOut) - // Keep per-item running balance so multiple lines in the same batch + // Keep per-bucket running balance so multiple lines in the same batch // chain from the previous ledger balance instead of reusing inventory.onHandQty. - val runningLedgerBalanceByItemId = mutableMapOf() - val inventoryByItemId = mutableMapOf() + val runningLedgerBalanceByBucket = mutableMapOf, Double>() + val inventoryByBucket = mutableMapOf, Inventory>() savedLines.sortedBy { it.id ?: Long.MAX_VALUE }.forEach { sol -> val item = sol.item ?: return@forEach val itemId = item.id ?: return@forEach - val inv = inventoryByItemId[itemId] - ?: itemUomService.findInventoryForItemBaseUom(itemId)?.also { inventoryByItemId[itemId] = it } + val stockUomId = inventoryBucketResolver.resolveStockUomId(sol.inventoryLotLine) + ?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id + ?: return@forEach + val bucketKey = itemId to stockUomId + val inv = inventoryByBucket[bucketKey] + ?: inventoryBucketResolver.findInventoryBucket(itemId, stockUomId)?.also { inventoryByBucket[bucketKey] = it } ?: return@forEach val delta = BigDecimal.valueOf(sol.qty ?: 0.0) - val prevBalance = runningLedgerBalanceByItemId[itemId] + val prevBalance = runningLedgerBalanceByBucket[bucketKey] ?: run { val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) latestLedger?.balance ?: (inv.onHandQty ?: BigDecimal.ZERO).toDouble() @@ -2392,7 +2397,7 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult signedDelta = delta.negate(), ) ledgersToInsert += ledger - runningLedgerBalanceByItemId[itemId] = newBalance + runningLedgerBalanceByBucket[bucketKey] = newBalance } stockLedgerRepository.saveAll(ledgersToInsert) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt index efcf4c3..6744f73 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt @@ -62,6 +62,7 @@ open class StockOutLineWorkbenchService( private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, private val stockLedgerRepository: StockLedgerRepository, private val itemUomService: ItemUomService, + private val inventoryBucketResolver: InventoryBucketResolver, private val itemUomRespository: ItemUomRespository, private val bagRepository: BagRepository, private val bagService: BagService, @@ -512,13 +513,13 @@ if (updated == 0) { if (deltaQty <= BigDecimal.ZERO) return val sol = stockOutLIneRepository.findById(stockOutLineId).orElse(null) ?: return val solItem = sol.item ?: return - val inventory = itemUomService.findInventoryForItemBaseUom(solItem.id!!) ?: return + val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } + ?: sol.inventoryLotLine + val inventory = inventoryBucketResolver.findInventoryBucket(solItem.id!!, ill) ?: return // Fast path: use inventory onHand after update + delta to reconstruct previous balance. val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val previousBalance = onHandAfter + deltaQty.toDouble() val newBalance = previousBalance - deltaQty.toDouble() - val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } - ?: sol.inventoryLotLine val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val ledger = StockLedger().apply { this.stockOutLine = sol diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt index 20cf216..1dfd2ba 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt @@ -16,20 +16,22 @@ class InventoryController( return inventoryService.allInventories() } + /** Full inventory search: one row per (itemId, stockUomId) bucket. */ @GetMapping("/getRecordByPage") fun allInventoriesByPage(@ModelAttribute request: SearchInventoryRequest): RecordsRes { - return inventoryService.allInventoriesByPage(request); + return inventoryService.allInventoriesByPage(request) } /** - * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 - * Inventory search page only: latest inventory row per item, no baseUnit/uomId filter. + * ChangeList #8 — deprecated. Formerly returned only max(id) per item (hid older stock UOMs). + * Now aliases [allInventoriesByPage] (all buckets). Prefer GET /inventory/getRecordByPage. */ + @Deprecated("Use GET /inventory/getRecordByPage — returns all stock-UOM buckets") @GetMapping("/searchLatest/getRecordByPage") fun searchInventoriesByLatestInventory( @ModelAttribute request: SearchInventoryRequest, ): RecordsRes { - return inventoryService.searchInventoriesByLatestInventory(request) + return inventoryService.allInventoriesByPage(request) } @PostMapping("/import-bom") diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/01_add_inventory_stock_uom_id.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/01_add_inventory_stock_uom_id.sql new file mode 100644 index 0000000..9152bfe --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/01_add_inventory_stock_uom_id.sql @@ -0,0 +1,17 @@ +-- liquibase formatted sql + +-- changeset kelvin:20260910-add-inventory-stock-uom-id +-- comment: Add inventory.stockUomId as the stock-UOM bucket key. Nullable until the post-go-live data patch backfills existing rows. + +ALTER TABLE `inventory` + ADD COLUMN `stockUomId` INT NULL + COMMENT 'Stock-UOM bucket key: uom_conversion.id. Resolved from inventory_lot_line.stockItemUomId -> item_uom.uomId. Distinct from uomId (base-UOM snapshot only).' + AFTER `uomId`; + +ALTER TABLE `inventory` + ADD CONSTRAINT `FK_INVENTORY_STOCK_UOM` + FOREIGN KEY (`stockUomId`) REFERENCES `uom_conversion` (`id`); + +-- One inventory row per (itemId, stockUomId). MySQL UNIQUE allows multiple NULLs, so pre-patch rows may keep stockUomId NULL. +ALTER TABLE `inventory` + ADD UNIQUE KEY `uk_inventory_item_stock_uom` (`itemId`, `stockUomId`); diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/02_add_stock_uom_indexes.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/02_add_stock_uom_indexes.sql new file mode 100644 index 0000000..735e3e7 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/02_add_stock_uom_indexes.sql @@ -0,0 +1,16 @@ +-- liquibase formatted sql + +-- changeset kelvin:20260910-add-stock-uom-indexes +-- comment: Supporting indexes for AFTER INSERT/UPDATE triggers that resolve and full-SUM one (itemId, stockUomId) bucket. + +-- Join path: inventory_lot_line.stockItemUomId -> item_uom (bucket resolve + SUM filter). +CREATE INDEX `idx_ill_stock_item_uom` + ON `inventory_lot_line` (`stockItemUomId`); + +-- Join path: inventory_lot_line -> inventory_lot; includes deleted for the trigger filter IFNULL(deleted,0)=0. +CREATE INDEX `idx_ill_lot_deleted` + ON `inventory_lot_line` (`inventoryLotId`, `deleted`); + +-- Join path: inventory_lot by itemId for SUM WHERE il.itemId = :itemId (complements existing lotNo+itemId index). +CREATE INDEX `idx_il_item_deleted` + ON `inventory_lot` (`itemId`, `deleted`); diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/03_drop_inventory_lot_line_triggers.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/03_drop_inventory_lot_line_triggers.sql new file mode 100644 index 0000000..b55e74d --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/03_drop_inventory_lot_line_triggers.sql @@ -0,0 +1,10 @@ +-- liquibase formatted sql + +-- changeset kelvin:20260910-drop-ill-triggers +-- comment: Drop legacy inventory_lot_line triggers (baseUnit lookup + delta qty). Replaced by stockUomId full-SUM triggers in 04/05. + +-- Drop both case variants; MySQL trigger name casing can differ by platform history. +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_insert`; +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_INSERT`; +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_update`; +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_UPDATE`; diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/04_rewrite_inventory_lot_line_trigger_insert.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/04_rewrite_inventory_lot_line_trigger_insert.sql new file mode 100644 index 0000000..00d4d7a --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/04_rewrite_inventory_lot_line_trigger_insert.sql @@ -0,0 +1,161 @@ +-- liquibase formatted sql + +-- changeset kelvin:20260910-ill-after-insert-stock-uom splitStatements:false +-- comment: AFTER INSERT on inventory_lot_line. Resolve (itemId, stockUomId) bucket, ensure inventory row, full-SUM overwrite onHand/unavailable/status. SIGNAL rolls back the lot-line write. Does not write onHoldQty. Sets uomId (base snapshot) only when creating a new inventory row. + +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 = 'inventory_lot_line.stockItemUomId is required'; + 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 = 'Cannot resolve stockUomId from stockItemUomId / inventoryLotId'; + 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 = 'item has no baseUnit item_uom for inventory.uomId snapshot'; + 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 = 'Cannot resolve currencyId for new inventory row'; + 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; diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/05_rewrite_inventory_lot_line_trigger_update.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/05_rewrite_inventory_lot_line_trigger_update.sql new file mode 100644 index 0000000..149d7a5 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/05_rewrite_inventory_lot_line_trigger_update.sql @@ -0,0 +1,217 @@ +-- liquibase formatted sql + +-- changeset kelvin:20260910-ill-after-update-stock-uom splitStatements:false +-- comment: AFTER UPDATE on inventory_lot_line. Full-SUM the NEW (itemId, stockUomId) bucket. If the line moved to a different bucket, also full-SUM the OLD bucket. SIGNAL rolls back on resolve failure. Does not write onHoldQty. + +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 = 'inventory_lot_line.stockItemUomId is required'; + 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 = 'Cannot resolve stockUomId from stockItemUomId / inventoryLotId'; + 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 = 'item has no baseUnit item_uom for inventory.uomId snapshot'; + 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 = 'Cannot resolve currencyId for new inventory row'; + 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;