"Where used" goes 3 levels deep nowitem_tracing
| @@ -1,6 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.stock.trace | package com.ffii.fpsms.modules.stock.trace | ||||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | 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 com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse | ||||
| import org.springframework.http.HttpStatus | import org.springframework.http.HttpStatus | ||||
| import org.springframework.stereotype.Component | 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( | return ItemLotTraceResponse( | ||||
| lot = lot, | lot = lot, | ||||
| warehouseLines = warehouseLines, | warehouseLines = warehouseLines, | ||||
| @@ -188,6 +204,7 @@ open class ItemLotTraceOrchestrator( | |||||
| lotRelations = lotRelations, | lotRelations = lotRelations, | ||||
| alternateLocations = alternateLocations, | alternateLocations = alternateLocations, | ||||
| locationBlocks = locationBlocks, | locationBlocks = locationBlocks, | ||||
| issueTrace = issueTrace, | |||||
| ).let { response -> | ).let { response -> | ||||
| response.copy(traceGraph = traceGraphSemanticsBuilder.build(response)) | response.copy(traceGraph = traceGraphSemanticsBuilder.build(response)) | ||||
| } | } | ||||
| @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.stock.trace | |||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import com.ffii.fpsms.modules.stock.web.model.* | import com.ffii.fpsms.modules.stock.web.model.* | ||||
| import org.springframework.stereotype.Component | import org.springframework.stereotype.Component | ||||
| import java.math.BigDecimal | |||||
| data class BomStepAssignment( | data class BomStepAssignment( | ||||
| val bomProcessId: Long, | val bomProcessId: Long, | ||||
| @@ -319,4 +320,174 @@ open class TraceBomLoader( | |||||
| return jdbcDao.queryForList(query, mapOf("primaryStockInLineId" to primaryStockInLineId)) | return jdbcDao.queryForList(query, mapOf("primaryStockInLineId" to primaryStockInLineId)) | ||||
| .firstOrNull()?.let { sql.longVal(it["jobOrderId"]) } | .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<Long>, | |||||
| originInventoryLotIds: Set<Long>, | |||||
| ): List<ItemLotTraceWhereUsed> { | |||||
| if (startLotLineIds.isEmpty()) return emptyList() | |||||
| val results = mutableListOf<ItemLotTraceWhereUsed>() | |||||
| 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<Long>() | |||||
| 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<ItemLotTraceWhereUsed> { it.depth } | |||||
| .thenBy { it.producedItemCode } | |||||
| .thenBy { it.producedLotNo } | |||||
| .thenBy { it.jobOrderCode }, | |||||
| ) | |||||
| } | |||||
| private fun loadWhereUsedHits(lotLineIds: List<Long>): List<WhereUsedHit> { | |||||
| 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 | |||||
| } | |||||
| } | } | ||||
| @@ -127,6 +127,20 @@ open class TraceOutboundLoader( | |||||
| COALESCE(so.consoPickOrderCode, '') AS stockOutConsoCode, | COALESCE(so.consoPickOrderCode, '') AS stockOutConsoCode, | ||||
| COALESCE(shop_tbl.code, dpo.ShopCode, dpor.ShopCode, '') AS shopCode, | COALESCE(shop_tbl.code, dpo.ShopCode, dpor.ShopCode, '') AS shopCode, | ||||
| COALESCE(shop_tbl.name, dpo.ShopName, dpor.ShopName, '') AS shopName, | 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(picker_user.name, sol.createdBy, '') AS handler, | ||||
| COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp | COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp | ||||
| FROM stock_out_line sol | FROM stock_out_line sol | ||||
| @@ -146,15 +160,28 @@ open class TraceOutboundLoader( | |||||
| ON po.deliveryOrderPickOrderId = dop_direct.id AND dop_direct.deleted = 0 | ON po.deliveryOrderPickOrderId = dop_direct.id AND dop_direct.deleted = 0 | ||||
| LEFT JOIN delivery_order_pick_order dop_conso | LEFT JOIN delivery_order_pick_order dop_conso | ||||
| ON po.consoCode = dop_conso.ticketNo AND dop_conso.deleted = 0 | 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 | LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 | ||||
| """.trimIndent() | """.trimIndent() | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 24 | v1.0.0 | 2026-07-20 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 24 | v1.0.0 | 2026-07-20 */ | ||||
| open fun loadDoDeliveries(inventoryLotId: Long): List<ItemLotTraceDoDelivery> { | |||||
| open fun loadDoDeliveries(inventoryLotId: Long): List<ItemLotTraceDoDelivery> = | |||||
| loadDoDeliveriesForLots(listOf(inventoryLotId)) | |||||
| open fun loadDoDeliveriesForLots(inventoryLotIds: List<Long>): List<ItemLotTraceDoDelivery> { | |||||
| val ids = inventoryLotIds.distinct() | |||||
| if (ids.isEmpty()) return emptyList() | |||||
| val query = """ | val query = """ | ||||
| ${doDeliverySelectSql()} | ${doDeliverySelectSql()} | ||||
| WHERE sol.deleted = 0 AND il.id = :inventoryLotId | |||||
| WHERE sol.deleted = 0 AND il.id IN (:inventoryLotIds) | |||||
| AND ( | AND ( | ||||
| UPPER(COALESCE(so.type, '')) = 'DO' | UPPER(COALESCE(so.type, '')) = 'DO' | ||||
| OR po.doId IS NOT NULL | OR po.doId IS NOT NULL | ||||
| @@ -164,7 +191,7 @@ open class TraceOutboundLoader( | |||||
| ORDER BY timestamp DESC | ORDER BY timestamp DESC | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return mapDoDeliveryRows( | 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, | isExtra = isExtra, | ||||
| isReplenish = isReplenish, | isReplenish = isReplenish, | ||||
| qtyChanged = qtyChanged, | 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<ItemLotTraceReplenishmentEvent> { | |||||
| open fun loadReplenishmentEvents(inventoryLotId: Long): List<ItemLotTraceReplenishmentEvent> = | |||||
| loadReplenishmentEventsForLots(listOf(inventoryLotId)) | |||||
| open fun loadReplenishmentEventsForLots(inventoryLotIds: List<Long>): List<ItemLotTraceReplenishmentEvent> { | |||||
| val ids = inventoryLotIds.distinct() | |||||
| if (ids.isEmpty()) return emptyList() | |||||
| val query = """ | val query = """ | ||||
| SELECT | SELECT | ||||
| dr.id AS replenishmentId, | dr.id AS replenishmentId, | ||||
| @@ -346,22 +387,37 @@ open class TraceOutboundLoader( | |||||
| COALESCE(dr.itemNo, '') AS itemNo, | COALESCE(dr.itemNo, '') AS itemNo, | ||||
| COALESCE(dr.itemName, '') AS itemName, | COALESCE(dr.itemName, '') AS itemName, | ||||
| dr.itemId AS itemId, | dr.itemId AS itemId, | ||||
| COALESCE(il.lotNo, '') AS lotNo, | |||||
| il.id AS inventoryLotId, | |||||
| COALESCE(dr.reason, '') AS reason, | COALESCE(dr.reason, '') AS reason, | ||||
| COALESCE(dr.replenishQty, 0) AS replenishQty, | COALESCE(dr.replenishQty, 0) AS replenishQty, | ||||
| dr.pickOrderLineId AS pickOrderLineId, | dr.pickOrderLineId AS pickOrderLineId, | ||||
| dr.created AS timestamp, | dr.created AS timestamp, | ||||
| COALESCE(dr.createdBy, '') AS handler, | 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 | FROM stock_out_line sol | ||||
| INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 | 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 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 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 | INNER JOIN do_replenishment dr ON dr.pickOrderLineId = pol.id AND dr.deleted = 0 | ||||
| AND dr.itemId = il.itemId | 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 | ORDER BY dr.created DESC | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) | |||||
| return jdbcDao.queryForList(query, mapOf("inventoryLotIds" to ids)) | |||||
| .map { row -> mapReplenishmentEvent(row) } | .map { row -> mapReplenishmentEvent(row) } | ||||
| } | } | ||||
| @@ -381,6 +437,13 @@ open class TraceOutboundLoader( | |||||
| timestamp = sql.formatDateTime(row["timestamp"]), | timestamp = sql.formatDateTime(row["timestamp"]), | ||||
| pickOrderLineId = sql.longVal(row["pickOrderLineId"]), | pickOrderLineId = sql.longVal(row["pickOrderLineId"]), | ||||
| stockOutLineId = sql.longVal(row["stockOutLineId"]), | 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<Long>): List<ItemLotTraceReturnEvent> { | open fun loadReturnEvents(inventoryLotId: Long, lotLineIds: List<Long>): List<ItemLotTraceReturnEvent> { | ||||
| @@ -383,6 +383,17 @@ data class ItemLotTraceDoDelivery( | |||||
| */ | */ | ||||
| @get:JsonProperty("qtyChanged") | @get:JsonProperty("qtyChanged") | ||||
| val qtyChanged: Boolean = false, | 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 (建立補貨). */ | /** DO replenishment created for a source delivery order line (建立補貨). */ | ||||
| @@ -402,6 +413,13 @@ data class ItemLotTraceReplenishmentEvent( | |||||
| val pickOrderLineId: Long?, | val pickOrderLineId: Long?, | ||||
| /** Stock-out line that fulfilled this replenishment pick (links to DO_OUT node). */ | /** Stock-out line that fulfilled this replenishment pick (links to DO_OUT node). */ | ||||
| val stockOutLineId: Long?, | 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). */ | /** Supplier / stock return (outbound not tied to JO pick or DO). */ | ||||
| @@ -498,6 +516,37 @@ data class ItemLotTraceGraph( | |||||
| val edges: List<ItemLotTraceGraphEdge>, | val edges: List<ItemLotTraceGraphEdge>, | ||||
| ) | ) | ||||
| /** | |||||
| * 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<ItemLotTraceWhereUsed> = emptyList(), | |||||
| val downstreamDeliveries: List<ItemLotTraceDoDelivery> = emptyList(), | |||||
| val downstreamReplenishments: List<ItemLotTraceReplenishmentEvent> = emptyList(), | |||||
| ) | |||||
| /** | /** | ||||
| * Lot genealogy / operational lifecycle trace response. | * Lot genealogy / operational lifecycle trace response. | ||||
| * | * | ||||
| @@ -535,4 +584,6 @@ data class ItemLotTraceResponse( | |||||
| * are NOT duplicated here — see [ItemLotTraceLocationBlock] for what IS included. */ | * are NOT duplicated here — see [ItemLotTraceLocationBlock] for what IS included. */ | ||||
| val locationBlocks: List<ItemLotTraceLocationBlock> = emptyList(), | val locationBlocks: List<ItemLotTraceLocationBlock> = emptyList(), | ||||
| val traceGraph: ItemLotTraceGraph? = null, | val traceGraph: ItemLotTraceGraph? = null, | ||||
| /** Products made with this lot, and shop deliveries of those products. */ | |||||
| val issueTrace: ItemLotTraceIssue = ItemLotTraceIssue(), | |||||
| ) | ) | ||||