Bläddra i källkod

Add inventory stock-UOM bucket foundation (schema, triggers, resolver, API).

Co-authored-by: Cursor <[email protected]>
fix負數倉
kelvin.yau 1 vecka sedan
förälder
incheckning
1541c5d1b4
16 ändrade filer med 606 tillägg och 135 borttagningar
  1. +5
    -3
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  2. +19
    -9
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  3. +9
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/Inventory.kt
  4. +50
    -41
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt
  5. +10
    -23
      src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt
  6. +56
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryBucketResolver.kt
  7. +4
    -36
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt
  8. +3
    -2
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  9. +19
    -14
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt
  10. +4
    -3
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt
  11. +6
    -4
      src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt
  12. +17
    -0
      src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/01_add_inventory_stock_uom_id.sql
  13. +16
    -0
      src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/02_add_stock_uom_indexes.sql
  14. +10
    -0
      src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/03_drop_inventory_lot_line_triggers.sql
  15. +161
    -0
      src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/04_rewrite_inventory_lot_line_trigger_insert.sql
  16. +217
    -0
      src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/05_rewrite_inventory_lot_line_trigger_update.sql

+ 5
- 3
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt Visa fil

@@ -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


+ 19
- 9
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt Visa fil

@@ -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)
}



+ 9
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/Inventory.kt Visa fil

@@ -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

+ 50
- 41
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt Visa fil

@@ -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?
}

+ 10
- 23
src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt Visa fil

@@ -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?


+ 56
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryBucketResolver.kt Visa fil

@@ -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)
}
}

+ 4
- 36
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt Visa fil

@@ -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)


+ 3
- 2
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt Visa fil

@@ -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()


+ 19
- 14
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt Visa fil

@@ -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)



+ 4
- 3
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt Visa fil

@@ -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


+ 6
- 4
src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt Visa fil

@@ -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")


+ 17
- 0
src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/01_add_inventory_stock_uom_id.sql Visa fil

@@ -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`);

+ 16
- 0
src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/02_add_stock_uom_indexes.sql Visa fil

@@ -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`);

+ 10
- 0
src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/03_drop_inventory_lot_line_triggers.sql Visa fil

@@ -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`;

+ 161
- 0
src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/04_rewrite_inventory_lot_line_trigger_insert.sql Visa fil

@@ -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;

+ 217
- 0
src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/05_rewrite_inventory_lot_line_trigger_update.sql Visa fil

@@ -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;

Laddar…
Avbryt
Spara