ソースを参照

Bad lot now lists products made from it and shops those products were sent to

"Where used" goes 3 levels deep now
item_tracing
Harry Groves 1週間前
親
コミット
dba2049ec6
4個のファイルの変更、310行の追加、8行の削除
  1. +17
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt
  2. +171
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt
  3. +71
    -8
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt
  4. +51
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt

+ 17
- 0
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))
}


+ 171
- 0
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<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
}
}

+ 71
- 8
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<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> {


+ 51
- 0
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<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(),
)

読み込み中…
キャンセル
保存