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.StockOutLineWorkbenchService | ||||
| import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService | import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService | ||||
| import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot | 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.service.WorkbenchStockOutLinePickProgress | ||||
| import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus | import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| @@ -115,6 +116,7 @@ open class DoWorkbenchMainService( | |||||
| private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | ||||
| private val stockLedgerRepository: StockLedgerRepository, | private val stockLedgerRepository: StockLedgerRepository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||||
| private val itemUomRespository: ItemUomRespository, | private val itemUomRespository: ItemUomRespository, | ||||
| private val bagRepository: BagRepository, | private val bagRepository: BagRepository, | ||||
| private val bagService: BagService, | private val bagService: BagService, | ||||
| @@ -2595,15 +2597,15 @@ return MessageResponse( | |||||
| private fun createWorkbenchPickLedger(sol: StockOutLine, deltaQty: BigDecimal) { | private fun createWorkbenchPickLedger(sol: StockOutLine, deltaQty: BigDecimal) { | ||||
| if (deltaQty <= BigDecimal.ZERO) return | if (deltaQty <= BigDecimal.ZERO) return | ||||
| val solItem = sol.item ?: 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. | // 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. | // At this point inventory.onHandQty has already been updated by DB triggers after lot outQty change. | ||||
| // So previousBalance can be reconstructed as (onHandAfter + deltaQty). | // So previousBalance can be reconstructed as (onHandAfter + deltaQty). | ||||
| val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| val previousBalance = onHandAfter + deltaQty.toDouble() | val previousBalance = onHandAfter + deltaQty.toDouble() | ||||
| val newBalance = previousBalance - 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 lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | ||||
| val ledger = StockLedger().apply { | val ledger = StockLedger().apply { | ||||
| this.stockOutLine = sol | 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? { | 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) | return inventoryRepository.findFirstByItemIdAndDeletedIsFalseOrderByIdAsc(itemId) | ||||
| } | } | ||||
| @@ -54,10 +54,19 @@ open class Inventory: BaseEntity<Long>(){ | |||||
| open var cpmUnit: String? = null | open var cpmUnit: String? = null | ||||
| // @NotNull | // @NotNull | ||||
| /** Base UOM snapshot only (set when the inventory row is created; not the bucket key). */ | |||||
| @ManyToOne | @ManyToOne | ||||
| @JoinColumn(name = "uomId") | @JoinColumn(name = "uomId") | ||||
| open var uom: UomConversion? = null | 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 | // @NotNull | ||||
| @Column(name = "status") | @Column(name = "status") | ||||
| open var status: String? = null | open var status: String? = null | ||||
| @@ -14,43 +14,49 @@ import java.util.Optional | |||||
| interface InventoryRepository: AbstractRepository<Inventory, Long> { | interface InventoryRepository: AbstractRepository<Inventory, Long> { | ||||
| fun findInventoryInfoByDeletedIsFalse(): List<InventoryInfo> | 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( | @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( | fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( | ||||
| code: String, | code: String, | ||||
| name: String, | name: String, | ||||
| type: String, | type: String, | ||||
| itemIds: List<Long>, | itemIds: List<Long>, | ||||
| pageable: Pageable | |||||
| pageable: Pageable, | |||||
| ): Page<InventoryInfo> | ): 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( | @Query( | ||||
| """ | """ | ||||
| SELECT i FROM Inventory i | 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 (: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 (:type IS NULL OR :type = '' OR i.item.type = :type) | ||||
| AND i.deleted = false | 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( | fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( | ||||
| @@ -73,7 +73,7 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||||
| pageable: Pageable, | pageable: Pageable, | ||||
| ): Page<InventoryInfo> | ): Page<InventoryInfo> | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 */ | |||||
| @Deprecated("Use findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse") | |||||
| @Query( | @Query( | ||||
| """ | """ | ||||
| SELECT i FROM Inventory i | 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 (:type IS NULL OR :type = '' OR i.item.type = :type) | ||||
| AND i.item.id IN :itemIds | AND i.item.id IN :itemIds | ||||
| AND i.deleted = false | 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( | fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( | ||||
| @@ -102,7 +96,13 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||||
| fun findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo> | 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 findByItemId(itemId: Long): Optional<Inventory> | ||||
| fun findAllByItemIdInAndDeletedIsFalse(itemIds: Collection<Long>): List<Inventory> | fun findAllByItemIdInAndDeletedIsFalse(itemIds: Collection<Long>): List<Inventory> | ||||
| @Query("SELECT i FROM Inventory i WHERE i.item.id = :itemId AND i.deleted = false") | @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? | 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? | 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? | val itemName: String? | ||||
| @get:Value("#{target.item.type}") | @get:Value("#{target.item.type}") | ||||
| val itemType: String? | 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}") | @get:Value("#{target.onHandQty}") | ||||
| val onHandQty: BigDecimal? | 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}") | @get:Value("#{target.onHoldQty}") | ||||
| val onHoldQty: BigDecimal? | 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}") | @get:Value("#{target.unavailableQty}") | ||||
| val unavailableQty: BigDecimal? | 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? | 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? | val uomCode: String? | ||||
| @get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.udfudesc}") | |||||
| @get:Value("#{target.stockUom?.udfudesc}") | |||||
| val uomUdfudesc: String? | val uomUdfudesc: String? | ||||
| @get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.udfShortDesc}") | |||||
| @get:Value("#{target.stockUom?.udfShortDesc}") | |||||
| val uomShortDesc: String? | 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? | 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? | 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? | val price: BigDecimal? | ||||
| @get:Value("#{target.currency?.name}") | @get:Value("#{target.currency?.name}") | ||||
| val currencyName: String? | 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( | open fun searchInventoriesByLatestInventory( | ||||
| request: SearchInventoryRequest, | request: SearchInventoryRequest, | ||||
| ): RecordsRes<InventoryInfo> { | ): 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>{ | 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 warehouse = warehouseRepository.findAll().find { it.code == request.warehouseCode }!! | ||||
| // val salesUnit = itemUomService.findSalesUnitByItemId(itemId = inventoryLot.item!!.id!!) | // val salesUnit = itemUomService.findSalesUnitByItemId(itemId = inventoryLot.item!!.id!!) | ||||
| val stockUnit = itemUomService.findStockUnitByItemId(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 { | InventoryLotLine().apply { | ||||
| this.inventoryLot = inventoryLot | this.inventoryLot = inventoryLot | ||||
| this.warehouse = warehouse | this.warehouse = warehouse | ||||
| this.inQty = request.qty | this.inQty = request.qty | ||||
| this.status = InventoryLotLineStatus.AVAILABLE | this.status = InventoryLotLineStatus.AVAILABLE | ||||
| this.stockUom = stockUnit | this.stockUom = stockUnit | ||||
| // this.stockUom = salesUnit | |||||
| } | } | ||||
| } | } | ||||
| val savedInventoryLotLine = inventoryLotLineRepository.saveAllAndFlush(inventoryLotLineEntries) | val savedInventoryLotLine = inventoryLotLineRepository.saveAllAndFlush(inventoryLotLineEntries) | ||||
| @@ -103,6 +103,7 @@ open class StockInLineService( | |||||
| private val deliveryOrderRepository: DeliveryOrderRepository, | private val deliveryOrderRepository: DeliveryOrderRepository, | ||||
| private val stockLedgerRepository: StockLedgerRepository, | private val stockLedgerRepository: StockLedgerRepository, | ||||
| private val inventoryRepository: InventoryRepository, | private val inventoryRepository: InventoryRepository, | ||||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||||
| private val m18GoodsReceiptNoteService: M18GoodsReceiptNoteService, | private val m18GoodsReceiptNoteService: M18GoodsReceiptNoteService, | ||||
| private val m18GoodsReceiptNoteLogRepository: M18GoodsReceiptNoteLogRepository, | private val m18GoodsReceiptNoteLogRepository: M18GoodsReceiptNoteLogRepository, | ||||
| /** Lazy to avoid circular dependency: M18PurchaseOrderService → … → StockInLineService. */ | /** Lazy to avoid circular dependency: M18PurchaseOrderService → … → StockInLineService. */ | ||||
| @@ -1389,8 +1390,8 @@ open class StockInLineService( | |||||
| val item = stockInLine.item ?: return | val item = stockInLine.item ?: return | ||||
| val _tInv = System.nanoTime() | 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 计算 | // ✅ 修复:查询最新的 stock_ledger 记录,基于前一笔 balance 计算 | ||||
| val _tLatest = System.nanoTime() | val _tLatest = System.nanoTime() | ||||
| @@ -81,6 +81,7 @@ private val inventoryLotLineService: InventoryLotLineService, | |||||
| private val inventoryRepository: InventoryRepository, | private val inventoryRepository: InventoryRepository, | ||||
| private val pickExecutionIssueRepository: PickExecutionIssueRepository, | private val pickExecutionIssueRepository: PickExecutionIssueRepository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||||
| @Lazy private val doReplenishmentService: DoReplenishmentService, | @Lazy private val doReplenishmentService: DoReplenishmentService, | ||||
| ): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) { | ): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) { | ||||
| @@ -1359,7 +1360,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| } | } | ||||
| val item = savedSol.item | val item = savedSol.item | ||||
| val inventoryBeforeUpdate = item?.id?.let { itemId -> | val inventoryBeforeUpdate = item?.id?.let { itemId -> | ||||
| itemUomService.findInventoryForItemBaseUom(itemId) | |||||
| inventoryBucketResolver.findInventoryBucket(itemId, ill) | |||||
| } | } | ||||
| val onHandQtyBeforeUpdate = | val onHandQtyBeforeUpdate = | ||||
| (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| @@ -1374,7 +1375,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| } else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) { | } else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) { | ||||
| val item = savedSol.item | val item = savedSol.item | ||||
| val inventoryBeforeUpdate = item?.id?.let { itemId -> | val inventoryBeforeUpdate = item?.id?.let { itemId -> | ||||
| itemUomService.findInventoryForItemBaseUom(itemId) | |||||
| inventoryBucketResolver.findInventoryBucket(itemId, savedSol.inventoryLotLine) | |||||
| } | } | ||||
| val onHandQtyBeforeUpdate = | val onHandQtyBeforeUpdate = | ||||
| (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | (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 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */ | ||||
| private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) { | private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) { | ||||
| val item = stockOutLine.item ?: return | 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 | 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 | // 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 | return | ||||
| } | } | ||||
| val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) | |||||
| val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockOutLine.inventoryLotLine) | |||||
| if (inventory == null) { | if (inventory == null) { | ||||
| if (flushAfterSave) { | 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 | return | ||||
| } | } | ||||
| @@ -2198,7 +2199,7 @@ fun applyStockOutLineDelta( | |||||
| val itemId = savedSol.item?.id | val itemId = savedSol.item?.id | ||||
| if (itemId != null) { | if (itemId != null) { | ||||
| val inv = itemUomService.findInventoryForItemBaseUom(itemId) | |||||
| val inv = inventoryBucketResolver.findInventoryBucket(itemId, savedSol.inventoryLotLine) | |||||
| if (inv != null) { | if (inv != null) { | ||||
| val zero = BigDecimal.ZERO | val zero = BigDecimal.ZERO | ||||
| inv.onHandQty = (inv.onHandQty ?: zero).minus(deltaQty) | inv.onHandQty = (inv.onHandQty ?: zero).minus(deltaQty) | ||||
| @@ -2213,7 +2214,7 @@ fun applyStockOutLineDelta( | |||||
| // 3) stock_ledger (same ledger shape as createStockLedgerForStockOut / completeStockOutAfterLotLineSave) | // 3) stock_ledger (same ledger shape as createStockLedgerForStockOut / completeStockOutAfterLotLineSave) | ||||
| if (!skipLedgerWrite) { | if (!skipLedgerWrite) { | ||||
| val item = savedSol.item ?: return savedSol | 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( | val previousBalance = resolvePreviousBalance( | ||||
| itemId = item.id!!, | itemId = item.id!!, | ||||
| @@ -2349,18 +2350,22 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult | |||||
| val savedLines = stockOutLineRepository.saveAll(stockOutLinesToInsert) | val savedLines = stockOutLineRepository.saveAll(stockOutLinesToInsert) | ||||
| // 5) 批量组装 ledger(避免每笔 createStockLedgerForStockOut) | // 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. | // 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 -> | savedLines.sortedBy { it.id ?: Long.MAX_VALUE }.forEach { sol -> | ||||
| val item = sol.item ?: return@forEach | val item = sol.item ?: return@forEach | ||||
| val itemId = item.id ?: 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 | ?: return@forEach | ||||
| val delta = BigDecimal.valueOf(sol.qty ?: 0.0) | val delta = BigDecimal.valueOf(sol.qty ?: 0.0) | ||||
| val prevBalance = runningLedgerBalanceByItemId[itemId] | |||||
| val prevBalance = runningLedgerBalanceByBucket[bucketKey] | |||||
| ?: run { | ?: run { | ||||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) | val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) | ||||
| latestLedger?.balance ?: (inv.onHandQty ?: BigDecimal.ZERO).toDouble() | latestLedger?.balance ?: (inv.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| @@ -2392,7 +2397,7 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult | |||||
| signedDelta = delta.negate(), | signedDelta = delta.negate(), | ||||
| ) | ) | ||||
| ledgersToInsert += ledger | ledgersToInsert += ledger | ||||
| runningLedgerBalanceByItemId[itemId] = newBalance | |||||
| runningLedgerBalanceByBucket[bucketKey] = newBalance | |||||
| } | } | ||||
| stockLedgerRepository.saveAll(ledgersToInsert) | stockLedgerRepository.saveAll(ledgersToInsert) | ||||
| @@ -62,6 +62,7 @@ open class StockOutLineWorkbenchService( | |||||
| private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | ||||
| private val stockLedgerRepository: StockLedgerRepository, | private val stockLedgerRepository: StockLedgerRepository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||||
| private val itemUomRespository: ItemUomRespository, | private val itemUomRespository: ItemUomRespository, | ||||
| private val bagRepository: BagRepository, | private val bagRepository: BagRepository, | ||||
| private val bagService: BagService, | private val bagService: BagService, | ||||
| @@ -512,13 +513,13 @@ if (updated == 0) { | |||||
| if (deltaQty <= BigDecimal.ZERO) return | if (deltaQty <= BigDecimal.ZERO) return | ||||
| val sol = stockOutLIneRepository.findById(stockOutLineId).orElse(null) ?: return | val sol = stockOutLIneRepository.findById(stockOutLineId).orElse(null) ?: return | ||||
| val solItem = sol.item ?: 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. | // Fast path: use inventory onHand after update + delta to reconstruct previous balance. | ||||
| val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| val previousBalance = onHandAfter + deltaQty.toDouble() | val previousBalance = onHandAfter + deltaQty.toDouble() | ||||
| val newBalance = previousBalance - 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 lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | ||||
| val ledger = StockLedger().apply { | val ledger = StockLedger().apply { | ||||
| this.stockOutLine = sol | this.stockOutLine = sol | ||||
| @@ -16,20 +16,22 @@ class InventoryController( | |||||
| return inventoryService.allInventories() | return inventoryService.allInventories() | ||||
| } | } | ||||
| /** Full inventory search: one row per (itemId, stockUomId) bucket. */ | |||||
| @GetMapping("/getRecordByPage") | @GetMapping("/getRecordByPage") | ||||
| fun allInventoriesByPage(@ModelAttribute request: SearchInventoryRequest): RecordsRes<InventoryInfo> { | 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") | @GetMapping("/searchLatest/getRecordByPage") | ||||
| fun searchInventoriesByLatestInventory( | fun searchInventoriesByLatestInventory( | ||||
| @ModelAttribute request: SearchInventoryRequest, | @ModelAttribute request: SearchInventoryRequest, | ||||
| ): RecordsRes<InventoryInfo> { | ): RecordsRes<InventoryInfo> { | ||||
| return inventoryService.searchInventoriesByLatestInventory(request) | |||||
| return inventoryService.allInventoriesByPage(request) | |||||
| } | } | ||||
| @PostMapping("/import-bom") | @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; | |||||