diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt index 32186f2d..31a8d23e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt @@ -1,6 +1,7 @@ package com.ffii.fpsms.modules.stock.trace import com.ffii.fpsms.modules.master.entity.ItemsRepository +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceIssue import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse import org.springframework.http.HttpStatus import org.springframework.stereotype.Component @@ -164,6 +165,21 @@ open class ItemLotTraceOrchestrator( ) } + val directLotIds = (listOf(inventoryLotId) + altLotIds).distinct() + val directLotLineIds = lotResolver.loadLotLineIdsForLots(directLotIds) + .values + .flatten() + .distinct() + val whereUsed = bomLoader.loadWhereUsedChain(directLotLineIds, directLotIds.toSet()) + val downstreamLotIds = whereUsed.mapNotNull { it.producedInventoryLotId } + .distinct() + .filter { it !in directLotIds.toSet() } + val issueTrace = ItemLotTraceIssue( + whereUsed = whereUsed, + downstreamDeliveries = outboundLoader.loadDoDeliveriesForLots(downstreamLotIds), + downstreamReplenishments = outboundLoader.loadReplenishmentEventsForLots(downstreamLotIds), + ) + return ItemLotTraceResponse( lot = lot, warehouseLines = warehouseLines, @@ -188,6 +204,7 @@ open class ItemLotTraceOrchestrator( lotRelations = lotRelations, alternateLocations = alternateLocations, locationBlocks = locationBlocks, + issueTrace = issueTrace, ).let { response -> response.copy(traceGraph = traceGraphSemanticsBuilder.build(response)) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt index 33017a1a..11a92827 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.stock.trace import com.ffii.core.support.JdbcDao import com.ffii.fpsms.modules.stock.web.model.* import org.springframework.stereotype.Component +import java.math.BigDecimal data class BomStepAssignment( val bomProcessId: Long, @@ -319,4 +320,174 @@ open class TraceBomLoader( return jdbcDao.queryForList(query, mapOf("primaryStockInLineId" to primaryStockInLineId)) .firstOrNull()?.let { sql.longVal(it["jobOrderId"]) } } + + /** + * Walk products that consumed [startLotLineIds], then products made from those products. + * Stops at depth 3 and does not walk back into [originInventoryLotIds]. + */ + open fun loadWhereUsedChain( + startLotLineIds: List, + originInventoryLotIds: Set, + ): List { + if (startLotLineIds.isEmpty()) return emptyList() + val results = mutableListOf() + val visitedLines = startLotLineIds.toMutableSet() + val visitedProducedLots = originInventoryLotIds.toMutableSet() + var frontier = startLotLineIds.distinct() + var depth = 1 + while (frontier.isNotEmpty() && depth <= WHERE_USED_MAX_DEPTH) { + val hits = loadWhereUsedHits(frontier) + val nextLotIds = mutableSetOf() + hits.forEach { hit -> + val producedId = hit.producedInventoryLotId + if (producedId != null && producedId in originInventoryLotIds) return@forEach + results += hit.toWhereUsed(depth) + if (producedId != null && producedId !in visitedProducedLots) { + visitedProducedLots += producedId + nextLotIds += producedId + } + } + val nextLines = lotResolver.loadLotLineIdsForLots(nextLotIds.toList()) + .values + .flatten() + .filter { it !in visitedLines } + .distinct() + visitedLines += nextLines + frontier = nextLines + depth++ + } + return results.groupBy { + listOf( + it.depth, + it.jobOrderId, + it.consumedItemCode, + it.consumedLotNo, + it.producedItemCode, + it.producedLotNo, + it.producedStockInLineId, + ).joinToString("|") + }.values.map { group -> + val first = group.first() + first.copy( + materialQtyUsed = group.fold(BigDecimal.ZERO) { acc, row -> acc.add(row.materialQtyUsed) }, + ) + }.sortedWith( + compareBy { it.depth } + .thenBy { it.producedItemCode } + .thenBy { it.producedLotNo } + .thenBy { it.jobOrderCode }, + ) + } + + private fun loadWhereUsedHits(lotLineIds: List): List { + if (lotLineIds.isEmpty()) return emptyList() + val query = """ + SELECT + sol.id AS stockOutLineId, + COALESCE(consumed_it.code, '') AS consumedItemCode, + COALESCE(consumed_it.name, '') AS consumedItemName, + COALESCE(consumed_il.lotNo, '') AS consumedLotNo, + COALESCE(sol.qty, 0) AS materialQtyUsed, + COALESCE(jo.code, '') AS jobOrderCode, + jo.id AS jobOrderId, + COALESCE(fg_stock_it.code, fg_bom_it.code, '') AS finishedItemCode, + COALESCE(fg_stock_it.name, fg_bom_it.name, '') AS finishedItemName, + COALESCE(fg_il.lotNo, fg_sil.lotNo, '') AS finishedLotNo, + fg_sil.id AS finishedStockInLineId, + fg_il.id AS finishedInventoryLotId, + COALESCE(fg_sil.acceptedQty, 0) AS fgQty, + COALESCE(fg_uc_tx.udfudesc, fg_uc_fb.udfudesc, '') AS fgUom + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 + AND so.type IN ('job', 'jo') + INNER JOIN inventory_lot_line consumed_ill ON sol.inventoryLotLineId = consumed_ill.id AND consumed_ill.deleted = 0 + INNER JOIN inventory_lot consumed_il ON consumed_ill.inventoryLotId = consumed_il.id AND consumed_il.deleted = 0 + LEFT JOIN items consumed_it ON consumed_il.itemId = consumed_it.id AND consumed_it.deleted = 0 + INNER JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 + INNER JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 AND po.joId IS NOT NULL + INNER JOIN job_order jo ON po.joId = jo.id AND jo.deleted = 0 + LEFT JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 + LEFT JOIN items fg_bom_it ON b.itemId = fg_bom_it.id AND fg_bom_it.deleted = 0 + LEFT JOIN stock_in_line fg_sil ON fg_sil.jobOrderId = jo.id AND fg_sil.deleted = 0 + LEFT JOIN inventory_lot fg_il ON fg_sil.inventoryLotId = fg_il.id AND fg_il.deleted = 0 + LEFT JOIN items fg_stock_it ON fg_il.itemId = fg_stock_it.id AND fg_stock_it.deleted = 0 + LEFT JOIN items fg_item ON fg_item.id = COALESCE(fg_stock_it.id, fg_bom_it.id) + LEFT JOIN inventory_lot_line fg_ill ON fg_ill.id = COALESCE( + fg_sil.inventoryLotLineId, + ( + SELECT ill2.id + FROM inventory_lot_line ill2 + WHERE ill2.inventoryLotId = fg_il.id + AND ill2.deleted = 0 + AND ill2.stockItemUomId IS NOT NULL + ORDER BY ill2.id ASC + LIMIT 1 + ) + ) + LEFT JOIN item_uom fg_iu_tx ON fg_iu_tx.id = fg_ill.stockItemUomId AND fg_iu_tx.deleted = 0 + LEFT JOIN uom_conversion fg_uc_tx ON fg_uc_tx.id = fg_iu_tx.uomId AND fg_uc_tx.deleted = 0 + LEFT JOIN item_uom fg_iu_fb ON fg_item.id = fg_iu_fb.itemId AND fg_iu_fb.stockUnit = 1 AND fg_iu_fb.deleted = 0 + LEFT JOIN uom_conversion fg_uc_fb ON fg_iu_fb.uomId = fg_uc_fb.id AND fg_uc_fb.deleted = 0 + WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds) + ORDER BY jobOrderCode, finishedLotNo + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("lotLineIds" to lotLineIds)).map { row -> + WhereUsedHit( + stockOutLineId = sql.longVal(row["stockOutLineId"]) ?: 0L, + consumedItemCode = sql.str(row["consumedItemCode"]), + consumedItemName = sql.str(row["consumedItemName"]), + consumedLotNo = sql.str(row["consumedLotNo"]), + materialQtyUsed = sql.decimal(row["materialQtyUsed"]), + jobOrderCode = sql.str(row["jobOrderCode"]), + jobOrderId = sql.longVal(row["jobOrderId"]), + producedItemCode = sql.str(row["finishedItemCode"]), + producedItemName = sql.str(row["finishedItemName"]), + producedLotNo = sql.str(row["finishedLotNo"]), + producedQty = sql.decimal(row["fgQty"]), + producedUom = sql.str(row["fgUom"]), + producedInventoryLotId = sql.longVal(row["finishedInventoryLotId"]), + producedStockInLineId = sql.longVal(row["finishedStockInLineId"]), + ) + }.distinctBy { + "${it.stockOutLineId}|${it.producedInventoryLotId}|${it.producedStockInLineId}|${it.producedLotNo}" + } + } + + private fun WhereUsedHit.toWhereUsed(depth: Int) = ItemLotTraceWhereUsed( + depth = depth, + consumedItemCode = consumedItemCode, + consumedItemName = consumedItemName, + consumedLotNo = consumedLotNo, + materialQtyUsed = materialQtyUsed, + jobOrderCode = jobOrderCode, + jobOrderId = jobOrderId, + producedItemCode = producedItemCode, + producedItemName = producedItemName, + producedLotNo = producedLotNo, + producedQty = producedQty, + producedUom = producedUom, + producedInventoryLotId = producedInventoryLotId, + producedStockInLineId = producedStockInLineId, + ) + + private data class WhereUsedHit( + val stockOutLineId: Long, + val consumedItemCode: String, + val consumedItemName: String, + val consumedLotNo: String, + val materialQtyUsed: BigDecimal, + val jobOrderCode: String, + val jobOrderId: Long?, + val producedItemCode: String, + val producedItemName: String, + val producedLotNo: String, + val producedQty: BigDecimal, + val producedUom: String, + val producedInventoryLotId: Long?, + val producedStockInLineId: Long?, + ) + + companion object { + private const val WHERE_USED_MAX_DEPTH = 3 + } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt index 1c4c5996..d50ee806 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt @@ -127,6 +127,20 @@ open class TraceOutboundLoader( COALESCE(so.consoPickOrderCode, '') AS stockOutConsoCode, COALESCE(shop_tbl.code, dpo.ShopCode, dpor.ShopCode, '') AS shopCode, COALESCE(shop_tbl.name, dpo.ShopName, dpor.ShopName, '') AS shopName, + COALESCE(shop_tbl.contactName, '') AS shopContactName, + COALESCE(shop_tbl.contactNo, '') AS shopContactNo, + COALESCE(shop_tbl.contactEmail, '') AS shopContactEmail, + COALESCE(CONCAT_WS(', ', + NULLIF(shop_tbl.addr1, ''), + NULLIF(shop_tbl.addr2, ''), + NULLIF(shop_tbl.addr3, ''), + NULLIF(shop_tbl.addr4, '') + ), '') AS shopAddress, + COALESCE(shop_tbl.district, '') AS shopDistrict, + COALESCE(delivered_it.code, '') AS deliveredItemCode, + COALESCE(delivered_it.name, '') AS deliveredItemName, + COALESCE(il.lotNo, '') AS deliveredLotNo, + il.id AS deliveredInventoryLotId, COALESCE(picker_user.name, sol.createdBy, '') AS handler, COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp FROM stock_out_line sol @@ -146,15 +160,28 @@ open class TraceOutboundLoader( ON po.deliveryOrderPickOrderId = dop_direct.id AND dop_direct.deleted = 0 LEFT JOIN delivery_order_pick_order dop_conso ON po.consoCode = dop_conso.ticketNo AND dop_conso.deleted = 0 - LEFT JOIN shop shop_tbl ON do_tbl.shopId = shop_tbl.id AND shop_tbl.deleted = 0 + LEFT JOIN shop shop_tbl ON shop_tbl.deleted = 0 AND shop_tbl.id = COALESCE( + do_tbl.shopId, + ( + SELECT MIN(s2.id) FROM shop s2 + WHERE s2.deleted = 0 + AND s2.code = COALESCE(NULLIF(dpo.ShopCode, ''), NULLIF(dpor.ShopCode, '')) + ) + ) + LEFT JOIN items delivered_it ON il.itemId = delivered_it.id AND delivered_it.deleted = 0 LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 """.trimIndent() /** FP-MTMS Version Checklist | Functions Ref. No. 24 | v1.0.0 | 2026-07-20 */ - open fun loadDoDeliveries(inventoryLotId: Long): List { + open fun loadDoDeliveries(inventoryLotId: Long): List = + loadDoDeliveriesForLots(listOf(inventoryLotId)) + + open fun loadDoDeliveriesForLots(inventoryLotIds: List): List { + val ids = inventoryLotIds.distinct() + if (ids.isEmpty()) return emptyList() val query = """ ${doDeliverySelectSql()} - WHERE sol.deleted = 0 AND il.id = :inventoryLotId + WHERE sol.deleted = 0 AND il.id IN (:inventoryLotIds) AND ( UPPER(COALESCE(so.type, '')) = 'DO' OR po.doId IS NOT NULL @@ -164,7 +191,7 @@ open class TraceOutboundLoader( ORDER BY timestamp DESC """.trimIndent() return mapDoDeliveryRows( - jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)), + jdbcDao.queryForList(query, mapOf("inventoryLotIds" to ids)), ) } @@ -254,6 +281,15 @@ open class TraceOutboundLoader( isExtra = isExtra, isReplenish = isReplenish, qtyChanged = qtyChanged, + shopContactName = sql.str(row["shopContactName"]), + shopContactNo = sql.str(row["shopContactNo"]), + shopContactEmail = sql.str(row["shopContactEmail"]), + shopAddress = sql.str(row["shopAddress"]), + shopDistrict = sql.str(row["shopDistrict"]), + itemCode = sql.str(row["deliveredItemCode"]), + itemName = sql.str(row["deliveredItemName"]), + lotNo = sql.str(row["deliveredLotNo"]), + inventoryLotId = sql.longVal(row["deliveredInventoryLotId"]) ?: 0L, ) } @@ -334,7 +370,12 @@ open class TraceOutboundLoader( } } - open fun loadReplenishmentEvents(inventoryLotId: Long): List { + open fun loadReplenishmentEvents(inventoryLotId: Long): List = + loadReplenishmentEventsForLots(listOf(inventoryLotId)) + + open fun loadReplenishmentEventsForLots(inventoryLotIds: List): List { + val ids = inventoryLotIds.distinct() + if (ids.isEmpty()) return emptyList() val query = """ SELECT dr.id AS replenishmentId, @@ -346,22 +387,37 @@ open class TraceOutboundLoader( COALESCE(dr.itemNo, '') AS itemNo, COALESCE(dr.itemName, '') AS itemName, dr.itemId AS itemId, + COALESCE(il.lotNo, '') AS lotNo, + il.id AS inventoryLotId, COALESCE(dr.reason, '') AS reason, COALESCE(dr.replenishQty, 0) AS replenishQty, dr.pickOrderLineId AS pickOrderLineId, dr.created AS timestamp, COALESCE(dr.createdBy, '') AS handler, - sol.id AS stockOutLineId + sol.id AS stockOutLineId, + COALESCE(shop_tbl.contactName, '') AS shopContactName, + COALESCE(shop_tbl.contactNo, '') AS shopContactNo, + COALESCE(shop_tbl.contactEmail, '') AS shopContactEmail, + COALESCE(CONCAT_WS(', ', + NULLIF(shop_tbl.addr1, ''), + NULLIF(shop_tbl.addr2, ''), + NULLIF(shop_tbl.addr3, ''), + NULLIF(shop_tbl.addr4, '') + ), '') AS shopAddress, + COALESCE(shop_tbl.district, '') AS shopDistrict FROM stock_out_line sol INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 INNER JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 INNER JOIN do_replenishment dr ON dr.pickOrderLineId = pol.id AND dr.deleted = 0 AND dr.itemId = il.itemId - WHERE sol.deleted = 0 AND il.id = :inventoryLotId + LEFT JOIN shop shop_tbl ON shop_tbl.deleted = 0 AND shop_tbl.id = ( + SELECT MIN(s2.id) FROM shop s2 WHERE s2.deleted = 0 AND s2.code = dr.shopCode + ) + WHERE sol.deleted = 0 AND il.id IN (:inventoryLotIds) ORDER BY dr.created DESC """.trimIndent() - return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) + return jdbcDao.queryForList(query, mapOf("inventoryLotIds" to ids)) .map { row -> mapReplenishmentEvent(row) } } @@ -381,6 +437,13 @@ open class TraceOutboundLoader( timestamp = sql.formatDateTime(row["timestamp"]), pickOrderLineId = sql.longVal(row["pickOrderLineId"]), stockOutLineId = sql.longVal(row["stockOutLineId"]), + lotNo = sql.str(row["lotNo"]), + inventoryLotId = sql.longVal(row["inventoryLotId"]) ?: 0L, + shopContactName = sql.str(row["shopContactName"]), + shopContactNo = sql.str(row["shopContactNo"]), + shopContactEmail = sql.str(row["shopContactEmail"]), + shopAddress = sql.str(row["shopAddress"]), + shopDistrict = sql.str(row["shopDistrict"]), ) open fun loadReturnEvents(inventoryLotId: Long, lotLineIds: List): List { diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt index eceda0c1..50a36c43 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt @@ -383,6 +383,17 @@ data class ItemLotTraceDoDelivery( */ @get:JsonProperty("qtyChanged") val qtyChanged: Boolean = false, + /** Shop master contact, for recall calls. Blank when the shop row has no contact. */ + val shopContactName: String = "", + val shopContactNo: String = "", + val shopContactEmail: String = "", + val shopAddress: String = "", + val shopDistrict: String = "", + /** Item/lot that was actually delivered (this lot, or a product made from it). */ + val itemCode: String = "", + val itemName: String = "", + val lotNo: String = "", + val inventoryLotId: Long = 0, ) /** DO replenishment created for a source delivery order line (建立補貨). */ @@ -402,6 +413,13 @@ data class ItemLotTraceReplenishmentEvent( val pickOrderLineId: Long?, /** Stock-out line that fulfilled this replenishment pick (links to DO_OUT node). */ val stockOutLineId: Long?, + val lotNo: String = "", + val inventoryLotId: Long = 0, + val shopContactName: String = "", + val shopContactNo: String = "", + val shopContactEmail: String = "", + val shopAddress: String = "", + val shopDistrict: String = "", ) /** Supplier / stock return (outbound not tied to JO pick or DO). */ @@ -498,6 +516,37 @@ data class ItemLotTraceGraph( val edges: List, ) +/** + * One product lot that consumed this lot (or a product made from it). + * [depth] 1 = used the traced lot directly. Higher depths are products made from those products. + */ +data class ItemLotTraceWhereUsed( + val depth: Int, + val consumedItemCode: String, + val consumedItemName: String, + val consumedLotNo: String, + val materialQtyUsed: BigDecimal, + val jobOrderCode: String, + val jobOrderId: Long?, + val producedItemCode: String, + val producedItemName: String, + val producedLotNo: String, + val producedQty: BigDecimal, + val producedUom: String, + val producedInventoryLotId: Long?, + val producedStockInLineId: Long?, +) + +/** + * Recall pack for a bad lot: products that used it, and deliveries of those products. + * Deliveries of the traced lot itself stay on [ItemLotTraceResponse.doDeliveries]. + */ +data class ItemLotTraceIssue( + val whereUsed: List = emptyList(), + val downstreamDeliveries: List = emptyList(), + val downstreamReplenishments: List = emptyList(), +) + /** * Lot genealogy / operational lifecycle trace response. * @@ -535,4 +584,6 @@ data class ItemLotTraceResponse( * are NOT duplicated here — see [ItemLotTraceLocationBlock] for what IS included. */ val locationBlocks: List = emptyList(), val traceGraph: ItemLotTraceGraph? = null, + /** Products made with this lot, and shop deliveries of those products. */ + val issueTrace: ItemLotTraceIssue = ItemLotTraceIssue(), )