"Where used" goes 3 levels deep nowitem_tracing
| @@ -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)) | |||
| } | |||
| @@ -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<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(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<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 = """ | |||
| ${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<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 = """ | |||
| 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<Long>): List<ItemLotTraceReturnEvent> { | |||
| @@ -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<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. | |||
| * | |||
| @@ -535,4 +584,6 @@ data class ItemLotTraceResponse( | |||
| * are NOT duplicated here — see [ItemLotTraceLocationBlock] for what IS included. */ | |||
| val locationBlocks: List<ItemLotTraceLocationBlock> = emptyList(), | |||
| val traceGraph: ItemLotTraceGraph? = null, | |||
| /** Products made with this lot, and shop deliveries of those products. */ | |||
| val issueTrace: ItemLotTraceIssue = ItemLotTraceIssue(), | |||
| ) | |||