Co-authored-by: Cursor <[email protected]>fix負數倉
| @@ -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 | |||
| @@ -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) | |||
| } | |||
| @@ -54,10 +54,19 @@ open class Inventory: BaseEntity<Long>(){ | |||
| 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 | |||
| @@ -14,43 +14,49 @@ import java.util.Optional | |||
| interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||
| fun findInventoryInfoByDeletedIsFalse(): List<InventoryInfo> | |||
| /** | |||
| * 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<InventoryInfo> | |||
| """ | |||
| 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<InventoryInfo> | |||
| @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<Long>, | |||
| pageable: Pageable | |||
| pageable: Pageable, | |||
| ): Page<InventoryInfo> | |||
| /** | |||
| * 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<Inventory, Long> { | |||
| 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<Inventory, Long> { | |||
| pageable: Pageable, | |||
| ): Page<InventoryInfo> | |||
| /** 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<Inventory, Long> { | |||
| 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<Inventory, Long> { | |||
| fun findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo> | |||
| /** | |||
| * 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<Inventory> | |||
| fun findAllByItemIdInAndDeletedIsFalse(itemIds: Collection<Long>): List<Inventory> | |||
| @Query("SELECT i FROM Inventory i WHERE i.item.id = :itemId AND i.deleted = false") | |||
| @@ -110,6 +110,15 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||
| 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? | |||
| } | |||
| /** Resolve one inventory bucket by (itemId, stockUomId). */ | |||
| fun findByItemIdAndStockUomIdAndDeletedIsFalse(itemId: Long, stockUomId: Long): Inventory? | |||
| fun findFirstByItemIdAndStockUomIdAndDeletedIsFalseOrderByIdAsc(itemId: Long, stockUomId: Long): Inventory? | |||
| } | |||
| @@ -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? | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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<InventoryInfo> { | |||
| 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<Items>): List<InventoryInfo>{ | |||
| @@ -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) | |||
| @@ -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() | |||
| @@ -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<StockOutLine, Long, StockOutLIneRepository>(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<Long, Double>() | |||
| val inventoryByItemId = mutableMapOf<Long, Inventory>() | |||
| val runningLedgerBalanceByBucket = mutableMapOf<Pair<Long, Long>, Double>() | |||
| val inventoryByBucket = mutableMapOf<Pair<Long, Long>, 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) | |||
| @@ -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 | |||
| @@ -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<InventoryInfo> { | |||
| 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<InventoryInfo> { | |||
| return inventoryService.searchInventoriesByLatestInventory(request) | |||
| return inventoryService.allInventoriesByPage(request) | |||
| } | |||
| @PostMapping("/import-bom") | |||
| @@ -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`); | |||
| @@ -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`); | |||
| @@ -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`; | |||
| @@ -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; | |||
| @@ -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; | |||