diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index 9158d12..9a8b0e8 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -113,6 +113,10 @@ public class SecurityConfig { .hasAnyAuthority("TESTING", "ADMIN") .requestMatchers(HttpMethod.POST, "/stockAdjustment/submit") .hasAnyAuthority("ADMIN", "INVENTORY_ADJUST") + .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace") + .hasAuthority("ITEM_TRACING") + .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**") + .hasAuthority("ITEM_TRACING") .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt index 27b5bdb..6546c6f 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt @@ -100,6 +100,7 @@ fun findAllByJobOrder_Id(jobOrderId: Long): List fun findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds: Collection): List fun findTopByJobOrder_IdOrderByCreatedDesc(jobOrderId: Long): PickOrder? fun findAllByStatusIn(statuses: List): List +fun findByCodeAndDeletedFalse(@Param("code") code: String): PickOrder? @Query( """ diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderLifecycleService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderLifecycleService.kt new file mode 100644 index 0000000..d1a37a7 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderLifecycleService.kt @@ -0,0 +1,284 @@ +package com.ffii.fpsms.modules.pickOrder.service + +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRepository +import com.ffii.fpsms.modules.deliveryOrder.enums.DoPickOrderStatus +import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRecordRepository +import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository +import com.ffii.fpsms.modules.jobOrder.enums.JoPickOrderStatus +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderGroupRepository +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository +import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus +import com.ffii.fpsms.modules.pickOrder.enums.PickOrderType +import com.ffii.fpsms.modules.pickOrder.web.models.* +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.format.DateTimeFormatter + +@Service +@Transactional(readOnly = true) +open class PickOrderLifecycleService( + private val pickOrderRepository: PickOrderRepository, + private val pickOrderGroupRepository: PickOrderGroupRepository, + private val doPickOrderRepository: DoPickOrderRepository, + private val doPickOrderRecordRepository: DoPickOrderRecordRepository, + private val doPickOrderLineRepository: DoPickOrderLineRepository, + private val joPickOrderRepository: JoPickOrderRepository, + private val joPickOrderRecordRepository: JoPickOrderRecordRepository, +) { + private val dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + /** FP-MTMS Version Checklist | Functions Ref. No. 10 | v1.0.1 | 2026-07-14 */ + open fun getLifecycle(pickOrderId: Long): PickOrderLifecycleResponse? { + val po = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return null + + val groups = po.id?.let { pickOrderGroupRepository.findByPickOrderIdAndDeletedIsFalse(it) } ?: emptyList() + val groupName = groups.firstOrNull()?.name + + val stages = buildPickOrderStages(po) + val doLifecycle = if (po.type == PickOrderType.DELIVERY_ORDER) buildDoLifecycle(pickOrderId) else null + val joLifecycle = if (po.type == PickOrderType.JOB_ORDER) buildJoLifecycle(pickOrderId) else null + + return PickOrderLifecycleResponse( + pickOrderId = po.id!!, + pickOrderCode = po.code ?: "", + pickOrderType = po.type?.value ?: "", + currentStatus = po.status?.value ?: "", + targetDate = po.targetDate?.format(dtf), + completeDate = po.completeDate?.format(dtf), + consoCode = po.consoCode, + groupName = groupName, + totalLines = po.totalLines, + submittedLines = po.submittedLines, + stages = stages, + doLifecycle = doLifecycle, + joLifecycle = joLifecycle, + ) + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 10 | v1.0.1 | 2026-07-14 */ + open fun getLifecycleByCode(code: String): PickOrderLifecycleResponse? { + val po = pickOrderRepository.findByCodeAndDeletedFalse(code) ?: return null + return po.id?.let { getLifecycle(it) } + } + + private fun buildPickOrderStages(po: com.ffii.fpsms.modules.pickOrder.entity.PickOrder): List { + val currentStatus = po.status ?: PickOrderStatus.PENDING + val statusOrder = listOf( + PickOrderStatus.PENDING to "待處理", + PickOrderStatus.CONSOLIDATED to "已合併", + PickOrderStatus.ASSIGNED to "已指派", + PickOrderStatus.RELEASED to "已發放", + PickOrderStatus.PICKING to "提料中", + PickOrderStatus.COMPLETED to "已完成", + ) + + val currentIdx = statusOrder.indexOfFirst { it.first == currentStatus } + + return statusOrder.mapIndexed { idx, (status, label) -> + val reached = idx <= currentIdx + val isCurrent = status == currentStatus + LifecycleStage( + status = status.value, + label = label, + reached = reached, + timestamp = when (status) { + PickOrderStatus.RELEASED -> po.releasedDate?.format(dtf) + PickOrderStatus.COMPLETED -> po.completeDate?.format(dtf) + else -> null + }, + actor = when (status) { + PickOrderStatus.RELEASED -> po.releasedBy?.name + PickOrderStatus.ASSIGNED -> po.assignTo?.name + else -> null + }, + actorId = when (status) { + PickOrderStatus.RELEASED -> po.releasedBy?.id + PickOrderStatus.ASSIGNED -> po.assignTo?.id + else -> null + }, + isCurrent = isCurrent, + details = buildStageDetails(po, status), + ) + } + } + + private fun buildStageDetails( + po: com.ffii.fpsms.modules.pickOrder.entity.PickOrder, + status: PickOrderStatus + ): Map { + val map = mutableMapOf() + when (status) { + PickOrderStatus.CONSOLIDATED -> { + po.consoCode?.let { map["consoCode"] = it } + } + PickOrderStatus.RELEASED -> { + po.consoCode?.let { map["consoCode"] = it } + map["plasticBoxCartonQty2f"] = po.plasticBoxCartonQty2f?.toString() + map["plasticBoxCartonQty3f"] = po.plasticBoxCartonQty3f?.toString() + map["plasticBoxCartonQty4f"] = po.plasticBoxCartonQty4f?.toString() + } + PickOrderStatus.COMPLETED -> { + map["totalLines"] = po.totalLines?.toString() + map["submittedLines"] = po.submittedLines?.toString() + } + else -> {} + } + return map + } + + private fun buildDoLifecycle(pickOrderId: Long): DoLifecycleInfo? { + // Check active do_pick_order first + val doPickOrderLines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(pickOrderId) + val doPickOrderId = doPickOrderLines.firstOrNull()?.doPickOrderId + + var dpo = doPickOrderId?.let { doPickOrderRepository.findById(it).orElse(null) } + var deliveryNoteCode: String? = null + + // If not in active table, check record table + if (dpo == null) { + val records = doPickOrderRecordRepository.findByPickOrderId(pickOrderId) + val record = records.maxByOrNull { it.id ?: 0L } + if (record != null) { + deliveryNoteCode = record.deliveryNoteCode + } + } + + if (dpo == null && deliveryNoteCode == null) { + // Try do_pick_order_record by looking up via delivery_order + val po = pickOrderRepository.findById(pickOrderId).orElse(null) + val doId = po?.deliveryOrder?.id + if (doId != null) { + val doRecords = doPickOrderRecordRepository.findByDoOrderIdAndDeletedFalse(doId) + val doRecord = doRecords.firstOrNull() + if (doRecord != null) { + deliveryNoteCode = doRecord.deliveryNoteCode + } + } + } + + if (dpo == null && deliveryNoteCode == null) return null + + val stages = buildDoStages(dpo) + return DoLifecycleInfo( + doPickOrderId = dpo?.id, + ticketNo = dpo?.ticketNo, + ticketStatus = dpo?.ticketStatus?.value ?: DoPickOrderStatus.completed.value, + storeId = dpo?.storeId, + shopName = dpo?.shopName, + truckLanceCode = dpo?.truckLanceCode, + deliveryNoteCode = deliveryNoteCode, + deliveryOrderCode = dpo?.deliveryOrderCode, + loadingSequence = dpo?.loadingSequence, + stages = stages, + ) + } + + private fun buildDoStages(dpo: com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrder?): List { + val currentStatus = dpo?.ticketStatus ?: DoPickOrderStatus.completed + val statusOrder = listOf( + DoPickOrderStatus.pending to "DO待處理", + DoPickOrderStatus.released to "DO已發放", + DoPickOrderStatus.completed to "DO已完成(DN產生)", + ) + + val currentIdx = statusOrder.indexOfFirst { it.first == currentStatus } + if (currentIdx < 0 && dpo == null) { + // Already completed and archived + return statusOrder.mapIndexed { idx, (status, label) -> + LifecycleStage( + status = status.value, + label = label, + reached = true, + timestamp = null, + actor = null, + actorId = null, + isCurrent = false, + ) + } + } + + return statusOrder.mapIndexed { idx, (status, label) -> + val reached = idx <= maxOf(currentIdx, 0) + val isCurrent = status == currentStatus + LifecycleStage( + status = status.value, + label = label, + reached = reached, + timestamp = when (status) { + DoPickOrderStatus.released -> dpo?.ticketReleaseTime?.format(dtf) + DoPickOrderStatus.completed -> dpo?.ticketCompleteDateTime?.format(dtf) + else -> null + }, + actor = when (status) { + DoPickOrderStatus.released -> dpo?.handlerName + DoPickOrderStatus.completed -> dpo?.handlerName + else -> null + }, + actorId = dpo?.handledBy, + isCurrent = isCurrent, + ) + } + } + + private fun buildJoLifecycle(pickOrderId: Long): JoLifecycleInfo? { + val jpos = joPickOrderRepository.findByPickOrderId(pickOrderId) + val jpo = jpos.firstOrNull() + + // Check record table if not in active + val jpoRecord = if (jpo == null) { + joPickOrderRecordRepository.findByPickOrderId(pickOrderId).firstOrNull() + } else null + + if (jpo == null && jpoRecord == null) return null + + val stages = buildJoStages(jpo) + return JoLifecycleInfo( + joPickOrderId = jpo?.id ?: jpoRecord?.id, + jobOrderCode = jpo?.jobOrderCode ?: jpoRecord?.jobOrderCode, + matchStatus = jpo?.matchStatus?.value ?: jpoRecord?.matchStatus?.value, + matchQty = jpo?.matchQty ?: jpoRecord?.matchQty, + matchBy = jpo?.matchingBy?.toString() ?: jpoRecord?.matchBy?.toString(), + stages = stages, + ) + } + + private fun buildJoStages(jpo: com.ffii.fpsms.modules.jobOrder.entity.JoPickOrder?): List { + val currentStatus = jpo?.matchStatus ?: JoPickOrderStatus.completed + val statusOrder = listOf( + JoPickOrderStatus.pending to "等待二次掃描", + JoPickOrderStatus.scanned to "已掃描", + JoPickOrderStatus.completed to "配對完成", + ) + + val currentIdx = statusOrder.indexOfFirst { it.first == currentStatus } + if (currentIdx < 0 && jpo == null) { + return statusOrder.mapIndexed { idx, (status, label) -> + LifecycleStage( + status = status.value, + label = label, + reached = true, + timestamp = null, + actor = null, + actorId = null, + isCurrent = false, + ) + } + } + + return statusOrder.mapIndexed { idx, (status, label) -> + val reached = idx <= maxOf(currentIdx, 0) + val isCurrent = status == currentStatus + LifecycleStage( + status = status.value, + label = label, + reached = reached, + timestamp = null, + actor = null, + actorId = jpo?.matchingBy, + isCurrent = isCurrent, + ) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderLifecycleController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderLifecycleController.kt new file mode 100644 index 0000000..8601be1 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderLifecycleController.kt @@ -0,0 +1,28 @@ +package com.ffii.fpsms.modules.pickOrder.web + +import com.ffii.fpsms.modules.pickOrder.service.PickOrderLifecycleService +import com.ffii.fpsms.modules.pickOrder.web.models.PickOrderLifecycleResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/pickOrder") +class PickOrderLifecycleController( + private val lifecycleService: PickOrderLifecycleService, +) { + /** FP-MTMS Version Checklist | Functions Ref. No. 10 | v1.0.1 | 2026-07-14 */ + @GetMapping("/{id}/lifecycle") + fun getLifecycle(@PathVariable id: Long): ResponseEntity { + val result = lifecycleService.getLifecycle(id) + return if (result != null) ResponseEntity.ok(result) + else ResponseEntity.notFound().build() + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 10 | v1.0.1 | 2026-07-14 */ + @GetMapping("/lifecycle/by-code") + fun getLifecycleByCode(@RequestParam code: String): ResponseEntity { + val result = lifecycleService.getLifecycleByCode(code) + return if (result != null) ResponseEntity.ok(result) + else ResponseEntity.notFound().build() + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/PickOrderLifecycleResponse.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/PickOrderLifecycleResponse.kt new file mode 100644 index 0000000..858e284 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/PickOrderLifecycleResponse.kt @@ -0,0 +1,50 @@ +package com.ffii.fpsms.modules.pickOrder.web.models + +data class LifecycleStage( + val status: String, + val label: String, + val reached: Boolean, + val timestamp: String?, + val actor: String?, + val actorId: Long?, + val isCurrent: Boolean, + val details: Map = emptyMap(), +) + +data class DoLifecycleInfo( + val doPickOrderId: Long?, + val ticketNo: String?, + val ticketStatus: String?, + val storeId: String?, + val shopName: String?, + val truckLanceCode: String?, + val deliveryNoteCode: String?, + val deliveryOrderCode: String?, + val loadingSequence: Int?, + val stages: List, +) + +data class JoLifecycleInfo( + val joPickOrderId: Long?, + val jobOrderCode: String?, + val matchStatus: String?, + val matchQty: Int?, + val matchBy: String?, + val stages: List, +) + +data class PickOrderLifecycleResponse( + val pickOrderId: Long, + val pickOrderCode: String, + val pickOrderType: String, + val currentStatus: String, + val targetDate: String?, + val completeDate: String?, + val consoCode: String?, + val groupName: String?, + val totalLines: Int?, + val submittedLines: Int?, + val stages: List, + val doLifecycle: DoLifecycleInfo?, + val joLifecycle: JoLifecycleInfo?, +) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotRepository.kt index f4dbaf6..5635bc3 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotRepository.kt @@ -21,7 +21,10 @@ interface InventoryLotRepository: AbstractRepository { WHERE il.lotNo = :lotNo AND il.item.id = :itemId AND il.deleted = false + ORDER BY il.id ASC """) - fun findByLotNoAndItemId(lotNo: String, itemId: Long): InventoryLot? + fun findAllByLotNoAndItemIdAndDeletedFalseOrderByIdAsc(lotNo: String, itemId: Long): List + + fun findFirstByStockInLineIdAndDeletedFalseOrderByIdAsc(stockInLineId: Serializable): InventoryLot? fun findByIdAndDeletedFalse(id: Serializable): InventoryLot? } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLog.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLog.kt new file mode 100644 index 0000000..ff82cf7 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLog.kt @@ -0,0 +1,36 @@ +package com.ffii.fpsms.modules.stock.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +@Entity +@Table(name = "item_lot_trace_log") +open class ItemLotTraceLog : BaseEntity() { + + @NotNull + @Column(name = "userId", nullable = false) + open var userId: Long? = null + + @Size(max = 64) + @NotNull + @Column(name = "username", nullable = false, length = 64) + open var username: String? = null + + @Column(name = "inventoryLotId") + open var inventoryLotId: Long? = null + + @Size(max = 64) + @Column(name = "lotNo", length = 64) + open var lotNo: String? = null + + @Column(name = "itemId") + open var itemId: Long? = null + + @Size(max = 64) + @Column(name = "itemCode", length = 64) + open var itemCode: String? = null +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLogRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLogRepository.kt new file mode 100644 index 0000000..3dddb2d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLogRepository.kt @@ -0,0 +1,7 @@ +package com.ffii.fpsms.modules.stock.entity + +import com.ffii.core.support.AbstractRepository +import org.springframework.stereotype.Repository + +@Repository +interface ItemLotTraceLogRepository : AbstractRepository diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceLogService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceLogService.kt new file mode 100644 index 0000000..3f98b6e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceLogService.kt @@ -0,0 +1,60 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.stock.entity.ItemLotTraceLog +import com.ffii.fpsms.modules.stock.entity.ItemLotTraceLogRepository +import com.ffii.fpsms.modules.user.entity.User +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Isolation +import org.springframework.transaction.annotation.Transactional + +@Service +open class ItemLotTraceLogService( + private val itemLotTraceLogRepository: ItemLotTraceLogRepository, +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = [Exception::class], readOnly = false) + open fun insert( + user: User, + inventoryLotId: Long?, + lotNo: String?, + itemId: Long?, + itemCode: String?, + ) { + val entity = ItemLotTraceLog().apply { + this.userId = user.id + this.username = user.username + this.inventoryLotId = inventoryLotId + this.lotNo = lotNo?.takeIf { it.isNotBlank() } + this.itemId = itemId + this.itemCode = itemCode?.takeIf { it.isNotBlank() } + } + itemLotTraceLogRepository.save(entity) + } + + /** Best-effort audit write; never fails the main trace. */ + open fun insertQuietly( + user: User?, + inventoryLotId: Long?, + lotNo: String?, + itemId: Long?, + itemCode: String?, + ) { + if (user?.id == null) { + log.warn("Skip item_lot_trace_log insert: no authenticated user") + return + } + try { + insert( + user = user, + inventoryLotId = inventoryLotId, + lotNo = lotNo, + itemId = itemId, + itemCode = itemCode, + ) + } catch (e: Exception) { + log.warn("Failed to insert item_lot_trace_log: {}", e.message, e) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceService.kt new file mode 100644 index 0000000..7f43e76 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceService.kt @@ -0,0 +1,48 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.common.SecurityUtils +import com.ffii.fpsms.modules.stock.trace.ItemLotTraceOrchestrator +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse +import org.springframework.stereotype.Service + +@Service +open class ItemLotTraceService( + private val orchestrator: ItemLotTraceOrchestrator, + private val itemLotTraceLogService: ItemLotTraceLogService, +) { + /** FP-MTMS Version Checklist | Functions Ref. No. 7 | v1.0.0 | 2026-07-14 */ + open fun trace( + stockInLineId: Long?, + inventoryLotLineId: Long?, + lotNo: String?, + itemId: Long?, + itemCode: String?, + ): ItemLotTraceResponse { + val response = orchestrator.trace( + stockInLineId, + inventoryLotLineId, + lotNo, + itemId, + itemCode, + ) + val lot = response.lot + itemLotTraceLogService.insertQuietly( + user = SecurityUtils.getUser().orElse(null), + inventoryLotId = lot.inventoryLotId, + lotNo = lot.lotNo, + itemId = lot.itemId, + itemCode = lot.itemCode, + ) + return response + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 7 | v1.0.0 | 2026-07-14 + * Lazy-load endpoint: returns the full location-scoped trace block for a single inventory lot. + * Lot-level dimensions (BOM, JO prelude, production, byproducts) are excluded — those are only + * available via the main /trace endpoint. + */ + open fun traceLocation(inventoryLotId: Long): ItemLotTraceLocationBlock = + orchestrator.traceLocation(inventoryLotId) +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 67d3f98..a2c925e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -581,7 +581,11 @@ open class StockInLineService( this.stockInLine = stockInLine this.failQty = it.failQty this.qcPassed = it.qcPassed - this.type = "qc" + this.type = when { + stockInLine.jobOrder != null -> "EPQC" + stockInLine.purchaseOrderLine != null -> "IQC" + else -> "IQC" + } this.remarks = it.remarks this.escalationLog = escalationLogService.find(escLogId).getOrNull() } 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 new file mode 100644 index 0000000..e17866b --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt @@ -0,0 +1,317 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.fpsms.modules.master.entity.ItemsRepository +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException + +@Component +open class ItemLotTraceOrchestrator( + private val lotResolver: TraceLotResolver, + private val inboundLoader: TraceInboundLoader, + private val movementLoader: TraceMovementLoader, + private val outboundLoader: TraceOutboundLoader, + private val bomLoader: TraceBomLoader, + private val productionLoader: TraceProductionLoader, + private val joPreludeLoader: TraceJoPreludeLoader, + private val extendedLoader: TraceExtendedLoader, + private val upstreamLoader: TraceUpstreamLoader, + private val itemsRepository: ItemsRepository, + private val traceGraphSemanticsBuilder: TraceGraphSemanticsBuilder, +) { + open fun trace( + stockInLineId: Long?, + inventoryLotLineId: Long?, + lotNo: String?, + itemId: Long?, + itemCode: String?, + ): ItemLotTraceResponse { + val resolvedItemId = itemId ?: itemCode?.trim()?.takeIf { it.isNotEmpty() }?.let { code -> + itemsRepository.findByCodeAndDeletedFalse(code)?.id + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Item code not found: $code") + } + + val inventoryLotId = lotResolver.resolveInventoryLotId( + stockInLineId, + inventoryLotLineId, + lotNo, + resolvedItemId, + ) + val lot = lotResolver.loadLotInfo(inventoryLotId) + val lotLineIds = lotResolver.loadLotLineIds(inventoryLotId) + val stockInLineIds = lotResolver.loadStockInLineIds(inventoryLotId) + + val joProvenance = upstreamLoader.resolveJoProvenance( + inventoryLotId, + itemId = lot.itemId, + lotNo = lot.lotNo, + ) + + val effectiveJoSilId = lot.primaryStockInLineId?.takeIf { bomLoader.hasJoStockIn(it) } + ?: joProvenance?.joStockInLineId + val joId = lotResolver.resolveJobOrderIdForLot(inventoryLotId, lot.primaryStockInLineId) + ?: joProvenance?.jobOrderId + + val warehouseLines = lotResolver.loadWarehouseLines(inventoryLotId) + val alternateLocations = lotResolver.loadAlternateLocations(lot.itemId, lot.lotNo, inventoryLotId) + val altLotIdSet = alternateLocations.map { it.inventoryLotId }.toSet() + + // Transfer-chain / JO upstream lots whose PO/QC/putaway should surface on the + // destination lot when its own stock-in is only TRANSFER (helpers were unused). + // Sibling same-lotNo lots already arrive via locationBlocks — exclude them here + // to avoid duplicating rows in merged tabs. + val upstreamMergeLotIds = ( + upstreamLoader.resolveUpstreamLotIds(inventoryLotId, lot.itemId, lot.lotNo) + + (joProvenance?.upstreamLotIds.orEmpty()) + ) + .distinct() + .filter { it != inventoryLotId && it !in altLotIdSet } + + val origins = inboundLoader.loadOriginsForLots(listOf(inventoryLotId) + upstreamMergeLotIds) + .distinctBy { it.stockInLineId } + val upstreamStockInLineIds = if (upstreamMergeLotIds.isNotEmpty()) { + lotResolver.loadStockInLineIdsForLots(upstreamMergeLotIds).values.flatten().distinct() + } else { + emptyList() + } + val mergedStockInLineIds = (stockInLineIds + upstreamStockInLineIds).distinct() + val purchaseEvents = inboundLoader.loadPurchaseEvents(mergedStockInLineIds) + .distinctBy { it.purchaseOrderLineId } + val qcResults = inboundLoader.loadQcResults(mergedStockInLineIds) + .distinctBy { "${it.stockInLineId}|${it.qcItemCode}|${it.qcType}|${it.created}" } + val putawayEvents = ( + inboundLoader.loadPutawayEvents(inventoryLotId) + + upstreamMergeLotIds.flatMap { inboundLoader.loadPutawayEvents(it) } + ) + .distinctBy { "${it.inventoryLotLineId}|${it.timestamp}|${it.refCode}|${it.qty}" } + val movements = movementLoader.loadMovements(inventoryLotId, lotLineIds) + val outboundUsage = outboundLoader.loadOutboundUsage(inventoryLotId) + val stockTakeEvents = movementLoader.loadStockTakeEvents(lotLineIds) + val adjustments = movementLoader.loadAdjustments(inventoryLotId, lotLineIds) + val transfers = movementLoader.loadTransfers(inventoryLotId) + val bomTrace = bomLoader.loadBomTrace( + inventoryLotId, + effectiveJoSilId ?: lot.primaryStockInLineId, + lot.itemId, + lotLineIds, + ) + val joPrelude = effectiveJoSilId?.let { silId -> + if (bomLoader.hasJoStockIn(silId)) joPreludeLoader.loadJoPrelude(silId) else null + } + val productionSteps = joId?.let { productionLoader.loadProductionSteps(it) } ?: emptyList() + val byproductLots = joId?.let { productionLoader.loadByproductLots(it, lot.itemId) } ?: emptyList() + val openMovements = movementLoader.loadOpenMovements(inventoryLotId) + val failEvents = extendedLoader.loadFailEvents(inventoryLotId) + val rawMovements = movements.sortedByDescending { it.timestamp ?: "" } + val doDeliveries = resolveDoDeliveries(listOf(inventoryLotId), rawMovements) + val movementsWithDoMeta = enrichDoMovements(rawMovements, doDeliveries) + val returnEvents = outboundLoader.loadReturnEvents(inventoryLotId, lotLineIds) + val replenishmentEvents = outboundLoader.loadReplenishmentEvents(inventoryLotId) + val lotRelations = extendedLoader.loadLotRelations(inventoryLotId, lot.itemId, lot.lotNo) + + // Batch pre-load alternate location data to avoid N+1 queries + val altLotIds = alternateLocations.map { it.inventoryLotId } + val altLotInfoByLotId = if (altLotIds.isNotEmpty()) lotResolver.loadLotInfoBatch(altLotIds) else emptyMap() + val altStockInLineIdsByLotId = if (altLotIds.isNotEmpty()) lotResolver.loadStockInLineIdsForLots(altLotIds) else emptyMap() + val altLotLineIdsByLotId = if (altLotIds.isNotEmpty()) lotResolver.loadLotLineIdsForLots(altLotIds) else emptyMap() + val altPutawayByLotId = if (altLotIds.isNotEmpty()) inboundLoader.loadPutawayEventsByLotIds(altLotIds) else emptyMap() + + // Batch pre-load origins, purchase events, and QC results across ALL alternate locations + val altOriginsByLotId: Map> = + if (altLotIds.isNotEmpty()) { + inboundLoader.loadOriginsForLots(altLotIds) + .groupBy { origin -> + // Map back to lot via stockInLineId lookup + altStockInLineIdsByLotId.entries + .firstOrNull { (_, silIds) -> silIds.contains(origin.stockInLineId) }?.key + ?: altLotIds.first() + } + } else emptyMap() + + val allAltStockInLineIds = altStockInLineIdsByLotId.values.flatten().distinct() + val altPurchaseEventsBySilId: Map> = + if (allAltStockInLineIds.isNotEmpty()) { + inboundLoader.loadPurchaseEvents(allAltStockInLineIds) + .groupBy { it.purchaseOrderLineId } + } else emptyMap() + + val altQcResultsBySilId: Map> = + if (allAltStockInLineIds.isNotEmpty()) { + inboundLoader.loadQcResults(allAltStockInLineIds) + .groupBy { it.stockInLineId } + } else emptyMap() + + val locationBlocks = alternateLocations.map { alt -> + val altLot = altLotInfoByLotId[alt.inventoryLotId] + val altSilIds = altStockInLineIdsByLotId[alt.inventoryLotId].orEmpty() + traceLocationBlock( + inventoryLotId = alt.inventoryLotId, + itemCode = altLot?.itemCode ?: lot.itemCode, + lotNo = altLot?.lotNo ?: lot.lotNo, + itemName = altLot?.itemName ?: "", + uom = altLot?.uom ?: "", + expiryDate = altLot?.expiryDate, + productionDate = altLot?.productionDate, + stockInDate = altLot?.stockInDate, + primaryStockInLineId = altLot?.primaryStockInLineId, + preloadedStockInLineIds = altSilIds, + preloadedLotLineIds = altLotLineIdsByLotId[alt.inventoryLotId], + preloadedPutawayEvents = altPutawayByLotId[alt.inventoryLotId], + preloadedOrigins = altOriginsByLotId[alt.inventoryLotId], + preloadedPurchaseEvents = altSilIds.flatMap { sid -> altPurchaseEventsBySilId[sid].orEmpty() }.distinctBy { it.purchaseOrderLineId }, + preloadedQcResults = altSilIds.flatMap { sid -> altQcResultsBySilId[sid].orEmpty() }, + ) + } + + return ItemLotTraceResponse( + lot = lot, + warehouseLines = warehouseLines, + origins = origins, + purchaseEvents = purchaseEvents, + qcResults = qcResults, + putawayEvents = putawayEvents.sortedBy { it.timestamp ?: "" }, + movements = movementsWithDoMeta, + outboundUsage = outboundUsage.sortedByDescending { it.timestamp ?: "" }, + stockTakeEvents = stockTakeEvents.sortedByDescending { it.timestamp ?: "" }, + adjustments = adjustments.sortedByDescending { it.timestamp ?: "" }, + transfers = transfers.sortedByDescending { it.timestamp ?: "" }, + bomTrace = bomTrace, + joPrelude = joPrelude, + productionSteps = productionSteps, + byproductLots = byproductLots, + openMovements = openMovements, + failEvents = failEvents, + doDeliveries = doDeliveries, + replenishmentEvents = replenishmentEvents, + returnEvents = returnEvents, + lotRelations = lotRelations, + alternateLocations = alternateLocations, + locationBlocks = locationBlocks, + ).let { response -> + response.copy(traceGraph = traceGraphSemanticsBuilder.build(response)) + } + } + + private fun resolveDoDeliveries( + mergedLotIds: List, + movements: List, + ): List { + val fromLots = mergedLotIds.flatMap { outboundLoader.loadDoDeliveries(it) } + val covered = fromLots.map { it.pickOrderCode.trim() }.filter { it.isNotEmpty() }.toSet() + val missingCodes = movements + .filter { it.refType.equals("DO", ignoreCase = true) } + .map { it.refCode.trim() } + .filter { it.isNotEmpty() && it !in covered } + .distinct() + val supplemental = outboundLoader.loadDoDeliveriesByPickOrderCodes(missingCodes) + return (fromLots + supplemental).distinctBy { it.stockOutLineId } + } + + private fun enrichDoMovements( + movements: List, + doDeliveries: List, + ): List { + val metaByPickCode = doDeliveries.associateBy { it.pickOrderCode.trim() } + return movements.map { movement -> + if (!movement.refType.equals("DO", ignoreCase = true)) return@map movement + val meta = metaByPickCode[movement.refCode.trim()] ?: return@map movement + movement.copy( + pickConsoCode = meta.consoCode, + outboundTicketNo = meta.ticketNo, + deliveryOrderPickOrderId = meta.deliveryOrderPickOrderId, + relationshipId = meta.relationshipId, + doOutboundIsExtra = meta.isExtra, + doOutboundIsReplenish = meta.isReplenish, + ) + } + } + + /** + * Builds a location-scoped trace block for a single alternate [inventoryLotId]. + * + * Only includes dimensions that are location-scoped (warehouse lines, movements, origins, + * outbound usage, stock take, etc.). Lot-level dimensions (BOM, JO prelude, production steps, + * byproduct lots, lot relations, trace graph) are NOT included — they're already at the top + * level of [ItemLotTraceResponse] and are shared across all locations of the same lot. + */ + open fun traceLocationBlock( + inventoryLotId: Long, + itemCode: String, + lotNo: String, + itemName: String = "", + uom: String = "", + expiryDate: String? = null, + productionDate: String? = null, + stockInDate: String? = null, + primaryStockInLineId: Long? = null, + /** Pre-loaded from batch query — avoids per-location stockInLineIds query. */ + preloadedStockInLineIds: List? = null, + /** Pre-loaded from batch query — avoids per-location lotLineIds query. */ + preloadedLotLineIds: List? = null, + /** Pre-loaded from batch query — avoids per-location putaway query. */ + preloadedPutawayEvents: List? = null, + /** Pre-loaded from batch query — avoids per-location origins query. */ + preloadedOrigins: List? = null, + /** Pre-loaded from batch query — avoids per-location purchaseEvents query. */ + preloadedPurchaseEvents: List? = null, + /** Pre-loaded from batch query — avoids per-location qcResults query. */ + preloadedQcResults: List? = null, + ): com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock { + val lotLineIds = preloadedLotLineIds ?: lotResolver.loadLotLineIds(inventoryLotId) + val stockInLineIds = preloadedStockInLineIds ?: lotResolver.loadStockInLineIds(inventoryLotId) + val effectiveSilId = primaryStockInLineId ?: stockInLineIds.firstOrNull() + val rawMovements = movementLoader.loadMovements(inventoryLotId, lotLineIds) + .sortedByDescending { it.timestamp ?: "" } + val doDeliveries = resolveDoDeliveries(listOf(inventoryLotId), rawMovements) + val movementsWithDoMeta = enrichDoMovements(rawMovements, doDeliveries) + + return com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock( + inventoryLotId = inventoryLotId, + lotNo = lotNo, + itemCode = itemCode, + itemName = itemName, + uom = uom, + expiryDate = expiryDate, + productionDate = productionDate, + stockInDate = stockInDate, + stockInLineId = effectiveSilId, + warehouseLines = lotResolver.loadWarehouseLines(inventoryLotId), + origins = preloadedOrigins ?: inboundLoader.loadOrigins(inventoryLotId), + purchaseEvents = preloadedPurchaseEvents ?: inboundLoader.loadPurchaseEvents(stockInLineIds), + qcResults = preloadedQcResults ?: inboundLoader.loadQcResults(stockInLineIds), + putawayEvents = preloadedPutawayEvents ?: inboundLoader.loadPutawayEvents(inventoryLotId), + movements = movementsWithDoMeta, + outboundUsage = outboundLoader.loadOutboundUsage(inventoryLotId), + stockTakeEvents = movementLoader.loadStockTakeEvents(lotLineIds), + adjustments = movementLoader.loadAdjustments(inventoryLotId, lotLineIds), + transfers = movementLoader.loadTransfers(inventoryLotId), + openMovements = movementLoader.loadOpenMovements(inventoryLotId), + failEvents = extendedLoader.loadFailEvents(inventoryLotId), + doDeliveries = doDeliveries, + replenishmentEvents = outboundLoader.loadReplenishmentEvents(inventoryLotId), + returnEvents = outboundLoader.loadReturnEvents(inventoryLotId, lotLineIds), + ) + } + + /** + * Lazy-load entry point: resolves lot info from [inventoryLotId] then delegates to + * [traceLocationBlock]. Used by the controller's `GET /trace/location/{inventoryLotId}` endpoint. + */ + open fun traceLocation( + inventoryLotId: Long, + ): com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock { + val lot = lotResolver.loadLotInfo(inventoryLotId) + return traceLocationBlock( + inventoryLotId = inventoryLotId, + itemCode = lot.itemCode, + lotNo = lot.lotNo, + itemName = lot.itemName, + uom = lot.uom, + expiryDate = lot.expiryDate, + productionDate = lot.productionDate, + stockInDate = lot.stockInDate, + primaryStockInLineId = lot.primaryStockInLineId, + ) + } +} 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 new file mode 100644 index 0000000..24c1d1d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt @@ -0,0 +1,308 @@ +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 + +data class BomStepAssignment( + val bomProcessId: Long, + val bomProcessSeqNo: Long, + val stepName: String, +) + +@Component +open class TraceBomLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, + private val lotResolver: TraceLotResolver, +) { + open fun itemCodeKey(code: String): String = code.trim().uppercase() + + open fun loadBomTrace( + inventoryLotId: Long, + primaryStockInLineId: Long?, + itemId: Long, + lotLineIds: List, + ): ItemLotTraceBomTrace { + val isFinishedGood = primaryStockInLineId?.let { hasJoStockIn(it) } == true + val isMaterial = lotLineIds.isNotEmpty() && hasJoStockOut(lotLineIds) + + val direction = when { + isFinishedGood -> "FINISHED_GOOD" + isMaterial -> "MATERIAL" + else -> "UNKNOWN" + } + + val downstream = if (isMaterial) loadBomDownstream(lotLineIds) else emptyList() + val upstream = if (isFinishedGood && primaryStockInLineId != null) { + loadBomUpstream(primaryStockInLineId) + } else { + emptyList() + } + + val bomRecipe = when { + isFinishedGood && primaryStockInLineId != null -> loadBomRecipeForFgStockIn(primaryStockInLineId) + isMaterial && downstream.isNotEmpty() -> { + val joId = downstream.first().jobOrderId + if (joId != null) loadBomRecipeForJobOrder(joId) else emptyList() + } + else -> emptyList() + } + + return ItemLotTraceBomTrace( + direction = direction, + downstream = downstream, + upstream = upstream, + bomRecipe = bomRecipe, + ) + } + + open fun hasJoStockIn(stockInLineId: Long): Boolean { + val query = """ + SELECT COUNT(*) AS cnt FROM stock_in_line + WHERE id = :stockInLineId AND deleted = 0 AND jobOrderId IS NOT NULL + """.trimIndent() + val cnt = jdbcDao.queryForList(query, mapOf("stockInLineId" to stockInLineId)) + .firstOrNull()?.let { (it["cnt"] as? Number)?.toLong() ?: 0L } ?: 0L + return cnt > 0 + } + + /** Resolve the JO finished-goods stock-in line for a material lot (semi-finished from another JO). */ + open fun resolveJoProducedStockInLineId( + materialStockInLineId: Long?, + materialInventoryLotId: Long?, + silIdsByLot: Map>, + ): Long? { + materialStockInLineId?.takeIf { hasJoStockIn(it) }?.let { return it } + materialInventoryLotId?.let { lotId -> + silIdsByLot[lotId] + ?.sortedDescending() + ?.firstOrNull { hasJoStockIn(it) } + }?.let { return it } + if (materialInventoryLotId == null) return null + val query = """ + SELECT id FROM stock_in_line + WHERE inventoryLotId = :inventoryLotId AND deleted = 0 AND jobOrderId IS NOT NULL + ORDER BY created DESC + LIMIT 1 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to materialInventoryLotId)) + .firstOrNull()?.let { sql.longVal(it["id"]) } + } + + open fun isJoProducedMaterial( + resolvedSilId: Long?, + materialInventoryLotId: Long?, + stockInOrigin: ItemLotTraceOrigin?, + ): Boolean { + if (resolvedSilId != null && hasJoStockIn(resolvedSilId)) return true + if (stockInOrigin?.type.equals("JO", ignoreCase = true)) return true + return materialInventoryLotId?.let { lotResolver.resolveJobOrderIdForLot(it, null) != null } == true + } + + open fun hasJoStockOut(lotLineIds: List): Boolean { + val query = """ + SELECT COUNT(*) AS cnt + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 + WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds) + AND so.type IN ('job', 'jo') + """.trimIndent() + val cnt = jdbcDao.queryForList(query, mapOf("lotLineIds" to lotLineIds)) + .firstOrNull()?.let { (it["cnt"] as? Number)?.toLong() ?: 0L } ?: 0L + return cnt > 0 + } + + open fun loadBomDownstream(lotLineIds: List): List { + val query = """ + SELECT DISTINCT + COALESCE(jo.code, '') AS jobOrderCode, + jo.id AS jobOrderId, + COALESCE(fg_stock_it.code, fg_bom_it.code, '') AS finishedItemCode, + COALESCE(fg_il.lotNo, fg_sil.lotNo, '') AS finishedLotNo, + fg_sil.id AS finishedStockInLineId, + COALESCE(fg_sil.acceptedQty, 0) AS fgQty, + COALESCE(fg_uc.udfudesc, '') AS fgUom, + COALESCE(sol.qty, 0) AS materialQtyUsed + 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 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 item_uom fg_iu ON fg_item.id = fg_iu.itemId AND fg_iu.stockUnit = 1 AND fg_iu.deleted = 0 + LEFT JOIN uom_conversion fg_uc ON fg_iu.uomId = fg_uc.id AND fg_uc.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 -> + ItemLotTraceBomDownstream( + jobOrderCode = sql.str(row["jobOrderCode"]), + jobOrderId = sql.longVal(row["jobOrderId"]), + finishedItemCode = sql.str(row["finishedItemCode"]), + finishedLotNo = sql.str(row["finishedLotNo"]), + finishedStockInLineId = sql.longVal(row["finishedStockInLineId"]), + fgQty = sql.decimal(row["fgQty"]), + fgUom = sql.str(row["fgUom"]), + materialQtyUsed = sql.decimal(row["materialQtyUsed"]), + ) + } + } + + open fun loadBomUpstream(primaryStockInLineId: Long): List { + val query = """ + SELECT + COALESCE(jo.code, '') AS jobOrderCode, + jo.id AS jobOrderId, + COALESCE(mat_it.code, '') AS materialItemCode, + COALESCE(mat_il.lotNo, mat_sil.lotNo, '') AS materialLotNo, + mat_sil.id AS materialStockInLineId, + COALESCE(sol.qty, 0) AS materialQty, + bm.qty AS bomQtyPerUnit + FROM stock_in_line fg_sil + INNER JOIN job_order jo ON fg_sil.jobOrderId = jo.id AND jo.deleted = 0 + INNER JOIN pick_order po ON po.joId = jo.id AND po.deleted = 0 + AND po.type IN ('jo', 'JOB_ORDER') + INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0 + INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0 + INNER JOIN inventory_lot_line mat_ill ON sol.inventoryLotLineId = mat_ill.id AND mat_ill.deleted = 0 + INNER JOIN inventory_lot mat_il ON mat_ill.inventoryLotId = mat_il.id AND mat_il.deleted = 0 + LEFT JOIN stock_in_line mat_sil ON mat_il.stockInLineId = mat_sil.id AND mat_sil.deleted = 0 + LEFT JOIN items mat_it ON mat_il.itemId = mat_it.id AND mat_it.deleted = 0 + LEFT JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 + LEFT JOIN bom_material bm ON bm.bomId = b.id AND bm.itemId = mat_it.id AND bm.deleted = 0 + WHERE fg_sil.id = :primaryStockInLineId AND fg_sil.deleted = 0 + ORDER BY mat_it.code, mat_il.lotNo + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("primaryStockInLineId" to primaryStockInLineId)).map { row -> + ItemLotTraceBomUpstream( + jobOrderCode = sql.str(row["jobOrderCode"]), + jobOrderId = sql.longVal(row["jobOrderId"]), + materialItemCode = sql.str(row["materialItemCode"]), + materialLotNo = sql.str(row["materialLotNo"]), + materialStockInLineId = sql.longVal(row["materialStockInLineId"]), + materialQty = sql.decimal(row["materialQty"]), + bomQtyPerUnit = row["bomQtyPerUnit"]?.let { sql.decimal(it) }, + ) + } + } + + open fun loadBomRecipeForFgStockIn(primaryStockInLineId: Long): List { + val joId = getJobOrderIdForFgStockIn(primaryStockInLineId) + return if (joId != null) loadBomRecipeForJobOrder(joId) else emptyList() + } + + open fun loadBomRecipeForJobOrder(jobOrderId: Long): List { + val stepByItem = loadBomMaterialStepAssignments(jobOrderId) + val query = """ + SELECT + COALESCE(mat_it.code, '') AS materialItemCode, + COALESCE(bm.itemName, mat_it.name, '') AS materialItemName, + COALESCE(bm.qty, 0) AS qtyPerUnit, + COALESCE(bm.uomName, uc.udfudesc, '') AS uom + FROM job_order jo + INNER JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 + INNER JOIN bom_material bm ON bm.bomId = b.id AND bm.deleted = 0 + LEFT JOIN items mat_it ON bm.itemId = mat_it.id AND mat_it.deleted = 0 + LEFT JOIN uom_conversion uc ON bm.uomId = uc.id + WHERE jo.id = :jobOrderId AND jo.deleted = 0 + ORDER BY mat_it.code + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).map { row -> + val itemCode = sql.str(row["materialItemCode"]) + val assignment = stepByItem[itemCodeKey(itemCode)] + ItemLotTraceBomRecipeLine( + materialItemCode = itemCode, + materialItemName = sql.str(row["materialItemName"]), + qtyPerUnit = sql.decimal(row["qtyPerUnit"]), + uom = sql.str(row["uom"]), + bomProcessId = assignment?.bomProcessId, + bomProcessSeqNo = assignment?.bomProcessSeqNo, + assignedStepName = assignment?.stepName ?: "", + ) + } + } + + open fun loadBomMaterialStepAssignments(jobOrderId: Long): Map { + val query = """ + SELECT + COALESCE(it.code, '') AS materialItemCode, + bp.id AS bomProcessId, + bp.seqNo AS bomProcessSeqNo, + COALESCE(p.name, bp.description, '') AS stepName + FROM job_order jo + INNER JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 + INNER JOIN bom_material bm ON bm.bomId = b.id AND bm.deleted = 0 + INNER JOIN bom_process_material bpm ON bpm.bomMaterialId = bm.id AND bpm.deleted = 0 + INNER JOIN bom_process bp ON bp.id = bpm.bomProcessId AND bp.deleted = 0 + LEFT JOIN process p ON p.id = bp.processId AND p.deleted = 0 + LEFT JOIN items it ON bm.itemId = it.id AND it.deleted = 0 + WHERE jo.id = :jobOrderId AND jo.deleted = 0 + ORDER BY bp.seqNo, it.code + """.trimIndent() + val result = linkedMapOf() + jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).forEach { row -> + val key = itemCodeKey(sql.str(row["materialItemCode"])) + if (key.isEmpty() || key in result) return@forEach + val bomProcessId = sql.longVal(row["bomProcessId"]) ?: return@forEach + result[key] = BomStepAssignment( + bomProcessId = bomProcessId, + bomProcessSeqNo = sql.longVal(row["bomProcessSeqNo"]) ?: 0L, + stepName = sql.str(row["stepName"]), + ) + } + return result + } + + open fun loadStepMaterialsByJobOrder(jobOrderId: Long): Map> { + val query = """ + SELECT + bp.id AS bomProcessId, + COALESCE(it.code, '') AS materialItemCode, + COALESCE(bm.itemName, it.name, '') AS materialItemName, + COALESCE(bm.qty, 0) AS qtyPerUnit, + COALESCE(bm.uomName, uc.udfudesc, '') AS uom + FROM job_order jo + INNER JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 + INNER JOIN bom_process bp ON bp.bomId = b.id AND bp.deleted = 0 + INNER JOIN bom_process_material bpm ON bpm.bomProcessId = bp.id AND bpm.deleted = 0 + INNER JOIN bom_material bm ON bm.id = bpm.bomMaterialId AND bm.deleted = 0 + LEFT JOIN items it ON bm.itemId = it.id AND it.deleted = 0 + LEFT JOIN uom_conversion uc ON bm.uomId = uc.id + WHERE jo.id = :jobOrderId AND jo.deleted = 0 + ORDER BY bp.seqNo, it.code + """.trimIndent() + val grouped = linkedMapOf>() + jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).forEach { row -> + val bomProcessId = sql.longVal(row["bomProcessId"]) ?: return@forEach + val itemCode = sql.str(row["materialItemCode"]) + val list = grouped.getOrPut(bomProcessId) { mutableListOf() } + if (list.any { it.materialItemCode.equals(itemCode, ignoreCase = true) }) return@forEach + list.add( + ItemLotTraceStepMaterialRef( + materialItemCode = itemCode, + materialItemName = sql.str(row["materialItemName"]), + qtyPerUnit = sql.decimal(row["qtyPerUnit"]), + uom = sql.str(row["uom"]), + ), + ) + } + return grouped + } + + open fun getJobOrderIdForFgStockIn(primaryStockInLineId: Long): Long? { + val query = """ + SELECT jo.id AS jobOrderId FROM stock_in_line sil + INNER JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + WHERE sil.id = :primaryStockInLineId AND sil.deleted = 0 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("primaryStockInLineId" to primaryStockInLineId)) + .firstOrNull()?.let { sql.longVal(it["jobOrderId"]) } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceExtendedLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceExtendedLoader.kt new file mode 100644 index 0000000..66019b1 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceExtendedLoader.kt @@ -0,0 +1,112 @@ +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 + +@Component +open class TraceExtendedLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, +) { + open fun loadFailEvents(inventoryLotId: Long): List { + val query = """ + SELECT + f.id AS failId, + COALESCE(f.type, '') AS failType, + COALESCE(f.category, '') AS category, + COALESCE(f.qty, 0) AS qty, + COALESCE(u.name, '') AS handlerName, + f.recordDate AS recordDate, + COALESCE(po.code, '') AS pickOrderCode, + po.id AS pickOrderId, + COALESCE(wh.code, '') AS warehouseCode + FROM fail_inventory_lot_line f + LEFT JOIN user u ON f.handerId = u.id AND u.deleted = 0 + LEFT JOIN stock_out_line sol ON f.stock_out_line_id = sol.id AND sol.deleted = 0 + LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 + LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE f.deleted = 0 AND f.Lot_id = :inventoryLotId + ORDER BY recordDate DESC, f.id DESC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).map { row -> + ItemLotTraceFailEvent( + failId = sql.longVal(row["failId"]) ?: 0L, + failType = sql.str(row["failType"]), + category = sql.str(row["category"]), + qty = sql.decimal(row["qty"]), + handlerName = sql.str(row["handlerName"]), + recordDate = sql.formatDate(row["recordDate"]), + pickOrderCode = sql.str(row["pickOrderCode"]), + pickOrderId = sql.longVal(row["pickOrderId"]), + warehouseCode = sql.str(row["warehouseCode"]), + ) + } + } + + open fun loadLotRelations( + inventoryLotId: Long, + itemId: Long, + lotNo: String, + ): List { + val query = """ + SELECT + 'REPACK_SIBLING' AS relationType, + il2.id AS inventoryLotId, + COALESCE(il2.lotNo, '') AS lotNo, + COALESCE(it.code, '') AS itemCode, + COALESCE(it.name, '') AS itemName, + COALESCE(sil2.acceptedQty, 0) AS qty, + COALESCE(sil_src.productLotNo, '') AS productLotNo, + COALESCE(sil2.created, sil2.modified, sil2.receiptDate) AS timestamp + FROM inventory_lot il + INNER JOIN stock_in_line sil_src ON il.stockInLineId = sil_src.id AND sil_src.deleted = 0 + INNER JOIN stock_in_line sil2 ON sil2.deleted = 0 + AND sil2.itemId = il.itemId + AND sil2.productLotNo IS NOT NULL + AND TRIM(sil2.productLotNo) <> '' + AND sil2.productLotNo = sil_src.productLotNo + INNER JOIN inventory_lot il2 ON sil2.inventoryLotId = il2.id AND il2.deleted = 0 + INNER JOIN items it ON il2.itemId = it.id AND it.deleted = 0 + WHERE il.id = :inventoryLotId + AND il.deleted = 0 + AND il2.id <> :inventoryLotId + AND COALESCE(il2.lotNo, '') <> :lotNo + AND sil2.purchaseOrderId IS NULL + AND ( + sil_src.purchaseOrderId IS NULL + OR sil2.stockTransferId IS NOT NULL + OR EXISTS ( + SELECT 1 + FROM stock_transfer_record tr + INNER JOIN stock_out_line sol ON tr.stockOutLineId = sol.id AND sol.deleted = 0 + INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + WHERE tr.stockInLineId = sil2.id + AND ill.inventoryLotId = il.id + ) + OR ( + sil2.jobOrderId IS NOT NULL + AND sil2.jobOrderId = sil_src.jobOrderId + ) + ) + ORDER BY timestamp, il2.lotNo + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("inventoryLotId" to inventoryLotId, "lotNo" to lotNo), + ).map { row -> + ItemLotTraceLotRelation( + relationType = sql.str(row["relationType"]), + inventoryLotId = sql.longVal(row["inventoryLotId"]), + lotNo = sql.str(row["lotNo"]), + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + qty = sql.decimal(row["qty"]), + productLotNo = sql.str(row["productLotNo"]), + timestamp = sql.formatDateTime(row["timestamp"]), + ) + }.distinctBy { "${it.itemCode}:${it.lotNo}" } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt new file mode 100644 index 0000000..a3a203a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt @@ -0,0 +1,855 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceJoPrelude +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMaterialInput +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +internal data class TraceGraphNode( + val id: String, + val kind: String, + val timestamp: String?, + val sortKey: Long, + val refCode: String? = null, + val refId: Long? = null, + val traceLotNo: String? = null, + val traceItemCode: String? = null, + val consoCode: String? = null, + val jobOrderCode: String? = null, + val bomProcessId: Long? = null, + val bomProcessSeqNo: Long? = null, + val feedsProductionScopeLotNo: String? = null, + var doGroupId: String? = null, + val refType: String? = null, + val warehouseCode: String? = null, + val transferFromWarehouse: String? = null, + val transferToWarehouse: String? = null, + val replenishmentStockOutLineId: Long? = null, +) + +internal object TraceGraphNodeBuilder { + private val MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS = setOf("TRANSFER", "ADJ", "OPEN", "TKE") + private val DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + private val DATE_ONLY = DateTimeFormatter.ISO_LOCAL_DATE + + fun buildNodes(data: ItemLotTraceResponse): List { + val hasPrelude = data.joPrelude != null + val hasScrap = data.productionSteps.any { (it.scrapQty + it.defectQty) > BigDecimal.ZERO } + val hasMaterialProduction = + data.joPrelude?.materialInputs?.let { materialInputsHaveProduction(it) } == true + val hasProduction = + data.productionSteps.isNotEmpty() || + data.byproductLots.isNotEmpty() || + hasScrap || + hasMaterialProduction + + val preludeNodes = data.joPrelude?.let { buildJoPreludeGraphNodes(it) } ?: emptyList() + val productionNodes = + if (hasProduction) { + buildProductionGraphNodes( + data.productionSteps, + data.byproductLots, + data.lot.uom, + ) + } else { + emptyList() + } + val extendedNodes = buildExtendedTraceGraphNodes(data) + val fgNodes = buildTraceGraphNodes(data) + val locationBlockNodes = buildLocationBlockGraphNodes(data.locationBlocks) + + return (preludeNodes + productionNodes + extendedNodes + fgNodes + locationBlockNodes).sortedWith( + compareBy { it.sortKey }.thenBy { it.id }, + ) + } + + internal fun materialInputsHaveProduction(inputs: List): Boolean = + inputs.any { m -> + m.productionSteps.isNotEmpty() || + (m.nestedJoPrelude?.materialInputs?.let { materialInputsHaveProduction(it) } == true) + } + + private fun buildTraceGraphNodes(data: ItemLotTraceResponse): List { + val nodes = mutableListOf() + var seq = 0 + val stockUom = data.lot.uom + + data.purchaseEvents.forEachIndexed { i, p -> + nodes += + TraceGraphNode( + id = "purchase-$i-${p.purchaseOrderLineId}", + kind = "PURCHASE", + timestamp = p.orderDate, + sortKey = parseSortKey(p.orderDate, seq++), + refCode = p.purchaseOrderCode, + refId = p.purchaseOrderId, + ) + } + + data.movements.forEachIndexed { i, m -> + val refType = norm(m.refType) + if (MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS.contains(refType)) return@forEachIndexed + if (isStockTakeLedgerMovement(m.movementType)) return@forEachIndexed + if (refType == "DO" && data.doDeliveries.isNotEmpty()) return@forEachIndexed + if (refType == "RETURN" && data.returnEvents.isNotEmpty()) return@forEachIndexed + + val kind = + when { + m.direction == "IN" && refType == "PO" -> "RECEIPT" + m.direction == "IN" && refType == "JO" -> "JO_CREATED" + m.direction == "IN" -> "IN" + m.direction == "OUT" && refType == "DO" -> "DO_OUT" + m.direction == "OUT" && refType == "JO_PICK" -> "JO_OUT" + m.direction == "OUT" && refType == "PO_PICK" -> "PO_OUT" + else -> "OUT" + } + nodes += + TraceGraphNode( + id = "mov-$i-${m.refCode}-${m.timestamp}", + kind = kind, + timestamp = m.timestamp, + sortKey = parseSortKey(m.timestamp, seq++), + refCode = m.refCode, + refId = m.refId, + ) + } + + buildQcNodes(data.qcResults, seq).let { (qcNodes, nextSeq) -> + nodes += qcNodes + seq = nextSeq + } + + data.putawayEvents.forEachIndexed { i, p -> + if (norm(p.refType) == "TRANSFER") return@forEachIndexed + nodes += + TraceGraphNode( + id = "putaway-$i-${p.inventoryLotLineId}-${p.timestamp}", + kind = "PUTAWAY", + timestamp = p.timestamp, + sortKey = parseSortKey(p.timestamp, seq++), + refCode = p.refCode, + refId = p.refId, + refType = p.refType, + warehouseCode = p.warehouseCode, + ) + } + + data.stockTakeEvents.forEachIndexed { e, st -> + nodes += + TraceGraphNode( + id = "st-$e-${st.stockTakeCode}-${st.timestamp}", + kind = "STOCK_TAKE", + timestamp = st.timestamp, + sortKey = parseSortKey(st.timestamp, seq++), + refCode = st.stockTakeCode, + ) + } + + data.adjustments.forEachIndexed { i, a -> + if (norm(a.adjustmentType) == "TKE" && data.stockTakeEvents.isNotEmpty()) return@forEachIndexed + nodes += + TraceGraphNode( + id = "adj-$i-${a.refCode}-${a.timestamp}-${a.direction}", + kind = "ADJUSTMENT", + timestamp = a.timestamp, + sortKey = parseSortKey(a.timestamp, seq++), + refCode = a.refCode, + ) + } + + data.transfers.forEachIndexed { i, tr -> + nodes += + TraceGraphNode( + id = "tr-$i-${tr.transferCode}-${tr.timestamp}", + kind = "TRANSFER", + timestamp = tr.timestamp, + sortKey = parseSortKey(tr.timestamp, seq++), + refCode = tr.transferCode, + transferFromWarehouse = tr.fromWarehouse, + transferToWarehouse = tr.toWarehouse, + ) + } + + val totalAvailable = data.warehouseLines.fold(BigDecimal.ZERO) { acc, w -> acc + w.availableQty } + val expiry = data.lot.expiryDate + if (!expiry.isNullOrBlank()) { + runCatching { LocalDate.parse(expiry, DATE_ONLY) }.getOrNull()?.let { expDate -> + if (expDate.isBefore(LocalDate.now())) { + nodes += + TraceGraphNode( + id = "terminal-expired-${data.lot.lotNo}", + kind = "EXPIRED", + timestamp = expiry, + sortKey = parseExpirySortKey(expiry, seq + 1_000_000), + ) + } + } + } + if (totalAvailable <= BigDecimal.ZERO && data.warehouseLines.isNotEmpty()) { + val lastTs = + data.movements.firstOrNull()?.timestamp + ?: data.outboundUsage.firstOrNull()?.timestamp + ?: data.lot.stockInDate + nodes += + TraceGraphNode( + id = "terminal-depleted-${data.lot.lotNo}", + kind = "DEPLETED", + timestamp = lastTs, + sortKey = parseSortKey(lastTs, seq + 2_000_000), + ) + } + + return nodes + } + + private fun buildQcNodes( + qcResults: List, + seqStart: Int, + ): Pair, Int> { + val nodes = mutableListOf() + var seq = seqStart + groupQcResultsBySession(qcResults).forEachIndexed { i, group -> + val first = group.firstOrNull() ?: return@forEachIndexed + nodes += + TraceGraphNode( + id = "qc-$i-${first.stockInLineId}-${first.created}", + kind = "QC", + timestamp = first.created, + sortKey = parseSortKey(first.created, seq++), + ) + } + return nodes to seq + } + + private fun buildProductionGraphNodes( + productionSteps: List, + byproductLots: List, + lotUom: String, + ): List { + val nodes = mutableListOf() + var seq = 0 + + productionSteps.forEach { step -> + val ts = step.endTime ?: step.startTime + nodes += + TraceGraphNode( + id = "prod-step-${step.processLineId}", + kind = "PRODUCTION_STEP", + timestamp = ts, + sortKey = parseSortKey(ts, seq++), + refCode = null, + bomProcessId = step.bomProcessId, + bomProcessSeqNo = step.seqNo, + ) + if (step.scrapQty > BigDecimal.ZERO) { + nodes += + TraceGraphNode( + id = "scrap-${step.processLineId}", + kind = "SCRAP", + timestamp = ts, + sortKey = parseSortKey(ts, seq++) + 1, + bomProcessId = step.bomProcessId, + bomProcessSeqNo = step.seqNo, + ) + } + if (step.defectQty > BigDecimal.ZERO) { + nodes += + TraceGraphNode( + id = "defect-${step.processLineId}", + kind = "DEFECT", + timestamp = ts, + sortKey = parseSortKey(ts, seq++) + 1, + bomProcessId = step.bomProcessId, + bomProcessSeqNo = step.seqNo, + ) + } + } + + byproductLots.forEachIndexed { i, bp -> + nodes += + TraceGraphNode( + id = "byproduct-${bp.inventoryLotId ?: i}-${bp.lotNo}", + kind = "BYPRODUCT", + timestamp = bp.producedAt, + sortKey = parseSortKey(bp.producedAt, seq++), + refCode = bp.jobOrderCode, + refId = bp.jobOrderId, + traceLotNo = bp.lotNo, + traceItemCode = bp.itemCode, + ) + } + + return nodes + } + + private fun buildExtendedTraceGraphNodes(data: ItemLotTraceResponse): List { + val nodes = mutableListOf() + var seq = 0 + val stockUom = data.lot.uom + + data.openMovements.forEach { o -> + nodes += + TraceGraphNode( + id = "open-${o.stockInLineId}", + kind = "OPEN", + timestamp = o.timestamp, + sortKey = parseSortKey(o.timestamp, seq++), + refCode = o.refCode, + warehouseCode = o.warehouseCode, + ) + } + + data.failEvents.forEach { f -> + nodes += + TraceGraphNode( + id = "fail-${f.failId}", + kind = "FAIL", + timestamp = f.recordDate, + sortKey = parseSortKey(f.recordDate, seq++), + refCode = f.pickOrderCode, + refId = f.pickOrderId, + consoCode = f.pickOrderCode, + warehouseCode = f.warehouseCode, + ) + } + + data.doDeliveries.forEach { d -> + nodes += + TraceGraphNode( + id = "do-${d.stockOutLineId}", + kind = "DO_OUT", + timestamp = d.timestamp, + sortKey = parseSortKey(d.timestamp, seq++), + refCode = d.deliveryOrderCode.ifBlank { d.pickOrderCode }, + refId = d.deliveryOrderId ?: d.pickOrderId, + consoCode = d.consoCode.ifBlank { d.pickOrderCode }, + ) + } + + data.replenishmentEvents.forEach { r -> + nodes += + TraceGraphNode( + id = "replenish-${r.replenishmentId}", + kind = "REPLENISHMENT_CREATED", + timestamp = r.timestamp, + sortKey = parseSortKey(r.timestamp, seq++), + refCode = r.sourceDoCode.ifBlank { r.replenishmentCode }, + refId = r.sourceDoId, + replenishmentStockOutLineId = r.stockOutLineId, + ) + } + + data.returnEvents.forEach { r -> + nodes += + TraceGraphNode( + id = "return-${r.stockOutLineId}-${r.timestamp}", + kind = "RETURN", + timestamp = r.timestamp, + sortKey = parseSortKey(r.timestamp, seq++), + refCode = r.refCode, + warehouseCode = r.warehouseCode, + ) + } + + data.lotRelations.forEachIndexed { i, rel -> + nodes += + TraceGraphNode( + id = "repack-${rel.inventoryLotId ?: i}-${rel.lotNo}", + kind = "REPACK", + timestamp = rel.timestamp, + sortKey = parseSortKey(rel.timestamp, seq++), + traceLotNo = rel.lotNo, + traceItemCode = rel.itemCode, + ) + } + + return nodes + } + + private data class PreludeBuildContext( + val seenPurchaseLines: MutableSet = mutableSetOf(), + val seenInboundLots: MutableSet = mutableSetOf(), + val seenReceiptLots: MutableSet = mutableSetOf(), + val seenQcKeys: MutableSet = mutableSetOf(), + val seenPutawayKeys: MutableSet = mutableSetOf(), + var seq: Int = 0, + ) + + private data class LotPickRef( + val pickOrderCode: String, + val pickOrderId: Long?, + val consoCode: String, + ) + + private fun buildJoPreludeGraphNodes(joPrelude: ItemLotTraceJoPrelude): List { + val ctx = PreludeBuildContext() + val lotPicks = buildLotPickOrderMap(joPrelude.materialInputs) + val chainNodes = buildMaterialInputChainNodes(joPrelude.materialInputs, lotPicks, ctx) + val pickNodes = + joPrelude.materialInputs.mapIndexed { i, m -> + createMaterialPickNode(m, i, ctx) + } + return (chainNodes + pickNodes).sortedWith(compareBy { it.sortKey }.thenBy { it.id }) + } + + private fun buildLotPickOrderMap( + materialInputs: List, + ): Map> { + val map = mutableMapOf>() + materialInputs.forEach { m -> + val code = m.pickOrderCode.trim() + if (code.isEmpty()) return@forEach + val key = materialLotKey(m.materialItemCode, m.materialLotNo) + val list = map.getOrPut(key) { mutableListOf() } + if (list.none { it.pickOrderCode == code }) { + list += + LotPickRef( + pickOrderCode = code, + pickOrderId = m.pickOrderId, + consoCode = m.consoCode, + ) + } + val nestedInputs = m.nestedJoPrelude?.materialInputs ?: emptyList() + if (nestedInputs.isNotEmpty()) { + mergeLotPickMaps(map, buildLotPickOrderMap(nestedInputs)) + } + } + return map + } + + private fun mergeLotPickMaps( + target: MutableMap>, + source: Map>, + ) { + source.forEach { (key, picks) -> + val list = target.getOrPut(key) { mutableListOf() } + picks.forEach { p -> + if (list.none { it.pickOrderCode == p.pickOrderCode }) { + list += p + } + } + } + } + + private fun buildMaterialInputChainNodes( + materialInputs: List, + lotPicks: Map>, + ctx: PreludeBuildContext, + ): List { + val nodes = mutableListOf() + materialInputs.forEach { m -> + val joProduced = isJoProducedMaterial(m) + val nestedInputs = m.nestedJoPrelude?.materialInputs ?: emptyList() + + if (joProduced && m.nestedJoPrelude != null) { + buildNestedJoCreatedNode(m, ctx)?.let { nodes += it } + } + + if (joProduced && nestedInputs.isNotEmpty()) { + val nestedLotPicks = buildLotPickOrderMap(nestedInputs) + nodes += buildMaterialInputChainNodes(nestedInputs, nestedLotPicks, ctx) + nestedInputs.forEachIndexed { ni, nm -> + nodes += createMaterialPickNode(nm, ni, ctx, m.materialLotNo) + } + } + + if (joProduced && m.productionSteps.isNotEmpty()) { + nodes += buildMaterialProductionNodes(m, ctx) + } + + nodes += buildMaterialStockInPreludeNodes(m, lotPicks, ctx) + } + return nodes + } + + private fun buildNestedJoCreatedNode( + m: ItemLotTraceMaterialInput, + ctx: PreludeBuildContext, + ): TraceGraphNode? { + val nested = m.nestedJoPrelude ?: return null + val jo = nested.jobOrder + val joCode = jo.jobOrderCode.trim() + if (joCode.isEmpty()) return null + + val nestedInputs = nested.materialInputs + val nestedPickTs = nestedInputs.mapNotNull { it.pickedAt?.trim() }.firstOrNull() + val nestedProdTs = m.productionSteps.mapNotNull { it.startTime?.trim() }.firstOrNull() + val ts = + jo.createdAt?.trim()?.takeIf { it.isNotEmpty() } + ?: jo.planStart?.trim()?.takeIf { it.isNotEmpty() } + ?: nestedPickTs + ?: nestedProdTs + ?: m.stockInOrigin?.receiptDate?.trim()?.takeIf { it.isNotEmpty() } + + return TraceGraphNode( + id = "mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}", + kind = "JO_CREATED", + timestamp = ts, + sortKey = parseSortKey(ts, ctx.seq++), + refType = "JO", + refCode = jo.jobOrderCode, + refId = jo.jobOrderId, + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } + + private fun buildMaterialProductionNodes( + m: ItemLotTraceMaterialInput, + ctx: PreludeBuildContext, + ): List { + if (m.productionSteps.isEmpty()) return emptyList() + val matUom = m.materialUom.trim() + return buildProductionGraphNodes(m.productionSteps, emptyList(), matUom).map { node -> + node.copy( + id = "mat-prod-${m.materialLotNo}-${node.id}", + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } + } + + private fun buildMaterialStockInPreludeNodes( + m: ItemLotTraceMaterialInput, + lotPicks: Map>, + ctx: PreludeBuildContext, + ): List { + val nodes = mutableListOf() + val lotId = m.materialInventoryLotId + val origin = m.stockInOrigin + val matUom = m.materialUom.trim() + + m.purchaseEvents.forEach { p -> + if (ctx.seenPurchaseLines.contains(p.purchaseOrderLineId)) return@forEach + ctx.seenPurchaseLines += p.purchaseOrderLineId + nodes += + TraceGraphNode( + id = "mat-purchase-${p.purchaseOrderLineId}-${m.materialLotNo}", + kind = "PURCHASE", + timestamp = p.orderDate, + sortKey = parseSortKey(p.orderDate, ctx.seq++), + refCode = p.purchaseOrderCode, + refId = p.purchaseOrderId, + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } + + if (origin != null && lotId != null) { + val originType = origin.type.trim().uppercase() + if (originType == "PO" && !ctx.seenReceiptLots.contains(lotId)) { + ctx.seenReceiptLots += lotId + nodes += + TraceGraphNode( + id = "mat-receipt-$lotId-${origin.stockInLineId}", + kind = "RECEIPT", + timestamp = origin.receiptDate, + sortKey = parseSortKey(origin.receiptDate, ctx.seq++), + refCode = origin.refCode, + refId = origin.refId, + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } else if (originType != "PO" && !ctx.seenInboundLots.contains(lotId)) { + val skipJoOriginBecauseNested = + originType == "JO" && + !m.nestedJoPrelude?.jobOrder?.jobOrderCode?.trim().isNullOrEmpty() + if (!skipJoOriginBecauseNested) { + ctx.seenInboundLots += lotId + val isJoOrigin = originType == "JO" + nodes += + TraceGraphNode( + id = "mat-in-$lotId-${origin.stockInLineId}", + kind = if (isJoOrigin) "JO_CREATED" else "MATERIAL_IN", + timestamp = origin.receiptDate, + sortKey = parseSortKey(origin.receiptDate, ctx.seq++), + refCode = origin.refCode, + refId = origin.refId, + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } + } + } + + groupQcResultsBySession(m.qcResults).forEach { group -> + val first = group.firstOrNull() ?: return@forEach + val key = "${first.stockInLineId}-${first.created}" + if (ctx.seenQcKeys.contains(key)) return@forEach + ctx.seenQcKeys += key + nodes += + TraceGraphNode( + id = "mat-qc-$key", + kind = "MATERIAL_QC", + timestamp = first.created, + sortKey = parseSortKey(first.created, ctx.seq++), + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } + + m.putawayEvents.forEach { p -> + val key = "${p.inventoryLotLineId}-${p.timestamp}" + if (ctx.seenPutawayKeys.contains(key)) return@forEach + if (norm(p.refType) == "TRANSFER") return@forEach + ctx.seenPutawayKeys += key + nodes += + TraceGraphNode( + id = "mat-putaway-${p.inventoryLotLineId}-${p.timestamp}", + kind = "PUTAWAY", + timestamp = p.timestamp, + sortKey = parseSortKey(p.timestamp, ctx.seq++), + refCode = p.refCode, + refId = p.refId, + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + ) + } + + return nodes + } + + private fun createMaterialPickNode( + m: ItemLotTraceMaterialInput, + index: Int, + ctx: PreludeBuildContext, + feedsProductionScopeLotNo: String? = null, + ): TraceGraphNode = + TraceGraphNode( + id = "mat-pick-$index-${m.pickOrderId}-${m.materialLotNo}-${m.pickedAt}", + kind = "MATERIAL_PICK", + timestamp = m.pickedAt, + sortKey = parseSortKey(m.pickedAt, ctx.seq++), + refCode = m.pickOrderCode, + refId = m.pickOrderId, + traceLotNo = m.materialLotNo, + traceItemCode = m.materialItemCode, + consoCode = m.consoCode, + jobOrderCode = m.jobOrderCode.trim().ifEmpty { null }, + bomProcessId = m.bomProcessId, + bomProcessSeqNo = m.bomProcessSeqNo, + feedsProductionScopeLotNo = feedsProductionScopeLotNo, + ) + + private fun isJoProducedMaterial(m: ItemLotTraceMaterialInput): Boolean = + m.productionSteps.isNotEmpty() || + m.nestedJoPrelude != null || + m.stockInOrigin?.type?.trim()?.uppercase() == "JO" + + private fun groupQcResultsBySession(results: List): List> { + val map = linkedMapOf>() + results.forEach { q -> + val key = "${q.stockInLineId}:${q.created ?: ""}" + map.getOrPut(key) { mutableListOf() } += q + } + return map.values.toList() + } + + /** + * Builds trace graph nodes for all alternate location blocks, tagged with warehouseCode + * so the frontend can visually distinguish multi-location flows. + */ + private fun buildLocationBlockGraphNodes( + locationBlocks: List, + ): List { + if (locationBlocks.isEmpty()) return emptyList() + val nodes = mutableListOf() + var seq = 0 + + locationBlocks.forEach { block -> + val whCode = block.warehouseLines.firstOrNull()?.warehouseCode ?: "" + val locPrefix = "loc-${block.inventoryLotId}" + + // Purchase events + block.purchaseEvents.forEachIndexed { i, p -> + nodes += TraceGraphNode( + id = "$locPrefix-purchase-$i-${p.purchaseOrderLineId}", + kind = "PURCHASE", timestamp = p.orderDate, + sortKey = parseSortKey(p.orderDate, seq++), + refCode = p.purchaseOrderCode, refId = p.purchaseOrderId, + warehouseCode = whCode, + ) + } + + // Origins (receipts) + block.origins.forEachIndexed { i, o -> + val kind = when (norm(o.type)) { + "PO" -> "RECEIPT" + "JO" -> "JO_CREATED" + "OPEN" -> "OPEN" + else -> "IN" + } + nodes += TraceGraphNode( + id = "$locPrefix-origin-$i-${o.stockInLineId}", + kind = kind, timestamp = o.receiptDate, + sortKey = parseSortKey(o.receiptDate, seq++), + refCode = o.refCode, refId = o.refId, + warehouseCode = whCode, + ) + } + + // QC results + val qcBySession = groupQcResultsBySession(block.qcResults) + qcBySession.forEachIndexed { i, group -> + val first = group.firstOrNull() ?: return@forEachIndexed + nodes += TraceGraphNode( + id = "$locPrefix-qc-$i-${first.stockInLineId}", + kind = "QC", timestamp = first.created, + sortKey = parseSortKey(first.created, seq++), + warehouseCode = whCode, + ) + } + + // Putaway events + block.putawayEvents.forEachIndexed { i, p -> + if (norm(p.refType) == "TRANSFER") return@forEachIndexed + nodes += TraceGraphNode( + id = "$locPrefix-putaway-$i-${p.inventoryLotLineId}", + kind = "PUTAWAY", timestamp = p.timestamp, + sortKey = parseSortKey(p.timestamp, seq++), + refCode = p.refCode, refId = p.refId, + refType = p.refType, warehouseCode = whCode, + ) + } + + // Movements (non-transfer, non-ADJ, non-TKE) + block.movements.forEachIndexed { i, m -> + val refType = norm(m.refType) + if (refType in MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS) return@forEachIndexed + if (isStockTakeLedgerMovement(m.movementType)) return@forEachIndexed + val kind = when { + m.direction == "IN" && refType == "PO" -> "RECEIPT" + m.direction == "IN" && refType == "JO" -> "JO_CREATED" + m.direction == "IN" -> "IN" + m.direction == "OUT" && refType == "DO" -> "DO_OUT" + m.direction == "OUT" -> "OUT" + else -> "OUT" + } + nodes += TraceGraphNode( + id = "$locPrefix-mov-$i-${m.refCode}", + kind = kind, timestamp = m.timestamp, + sortKey = parseSortKey(m.timestamp, seq++), + refCode = m.refCode, refId = m.refId, + warehouseCode = whCode, + ) + } + + // Stock take events + block.stockTakeEvents.forEachIndexed { i, st -> + nodes += TraceGraphNode( + id = "$locPrefix-st-$i-${st.stockTakeCode}", + kind = "STOCK_TAKE", timestamp = st.timestamp, + sortKey = parseSortKey(st.timestamp, seq++), + refCode = st.stockTakeCode, + warehouseCode = whCode, + ) + } + + // Adjustments + block.adjustments.forEachIndexed { i, a -> + nodes += TraceGraphNode( + id = "$locPrefix-adj-$i-${a.refCode}-${a.timestamp}", + kind = "ADJUSTMENT", timestamp = a.timestamp, + sortKey = parseSortKey(a.timestamp, seq++), + refCode = a.refCode, + warehouseCode = whCode, + ) + } + + // Primary buildNodes already emits every TR-* for the traced lot; skip here to avoid duplicates. + + // DO deliveries + block.doDeliveries.forEachIndexed { i, dd -> + nodes += TraceGraphNode( + id = "$locPrefix-dodel-$i-${dd.stockOutLineId}", + kind = "DO_OUT", timestamp = dd.timestamp, + sortKey = parseSortKey(dd.timestamp, seq++), + refCode = dd.pickOrderCode, + warehouseCode = whCode, + ) + } + + block.replenishmentEvents.forEach { r -> + nodes += + TraceGraphNode( + id = "$locPrefix-replenish-${r.replenishmentId}", + kind = "REPLENISHMENT_CREATED", + timestamp = r.timestamp, + sortKey = parseSortKey(r.timestamp, seq++), + refCode = r.sourceDoCode.ifBlank { r.replenishmentCode }, + refId = r.sourceDoId, + replenishmentStockOutLineId = r.stockOutLineId, + warehouseCode = whCode, + ) + } + + // Fail events + block.failEvents.forEachIndexed { i, fe -> + nodes += TraceGraphNode( + id = "$locPrefix-fail-$i-${fe.failId}", + kind = "FAIL", timestamp = fe.recordDate, + sortKey = parseSortKey(fe.recordDate, seq++), + refCode = fe.pickOrderCode, + warehouseCode = whCode, + ) + } + + // Return events + block.returnEvents.forEachIndexed { i, re -> + nodes += TraceGraphNode( + id = "$locPrefix-return-$i-${re.stockOutLineId}", + kind = "RETURN", timestamp = re.timestamp, + sortKey = parseSortKey(re.timestamp, seq++), + refCode = re.refCode, + warehouseCode = whCode, + ) + } + + // Open movements + block.openMovements.forEachIndexed { i, om -> + nodes += TraceGraphNode( + id = "$locPrefix-open-$i-${om.stockInLineId}", + kind = "OPEN", timestamp = om.timestamp, + sortKey = parseSortKey(om.timestamp, seq++), + refCode = om.refCode, + warehouseCode = whCode, + ) + } + } + + return nodes + } + + private fun materialLotKey(itemCode: String, lotNo: String): String = "$itemCode::$lotNo" + + private fun norm(v: String?): String = v?.trim()?.uppercase() ?: "" + + private fun isStockTakeLedgerMovement(movementType: String): Boolean { + val mt = movementType.trim().uppercase() + return mt == "TKE" || mt == "STOCKTAKE" + } + + private fun parseSortKey(ts: String?, seq: Int): Long { + if (ts.isNullOrBlank()) return seq.toLong() + return runCatching { LocalDateTime.parse(ts, DATE_TIME).toEpochSecond(java.time.ZoneOffset.UTC) * 1000 } + .getOrElse { + runCatching { LocalDate.parse(ts, DATE_ONLY).atStartOfDay().toEpochSecond(java.time.ZoneOffset.UTC) * 1000 } + .getOrElse { seq.toLong() } + } + } + + /** Expiry is date-only; sort at end of that calendar day (23:59:59). */ + private fun parseExpirySortKey(expiry: String?, seq: Int): Long { + if (expiry.isNullOrBlank()) return seq.toLong() + return runCatching { + val dateStr = if (expiry.length >= 10) expiry.substring(0, 10) else expiry.trim() + LocalDate.parse(dateStr, DATE_ONLY) + .atTime(23, 59, 59) + .toEpochSecond(java.time.ZoneOffset.UTC) * 1000 + }.getOrElse { seq.toLong() } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt new file mode 100644 index 0000000..06f85a5 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt @@ -0,0 +1,1110 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceGraph +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceGraphEdge +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceGraphNodeRef +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse +import org.springframework.stereotype.Component +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Component +class TraceGraphSemanticsBuilder { + fun build(response: ItemLotTraceResponse): ItemLotTraceGraph { + val rawNodes = TraceGraphNodeBuilder.buildNodes(response) + val layout = TraceGraphLayoutSupport.buildLayout(response, rawNodes) + val edges = TraceGraphEdgeSemantics.buildTraceFlowEdgePairs(layout.nodes, layout.phaseOrder) + val nodeRefs = + layout.nodes.map { n -> + ItemLotTraceGraphNodeRef( + key = n.id, + kind = n.kind, + scopeKey = productionScopeKey(n), + refCode = n.refCode, + traceLotNo = n.traceLotNo, + traceItemCode = n.traceItemCode, + warehouseCode = n.warehouseCode, + ) + } + return ItemLotTraceGraph(nodes = nodeRefs, edges = edges) + } + + private fun productionScopeKey(n: TraceGraphLayoutNode): String? { + val lot = n.traceLotNo?.trim() + return when { + n.kind == "PRODUCTION_STEP" || n.kind == "SCRAP" || n.kind == "DEFECT" || n.kind == "MATERIAL_PICK" -> lot?.ifEmpty { null } ?: "__fg__" + lot != null && lot.isNotEmpty() -> lot + else -> null + } + } +} + +internal data class TraceGraphLayout( + val nodes: List, + val phaseOrder: List, +) + +internal data class TraceGraphLayoutNode( + val id: String, + val kind: String, + val timestamp: String?, + val sortKey: Long, + val refCode: String? = null, + val refId: Long? = null, + val traceLotNo: String? = null, + val traceItemCode: String? = null, + val consoCode: String? = null, + val jobOrderCode: String? = null, + val bomProcessId: Long? = null, + val bomProcessSeqNo: Long? = null, + val feedsProductionScopeLotNo: String? = null, + var doGroupId: String? = null, + val refType: String? = null, + val warehouseCode: String? = null, + val transferFromWarehouse: String? = null, + val transferToWarehouse: String? = null, + val replenishmentStockOutLineId: Long? = null, + var phase: String = "", + var column: Int = 0, + var dayKey: String = "", + var laneIndex: Int = 0, + var sequenceIndex: Int = 0, + var branchIndex: Int = 0, + var branchSize: Int = 1, + var dayPhaseStaggerIndex: Int = 0, +) + +internal object TraceGraphLayoutSupport { + private const val DO_GROUP_MIN_SIZE = 3 + private const val PICK_GROUP_MIN_SIZE = 1 + private val DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + private val DATE_ONLY = DateTimeFormatter.ISO_LOCAL_DATE + + // 採購 → 收貨 → 品檢 → 上架(轉倉,盤點) → 出倉/提料 → 過期 + private val FG_PHASE_ORDER = + listOf("PURCHASE", "INBOUND", "QC", "PUTAWAY", "WAREHOUSE", "STOCK_TAKE", "OUTBOUND") + + fun buildLayout( + data: ItemLotTraceResponse, + rawNodes: List, + ): TraceGraphLayout { + val hasPrelude = data.joPrelude != null + val hasScrap = data.productionSteps.any { (it.scrapQty + it.defectQty).signum() > 0 } + val hasMaterialProduction = + data.joPrelude?.materialInputs?.let { TraceGraphNodeBuilder.materialInputsHaveProduction(it) } == true + val hasProduction = + data.productionSteps.isNotEmpty() || + data.byproductLots.isNotEmpty() || + hasScrap || + hasMaterialProduction + val phaseOrder = getPhaseOrder(hasPrelude, hasProduction) + val assigned = assignColumns(rawNodes, phaseOrder) + val grouped = applyFlowNodeGrouping(assigned) + return TraceGraphLayout(nodes = grouped, phaseOrder = phaseOrder) + } + + private fun getPhaseOrder(hasPrelude: Boolean, hasProduction: Boolean): List { + if (hasPrelude) return preludePhaseOrder(hasProduction) + val phases = FG_PHASE_ORDER.toMutableList() + if (hasProduction) phases.add(phases.indexOf("QC") + 1, "PRODUCTION") + return phases + } + + // JO: 採購 → 原料收貨 → 品檢 → 上架(原料) → 建立工單 → 原料提料 → 生產步驟 + // → 上架/轉倉/盤點(成品) → 出倉/提料 → 過期/已用完 + private fun preludePhaseOrder(hasProduction: Boolean): List { + val phases = + mutableListOf( + "PURCHASE", + "INBOUND", + "QC", + "PUTAWAY", // material putaway (上架原料) + "MATERIAL_PICK", // includes JO_CREATED (建立工單) + ) + if (hasProduction) phases += "PRODUCTION" + // FG section: FG putaway is mapped to WAREHOUSE by phaseFromKind + phases += listOf("WAREHOUSE", "STOCK_TAKE", "OUTBOUND") + return phases + } + + private fun assignColumns( + sorted: List, + phaseOrder: List, + ): List { + val dayOrder = mutableListOf() + val columnByDayKey = mutableMapOf() + + val withMeta = + sorted.mapIndexed { sequenceIndex, node -> + val dayKey = dayKeyFromTimestamp(node.timestamp) + val column = + columnByDayKey.getOrPut(dayKey) { + dayOrder += dayKey + dayOrder.size - 1 + } + val phase = phaseFromKind(node.kind, phaseOrder, node.traceLotNo) + TraceGraphLayoutNode( + id = node.id, + kind = node.kind, + timestamp = node.timestamp, + sortKey = node.sortKey, + refCode = node.refCode, + refId = node.refId, + traceLotNo = node.traceLotNo, + traceItemCode = node.traceItemCode, + consoCode = node.consoCode, + jobOrderCode = node.jobOrderCode, + bomProcessId = node.bomProcessId, + bomProcessSeqNo = node.bomProcessSeqNo, + feedsProductionScopeLotNo = node.feedsProductionScopeLotNo, + doGroupId = node.doGroupId, + refType = node.refType, + warehouseCode = node.warehouseCode, + transferFromWarehouse = node.transferFromWarehouse, + transferToWarehouse = node.transferToWarehouse, + replenishmentStockOutLineId = node.replenishmentStockOutLineId, + phase = phase, + column = column, + dayKey = dayKey, + laneIndex = phaseOrder.indexOf(phase), + sequenceIndex = sequenceIndex, + ) + } + + val staggerByDay = mutableMapOf>() + withMeta.forEach { node -> + val ordered = + staggerByDay.getOrPut(node.dayKey) { + phasesOnDay(withMeta, node.dayKey, phaseOrder) + } + node.dayPhaseStaggerIndex = ordered.indexOf(node.phase).coerceAtLeast(0) + } + + val groups = mutableMapOf>() + withMeta.forEach { node -> + val key = "${node.column}:${node.laneIndex}" + groups.getOrPut(key) { mutableListOf() } += node + } + groups.values.forEach { list -> + list.sortWith(sortNodesInPhase) + list.forEachIndexed { idx, node -> + node.branchIndex = idx + node.branchSize = list.size + } + } + + return withMeta + } + + private fun applyFlowNodeGrouping(nodes: List): List = + applyMaterialPickGrouping(applyDoOutboundGrouping(nodes)) + + private fun applyDoOutboundGrouping(nodes: List): List { + val doByColumn = mutableMapOf>() + nodes.forEach { node -> + if (node.kind != "DO_OUT" || !node.doGroupId.isNullOrBlank()) return@forEach + doByColumn.getOrPut(node.column) { mutableListOf() } += node + } + + val groupNodes = mutableListOf() + val childGroupIds = mutableMapOf() + + doByColumn.forEach { (column, columnNodes) -> + if (columnNodes.size < DO_GROUP_MIN_SIZE) return@forEach + val groupId = "do-group-col-$column" + val sorted = columnNodes.sortedWith(sortNodesInPhase) + val minSort = sorted.minOf { it.sortKey } + sorted.forEach { child -> childGroupIds[child.id] = groupId } + val first = sorted.first() + groupNodes += + TraceGraphLayoutNode( + id = groupId, + kind = "DO_GROUP", + timestamp = first.timestamp, + sortKey = minSort, + refCode = first.refCode, + phase = first.phase, + column = column, + dayKey = first.dayKey, + laneIndex = first.laneIndex, + sequenceIndex = first.sequenceIndex, + dayPhaseStaggerIndex = first.dayPhaseStaggerIndex, + ) + } + + if (groupNodes.isEmpty()) return nodes + return (nodes.map { node -> + val groupId = childGroupIds[node.id] + if (groupId != null) node.copy(doGroupId = groupId) else node + } + groupNodes).sortedWith(compareBy { it.sortKey }.thenBy { it.id }) + } + + private fun applyMaterialPickGrouping(nodes: List): List { + val picksByKey = mutableMapOf>() + nodes.forEach { node -> + if (node.kind != "MATERIAL_PICK" || !node.doGroupId.isNullOrBlank()) return@forEach + val pickCode = node.refCode?.trim().orEmpty() + if (pickCode.isEmpty()) return@forEach + val key = "${node.column}::$pickCode" + picksByKey.getOrPut(key) { mutableListOf() } += node + } + + val groupNodes = mutableListOf() + val childGroupIds = mutableMapOf() + + picksByKey.forEach { (key, columnNodes) -> + if (columnNodes.size < PICK_GROUP_MIN_SIZE) return@forEach + val groupId = "pick-group-${key.replace(Regex("[^a-zA-Z0-9_-]"), "_")}" + val sorted = columnNodes.sortedWith(sortNodesInPhase) + val minSort = sorted.minOf { it.sortKey } + sorted.forEach { child -> childGroupIds[child.id] = groupId } + val first = sorted.first() + groupNodes += + TraceGraphLayoutNode( + id = groupId, + kind = "PICK_GROUP", + timestamp = first.timestamp, + sortKey = minSort, + refCode = first.refCode, + refId = first.refId, + consoCode = first.consoCode, + jobOrderCode = first.jobOrderCode, + phase = first.phase, + column = first.column, + dayKey = first.dayKey, + laneIndex = first.laneIndex, + sequenceIndex = first.sequenceIndex, + dayPhaseStaggerIndex = first.dayPhaseStaggerIndex, + ) + } + + if (groupNodes.isEmpty()) return nodes + return (nodes.map { node -> + val groupId = childGroupIds[node.id] + if (groupId != null) node.copy(doGroupId = groupId) else node + } + groupNodes).sortedWith(compareBy { it.sortKey }.thenBy { it.id }) + } + + private fun phasesOnDay( + nodes: List, + dayKey: String, + phaseOrder: List, + ): List { + val present = nodes.filter { it.dayKey == dayKey }.map { it.phase }.toSet() + return sortPhasesByEarliestEvent(nodes, present.toList(), phaseOrder, dayKey) + } + + fun dayKeyFromTimestamp(timestamp: String?): String { + if (timestamp.isNullOrBlank()) return "—" + return runCatching { LocalDateTime.parse(timestamp, DATE_TIME).toLocalDate().format(DATE_ONLY) } + .getOrElse { + timestamp.take(10).ifBlank { "—" } + } + } + + private fun phaseFromKind( + kind: String, + phaseOrder: List, + traceLotNo: String?, + ): String { + val materialPrelude = !traceLotNo.isNullOrBlank() + val isPrelude = phaseOrder.contains("MATERIAL_PICK") + return when (kind) { + "MATERIAL_IN" -> "INBOUND" + "JO_CREATED" -> { + // Keep 建立工單 in 工單提料 lane with pick groups when prelude is shown. + if (phaseOrder.contains("MATERIAL_PICK")) "MATERIAL_PICK" + else "INBOUND" + } + "MATERIAL_QC", "QC", "FAIL" -> "QC" + "MATERIAL_PICK", "PICK_GROUP" -> "MATERIAL_PICK" + "PRODUCTION_STEP", "BYPRODUCT", "SCRAP", "DEFECT" -> "PRODUCTION" + "OPEN" -> if (isPrelude) "PUTAWAY" else "INBOUND" + "DO_OUT", "DO_GROUP", "RETURN", "REPLENISHMENT_CREATED" -> "OUTBOUND" + "REPACK" -> "WAREHOUSE" + "PURCHASE" -> "PURCHASE" + "RECEIPT", "IN" -> "INBOUND" + "PUTAWAY" -> { + // Material putaways (with traceLotNo) stay in the material section. + // FG putaways in prelude go to WAREHOUSE (after PRODUCTION). + if (!materialPrelude && isPrelude) "WAREHOUSE" + else "PUTAWAY" + } + "TRANSFER", "ADJUSTMENT" -> "WAREHOUSE" + "OUT" -> "OUTBOUND" + "STOCK_TAKE" -> "STOCK_TAKE" + "EXPIRED", "DEPLETED" -> "OUTBOUND" + else -> if (phaseOrder.contains("WAREHOUSE")) "WAREHOUSE" else phaseOrder.first() + } + } + + val sortNodesInPhase: Comparator = + Comparator { a, b -> + if (a.sortKey != b.sortKey) { + a.sortKey.compareTo(b.sortKey) + } else { + a.sequenceIndex.compareTo(b.sequenceIndex) + } + } + + fun sortPhasesByEarliestEvent( + nodes: List, + phases: List, + phaseOrder: List, + dayKey: String? = null, + ): List = + phases.sortedWith { a, b -> + val ta = earliestSortKey(nodes, a, dayKey) + val tb = earliestSortKey(nodes, b, dayKey) + if (ta != tb) ta.compareTo(tb) else phaseOrder.indexOf(a).compareTo(phaseOrder.indexOf(b)) + } + + fun sortFgPhasesForCrossDayFlow( + nodes: List, + phases: List, + phaseOrder: List, + ): List = + phases.sortedWith { a, b -> + val nodeA = earliestNodeInPhase(nodes, a, null) + val nodeB = earliestNodeInPhase(nodes, b, null) + val ta = nodeA?.sortKey ?: Long.MAX_VALUE + val tb = nodeB?.sortKey ?: Long.MAX_VALUE + val sameDay = + nodeA != null && + nodeB != null && + nodeA.dayKey != "—" && + nodeA.dayKey == nodeB.dayKey + when { + sameDay -> phaseOrder.indexOf(a).compareTo(phaseOrder.indexOf(b)) + ta != tb -> ta.compareTo(tb) + else -> phaseOrder.indexOf(a).compareTo(phaseOrder.indexOf(b)) + } + } + + private fun earliestNodeInPhase( + nodes: List, + phase: String, + dayKey: String?, + ): TraceGraphLayoutNode? { + var best: TraceGraphLayoutNode? = null + nodes.forEach { n -> + if (n.phase != phase) return@forEach + if (dayKey != null && n.dayKey != dayKey) return@forEach + if (best == null || n.sortKey < best!!.sortKey) best = n + } + return best + } + + private fun earliestSortKey( + nodes: List, + phase: String, + dayKey: String?, + ): Long = earliestNodeInPhase(nodes, phase, dayKey)?.sortKey ?: Long.MAX_VALUE +} + +internal object TraceGraphEdgeSemantics { + private val NO_FLOW_EDGE_KINDS = setOf("EXPIRED", "DEPLETED", "REPACK", "BYPRODUCT") + private val WAREHOUSE_FLOW_KINDS = setOf("TRANSFER", "ADJUSTMENT") + private val INVENTORY_STATE_KINDS = + setOf( + "PUTAWAY", + "TRANSFER", + "ADJUSTMENT", + "OUT", + "DO_OUT", + "DO_GROUP", + "STOCK_TAKE", + "RETURN", + "RECEIPT", + "IN", + "OPEN", + ) + private val PRELUDE_MATERIAL_PHASES = + listOf("PURCHASE", "INBOUND", "QC", "PUTAWAY", "MATERIAL_PICK", "PRODUCTION") + + private fun shouldSkipTraceFlowEdge( + from: TraceGraphLayoutNode, + to: TraceGraphLayoutNode, + ): Boolean { + if (from.kind == "JO_CREATED" && !isMaterialPreludeNode(from)) { + if (to.kind == "MATERIAL_PICK" || to.kind == "PICK_GROUP") return false + return true + } + if ( + to.kind == "PRODUCTION_STEP" && + from.kind != "MATERIAL_PICK" && + from.kind != "PICK_GROUP" && + from.kind != "PRODUCTION_STEP" + ) { + return true + } + if ( + from.kind in listOf("QC", "FAIL", "MATERIAL_QC") && + to.kind == "PRODUCTION_STEP" + ) { + return true + } + if (from.kind == "PRODUCTION_STEP" && to.kind == "JO_CREATED") { + return true + } + if (from.kind == "PUTAWAY" && to.kind in listOf("QC", "FAIL")) { + return true + } + if ( + from.kind == "PRODUCTION_STEP" && + to.kind in + listOf( + "MATERIAL_PICK", + "PICK_GROUP", + "DO_OUT", + "DO_GROUP", + "JO_OUT", + "PO_OUT", + "OUT", + ) + ) { + return true + } + if (to.kind == "REPLENISHMENT_CREATED") { + return from.kind !in + listOf("PUTAWAY", "TRANSFER", "ADJUSTMENT", "OPEN", "RECEIPT", "IN") + } + if (from.kind == "REPLENISHMENT_CREATED") { + if (to.kind == "DO_GROUP") return false + if (to.kind != "DO_OUT") return true + val targetSolId = from.replenishmentStockOutLineId + if (targetSolId != null && + (to.id == "do-$targetSolId" || to.id.endsWith("-$targetSolId")) + ) { + return false + } + val doCode = from.refCode?.trim().orEmpty() + if (doCode.isNotEmpty() && doOutMatchesDeliveryCode(to, doCode)) return false + return true + } + return false + } + + private fun doOutMatchesDeliveryCode(doOut: TraceGraphLayoutNode, doCode: String): Boolean { + val code = doCode.trim() + if (code.isEmpty()) return false + if (doOut.refCode?.trim() == code) return true + return false + } + + private fun normWh(w: String?): String = w?.trim()?.uppercase() ?: "" + + private fun normRefType(refType: String?): String = refType?.trim()?.uppercase() ?: "" + + private fun warehouseCodesMatch(a: String?, b: String?): Boolean { + val x = normWh(a) + val y = normWh(b) + if (x.isEmpty() || y.isEmpty()) return false + if (x == y) return true + return x.startsWith("$y-") || y.startsWith("$x-") + } + + private fun pickSourcePutawayForTransfer( + putaways: List, + transfer: TraceGraphLayoutNode, + ): TraceGraphLayoutNode? { + val fromWh = normWh(transfer.transferFromWarehouse) + val candidates = putaways.filter { it.sortKey <= transfer.sortKey } + val whMatched = + if (fromWh.isNotEmpty()) { + candidates.filter { warehouseCodesMatch(it.warehouseCode, fromWh) } + } else { + candidates + } + if (whMatched.isEmpty()) return null + val nonTransfer = whMatched.filter { normRefType(it.refType) != "TRANSFER" } + val pool = if (nonTransfer.isNotEmpty()) nonTransfer else whMatched + return pool.maxWithOrNull(TraceGraphLayoutSupport.sortNodesInPhase) + } + + private fun pickDestPutawayForTransfer( + putaways: List, + transfer: TraceGraphLayoutNode, + ): TraceGraphLayoutNode? { + val toWh = normWh(transfer.transferToWarehouse) + val candidates = putaways.filter { it.sortKey >= transfer.sortKey } + val whMatched = + if (toWh.isNotEmpty()) { + candidates.filter { warehouseCodesMatch(it.warehouseCode, toWh) } + } else { + candidates + } + if (whMatched.isEmpty()) return null + val transferPutaways = whMatched.filter { normRefType(it.refType) == "TRANSFER" } + val pool = if (transferPutaways.isNotEmpty()) transferPutaways else whMatched + return pool.minWithOrNull(TraceGraphLayoutSupport.sortNodesInPhase) + } + + private fun connectPutawayToWarehousePhase( + fromList: List, + toList: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val transfers = toList.filter { it.kind == "TRANSFER" } + if (transfers.isNotEmpty()) { + transfers.forEach { tr -> + pickSourcePutawayForTransfer(fromList, tr)?.let { src -> add(src, tr) } + } + return + } + add(fromList.last(), toList.first()) + } + + private fun buildTransferPutawayEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val putaways = nodes.filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) } + val transfers = nodes.filter { it.kind == "TRANSFER" } + transfers.forEach { tr -> + pickSourcePutawayForTransfer(putaways, tr)?.let { src -> add(src, tr) } + } + } + + fun buildTraceFlowEdgePairs( + nodes: List, + phaseOrder: List, + ): List { + val pairs = mutableListOf() + val seen = mutableSetOf() + + fun add(from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) { + if (NO_FLOW_EDGE_KINDS.contains(from.kind) || NO_FLOW_EDGE_KINDS.contains(to.kind)) return + if (shouldSkipTraceFlowEdge(from, to)) return + val key = "${from.id}->${to.id}" + if (!seen.add(key)) return + pairs += ItemLotTraceGraphEdge(fromKey = from.id, toKey = to.id) + } + + val byDay = mutableMapOf>() + nodes.forEach { n -> byDay.getOrPut(n.dayKey) { mutableListOf() } += n } + + val sortedDays = + byDay.keys.sortedWith { a, b -> + when { + a == "—" -> 1 + b == "—" -> -1 + else -> a.compareTo(b) + } + } + + fun nodesInPhase(dayNodes: List, phase: String) = + dayNodes + .filter { it.phase == phase && !NO_FLOW_EDGE_KINDS.contains(it.kind) && !isDoGroupChild(it) } + .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + + for (dayKey in sortedDays) { + val dayNodes = byDay[dayKey] ?: continue + val phasesPresent = + TraceGraphLayoutSupport.sortPhasesByEarliestEvent( + dayNodes, + phaseOrder.filter { p -> nodesInPhase(dayNodes, p).isNotEmpty() }, + phaseOrder, + dayKey, + ) + + for (i in 0 until phasesPresent.size - 1) { + val fromList = nodesInPhase(dayNodes, phasesPresent[i]) + val toList = nodesInPhase(dayNodes, phasesPresent[i + 1]) + if (fromList.isEmpty() || toList.isEmpty()) continue + if (isMaterialPreludeNode(fromList.last()) || isMaterialPreludeNode(toList.first())) continue + // STOCK_TAKE now precedes OUTBOUND in phase order; edge flows naturally + if (phasesPresent[i] == "PUTAWAY" && phasesPresent[i + 1] == "WAREHOUSE") { + connectPutawayToWarehousePhase(fromList, toList, ::add) + continue + } + add(fromList.last(), toList.first()) + } + } + + buildMaterialLotFlowEdgePairs(nodes, phaseOrder, ::add) + buildProductionStepChainEdges(nodes, ::add) + buildProductionToFgQcEdges(nodes, ::add) + buildJoCreatedToMaterialPickEdges(nodes, ::add) + buildReplenishmentToDoOutEdges(nodes, ::add) + buildMaterialPickToProductionStepEdges(nodes, ::add) + buildProductionStepLossEdges(nodes, ::add) + buildFgLotFlowEdgePairs(nodes, phaseOrder, ::add) + buildPutawayToOutboundEdges(nodes, ::add) + buildPutawayToStockTakeEdges(nodes, ::add) + buildTransferPutawayEdges(nodes, ::add) + connectWarehousePhaseChainEdges(nodes, ::add) + connectStockTakeInboundEdges(nodes, ::add) + + return pairs + } + + private fun buildMaterialLotFlowEdgePairs( + nodes: List, + phaseOrder: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + if (!phaseOrder.contains("MATERIAL_PICK")) return + val preludePhases = PRELUDE_MATERIAL_PHASES.filter { phaseOrder.contains(it) } + val byLot = mutableMapOf>() + + nodes.forEach { n -> + if (!isMaterialPreludeNode(n)) return@forEach + val key = materialLotKey(n) ?: return@forEach + byLot.getOrPut(key) { mutableListOf() } += n + } + + fun nodesInPhase(lotNodes: List, phase: String) = + lotNodes + .filter { it.phase == phase && !NO_FLOW_EDGE_KINDS.contains(it.kind) } + .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + + byLot.values.forEach { lotNodes -> + val phasesPresent = + TraceGraphLayoutSupport.sortPhasesByEarliestEvent( + lotNodes, + preludePhases.filter { p -> nodesInPhase(lotNodes, p).isNotEmpty() }, + phaseOrder, + ) + + for (i in 0 until phasesPresent.size - 1) { + val fromPhase = phasesPresent[i] + val toPhase = phasesPresent[i + 1] + val fromList = nodesInPhase(lotNodes, fromPhase) + val toList = nodesInPhase(lotNodes, toPhase) + if (fromList.isEmpty() || toList.isEmpty()) continue + + if (toPhase == "MATERIAL_PICK") { + val upstream = fromList.last() + val seenPickTargets = mutableSetOf() + toList.forEach { pick -> + val target = resolvePickFlowTarget(pick, nodes) + if (!seenPickTargets.add(target.id)) return@forEach + add(upstream, target) + } + } else { + add(fromList.last(), toList.first()) + } + } + } + } + + private fun buildFgLotFlowEdgePairs( + nodes: List, + phaseOrder: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val fgPhases = phaseOrder.filter { it != "MATERIAL_PICK" } + val fgNodes = + nodes.filter { + !isMaterialPreludeNode(it) && + !NO_FLOW_EDGE_KINDS.contains(it.kind) && + !isDoGroupChild(it) + } + if (fgNodes.size < 2) return + + fun nodesInPhase(phase: String) = + fgNodes.filter { it.phase == phase }.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + + val phasesPresent = + TraceGraphLayoutSupport.sortFgPhasesForCrossDayFlow( + fgNodes, + fgPhases.filter { p -> nodesInPhase(p).isNotEmpty() }, + phaseOrder, + ) + + fun pickUpstreamForTarget( + fromList: List, + target: TraceGraphLayoutNode, + ): TraceGraphLayoutNode { + val eligible = fromList.filter { it.column <= target.column || it.sortKey <= target.sortKey } + return eligible.lastOrNull() ?: fromList.last() + } + + for (i in 0 until phasesPresent.size - 1) { + val fromPhase = phasesPresent[i] + val toPhase = phasesPresent[i + 1] + val fromList = nodesInPhase(fromPhase) + val toList = nodesInPhase(toPhase) + if (fromList.isEmpty() || toList.isEmpty()) continue + + when { + fromPhase == "OUTBOUND" && toPhase == "STOCK_TAKE" -> Unit + fromPhase == "OUTBOUND" && + toPhase in listOf("WAREHOUSE", "PUTAWAY", "STOCK_TAKE") -> Unit + fromPhase == "PUTAWAY" && toPhase == "WAREHOUSE" -> + connectPutawayToWarehousePhase(fromList, toList, add) + toPhase == "OUTBOUND" && toList.size > 1 -> + toList.forEach { out -> add(pickUpstreamForTarget(fromList, out), out) } + toList.size > 1 -> { + val targets = + if (toPhase == "PUTAWAY") { + val nonTransfer = toList.filter { normRefType(it.refType) != "TRANSFER" } + if (nonTransfer.isNotEmpty()) nonTransfer else toList + } else { + toList + } + targets.forEach { to -> add(pickUpstreamForTarget(fromList, to), to) } + } + else -> add(fromList.last(), toList.first()) + } + } + } + + private fun buildMaterialPickToProductionStepEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val productionByScopeAndProcess = mutableMapOf>() + nodes.forEach { n -> + if (n.kind != "PRODUCTION_STEP" || n.bomProcessId == null) return@forEach + val key = "${productionScopeKey(n)}::${n.bomProcessId}" + productionByScopeAndProcess.getOrPut(key) { mutableListOf() } += n + } + + fun connectPickSource(source: TraceGraphLayoutNode, bomProcessId: Long) { + val scope = source.feedsProductionScopeLotNo?.trim()?.ifEmpty { null } ?: "__fg__" + val key = "$scope::$bomProcessId" + val targets = productionByScopeAndProcess[key] ?: return + if (targets.isEmpty()) return + val target = targets.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase).first() + add(source, target) + } + + val seenGroupProcess = mutableSetOf() + nodes.filter { it.kind == "PICK_GROUP" }.forEach { group -> + val children = + nodes.filter { + it.doGroupId == group.id && it.kind == "MATERIAL_PICK" && it.bomProcessId != null + } + children.forEach { child -> + val edgeKey = "${group.id}::${child.bomProcessId}" + if (!seenGroupProcess.add(edgeKey)) return@forEach + connectPickSource( + group.copy(feedsProductionScopeLotNo = child.feedsProductionScopeLotNo), + child.bomProcessId!!, + ) + } + } + + nodes + .filter { it.kind == "MATERIAL_PICK" && it.doGroupId.isNullOrBlank() && it.bomProcessId != null } + .forEach { pick -> connectPickSource(pick, pick.bomProcessId!!) } + } + + private fun buildProductionToFgQcEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val prodSteps = + nodes.filter { + it.kind == "PRODUCTION_STEP" && !isMaterialPreludeNode(it) && !isDoGroupChild(it) + } + val qcNodes = nodes.filter { it.kind == "QC" && !isMaterialPreludeNode(it) } + if (prodSteps.isEmpty() || qcNodes.isEmpty()) return + + val lastProd = prodSteps.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase).last() + val firstQc = qcNodes.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase).first() + if (firstQc.sortKey >= lastProd.sortKey) add(lastProd, firstQc) + } + + private fun buildProductionStepChainEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val byScope = mutableMapOf>() + nodes + .filter { it.kind == "PRODUCTION_STEP" && !isDoGroupChild(it) } + .forEach { n -> + byScope.getOrPut(productionScopeKey(n)) { mutableListOf() } += n + } + byScope.values.forEach { list -> + val sorted = + list.sortedWith { a, b -> + val sa = a.bomProcessSeqNo + val sb = b.bomProcessSeqNo + when { + sa != null && sb != null && sa != sb -> sa.compareTo(sb) + sa != null && sb == null -> -1 + sa == null && sb != null -> 1 + else -> TraceGraphLayoutSupport.sortNodesInPhase.compare(a, b) + } + } + for (i in 0 until sorted.size - 1) { + add(sorted[i], sorted[i + 1]) + } + } + } + + private fun buildProductionStepLossEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val stepNodes = nodes.filter { it.kind == "PRODUCTION_STEP" && !isDoGroupChild(it) } + val scrapNodes = nodes.filter { it.kind == "SCRAP" && !isDoGroupChild(it) } + val defectNodes = nodes.filter { it.kind == "DEFECT" && !isDoGroupChild(it) } + + stepNodes.forEach { step -> + val processLineId = step.id.removePrefix("prod-step-") + scrapNodes.filter { it.id == "scrap-$processLineId" }.forEach { scrap -> + add(step, scrap) + } + defectNodes.filter { it.id == "defect-$processLineId" }.forEach { defect -> + add(step, defect) + } + } + } + + private fun joCodeForPickTarget( + target: TraceGraphLayoutNode, + nodes: List, + ): String? { + // Prefer jobOrderCode — po.consoCode is often a TI ticket or blank, and refCode is PI-*. + fun fromNode(n: TraceGraphLayoutNode): String? { + val jo = n.jobOrderCode?.trim()?.ifEmpty { null } + if (jo != null) return jo + val conso = n.consoCode?.trim()?.ifEmpty { null } ?: return null + return if (conso.startsWith("JO-", ignoreCase = true) || conso.startsWith("JO_")) conso else null + } + + if (target.kind == "PICK_GROUP") { + fromNode(target)?.let { return it } + return nodes.firstOrNull { it.doGroupId == target.id && fromNode(it) != null }?.let { fromNode(it) } + } + if (target.kind == "MATERIAL_PICK") { + return fromNode(target) + } + return null + } + + private fun buildPutawayToOutboundEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val putaways = + nodes + .filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) } + .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + if (putaways.isEmpty()) return + + val outboundTargets = + nodes.filter { + !isDoGroupChild(it) && + it.kind in listOf("DO_OUT", "DO_GROUP", "JO_OUT", "PO_OUT", "OUT", "REPLENISHMENT_CREATED") + } + if (outboundTargets.isEmpty()) return + + outboundTargets.forEach { target -> + val from = pickUpstreamForPutaway(putaways, target) + if (!shouldSkipTraceFlowEdge(from, target)) add(from, target) + } + } + + private fun buildPutawayToStockTakeEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val putaways = + nodes + .filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) } + .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + if (putaways.isEmpty()) return + + nodes.filter { it.kind == "STOCK_TAKE" && !isDoGroupChild(it) }.forEach { stockTake -> + val from = pickUpstreamForPutaway(putaways, stockTake) + if (!shouldSkipTraceFlowEdge(from, stockTake)) add(from, stockTake) + } + } + + private fun pickUpstreamForPutaway( + fromList: List, + target: TraceGraphLayoutNode, + ): TraceGraphLayoutNode { + val eligible = fromList.filter { it.column <= target.column || it.sortKey <= target.sortKey } + return eligible.lastOrNull() ?: fromList.last() + } + + private fun resolveDoOutFlowTarget( + doOut: TraceGraphLayoutNode, + nodes: List, + ): TraceGraphLayoutNode { + val groupId = doOut.doGroupId?.trim()?.ifEmpty { null } ?: return doOut + return nodes.firstOrNull { it.id == groupId } ?: doOut + } + + private fun findDoOutForReplenishment( + replenishment: TraceGraphLayoutNode, + nodes: List, + ): TraceGraphLayoutNode? { + val doOuts = nodes.filter { it.kind == "DO_OUT" } + val stockOutLineId = replenishment.replenishmentStockOutLineId + if (stockOutLineId != null) { + val bySol = + doOuts.firstOrNull { + it.id == "do-$stockOutLineId" || it.id.endsWith("-$stockOutLineId") + } + if (bySol != null) return bySol + } + val doCode = replenishment.refCode?.trim().orEmpty() + if (doCode.isEmpty()) return null + return doOuts.firstOrNull { doOutMatchesDeliveryCode(it, doCode) } + } + + private fun buildReplenishmentToDoOutEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + nodes.filter { it.kind == "REPLENISHMENT_CREATED" }.forEach { replenishment -> + val doOut = findDoOutForReplenishment(replenishment, nodes) ?: return@forEach + add(replenishment, resolveDoOutFlowTarget(doOut, nodes)) + } + } + + private fun buildJoCreatedToMaterialPickEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + // Include material-scoped JO_CREATED so nested JO stock-in links to that JO’s 提料. + val joCreatedNodes = nodes.filter { it.kind == "JO_CREATED" } + if (joCreatedNodes.isEmpty()) return + + val pickTargets = + nodes.filter { + !isDoGroupChild(it) && + (it.kind == "PICK_GROUP" || (it.kind == "MATERIAL_PICK" && it.doGroupId.isNullOrBlank())) + } + if (pickTargets.isEmpty()) return + + pickTargets.forEach { target -> + val pickJoCode = joCodeForPickTarget(target, nodes) + val matches = + joCreatedNodes.filter { jo -> + val joCode = jo.refCode?.trim() + !joCode.isNullOrEmpty() && joCode == pickJoCode + } + if (matches.isEmpty()) return@forEach + + val feedLot = + when (target.kind) { + "PICK_GROUP" -> + nodes + .firstOrNull { + it.doGroupId == target.id && !it.feedsProductionScopeLotNo.isNullOrBlank() + } + ?.feedsProductionScopeLotNo + ?.trim() + .orEmpty() + else -> target.feedsProductionScopeLotNo?.trim().orEmpty() + } + + val pool = + if (feedLot.isNotEmpty()) { + val scoped = matches.filter { it.traceLotNo?.trim() == feedLot } + if (scoped.isNotEmpty()) scoped else matches + } else { + // FG 工單提料: prefer FG 建立工單 (no material lot), not nested JO output. + val fg = matches.filter { it.traceLotNo.isNullOrBlank() } + if (fg.isNotEmpty()) fg else matches + } + val joCreated = pool.sortedWith(TraceGraphLayoutSupport.sortNodesInPhase).first() + add(joCreated, target) + } + } + + private fun connectWarehousePhaseChainEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val chainNodes = + nodes + .filter { + !isMaterialPreludeNode(it) && + !NO_FLOW_EDGE_KINDS.contains(it.kind) && + !isDoGroupChild(it) + } + .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + + chainNodes.filter { it.kind in WAREHOUSE_FLOW_KINDS }.forEach { warehouseNode -> + if (warehouseNode.kind == "TRANSFER") { + val putaways = + chainNodes.filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) } + pickSourcePutawayForTransfer(putaways, warehouseNode)?.let { src -> + add(src, warehouseNode) + return@forEach + } + } + + val predecessors = chainNodes.filter { it.sortKey < warehouseNode.sortKey } + val warehousePredecessors = predecessors.filter { it.kind in WAREHOUSE_FLOW_KINDS } + if (warehousePredecessors.isNotEmpty()) { + add(warehousePredecessors.last(), warehouseNode) + return@forEach + } + + val inventoryPredecessors = predecessors.filter { it.kind in INVENTORY_STATE_KINDS } + val lastBefore = + inventoryPredecessors.lastOrNull() + ?: predecessors.lastOrNull() + if (lastBefore != null) add(lastBefore, warehouseNode) + } + } + + private fun connectStockTakeInboundEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val chainNodes = + nodes + .filter { + !isMaterialPreludeNode(it) && + !NO_FLOW_EDGE_KINDS.contains(it.kind) && + !isDoGroupChild(it) + } + .sortedWith(TraceGraphLayoutSupport.sortNodesInPhase) + + val inventoryPredecessorKinds = + setOf("PUTAWAY", "TRANSFER", "ADJUSTMENT", "REPACK", "RECEIPT", "IN", "OPEN") + + chainNodes.filter { it.kind == "STOCK_TAKE" }.forEach { stockTake -> + val predecessors = chainNodes.filter { it.kind != "STOCK_TAKE" && it.sortKey < stockTake.sortKey } + if (predecessors.isEmpty()) return@forEach + + val putawayPredecessors = + predecessors.filter { it.kind == "PUTAWAY" && !isMaterialPreludeNode(it) } + val inventoryPredecessors = predecessors.filter { it.kind in inventoryPredecessorKinds } + val from = + putawayPredecessors.lastOrNull() + ?: inventoryPredecessors.lastOrNull() + ?: predecessors.lastOrNull() + if (from != null) add(from, stockTake) + } + } + + private fun isMaterialPreludeNode(n: TraceGraphLayoutNode): Boolean = + n.kind in listOf("MATERIAL_IN", "MATERIAL_QC", "MATERIAL_PICK") || + (n.kind == "JO_CREATED" && !n.traceLotNo.isNullOrBlank()) || + (n.kind == "PUTAWAY" && !n.traceLotNo.isNullOrBlank()) || + (n.kind == "PRODUCTION_STEP" && !n.traceLotNo.isNullOrBlank()) || + ((n.kind == "SCRAP" || n.kind == "DEFECT") && !n.traceLotNo.isNullOrBlank()) || + ((n.kind == "PURCHASE" || n.kind == "RECEIPT") && !n.traceLotNo.isNullOrBlank()) + + private fun materialLotKey(n: TraceGraphLayoutNode): String? { + val lot = n.traceLotNo?.trim()?.ifEmpty { null } ?: return null + val item = n.traceItemCode?.trim().orEmpty() + return "$item::$lot" + } + + private fun resolvePickFlowTarget( + pick: TraceGraphLayoutNode, + nodes: List, + ): TraceGraphLayoutNode { + val groupId = pick.doGroupId?.trim()?.ifEmpty { null } + if (groupId == null) return pick + return nodes.firstOrNull { it.id == groupId } ?: pick + } + + private fun productionScopeKey(n: TraceGraphLayoutNode): String = n.traceLotNo?.trim()?.ifEmpty { null } ?: "__fg__" + + private fun isDoGroupChild(n: TraceGraphLayoutNode): Boolean = !n.doGroupId.isNullOrBlank() +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt new file mode 100644 index 0000000..cd0d530 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt @@ -0,0 +1,369 @@ +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 + +@Component +open class TraceInboundLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, +) { + open fun loadOrigins(inventoryLotId: Long): List = + loadOriginsForLots(listOf(inventoryLotId)) + + open fun loadOriginsForLots(inventoryLotIds: List): List { + if (inventoryLotIds.isEmpty()) return emptyList() + val query = """ + SELECT + sil.inventoryLotId AS inventoryLotId, + sil.id AS stockInLineId, + CASE + WHEN sil.purchaseOrderId IS NOT NULL THEN 'PO' + WHEN sil.jobOrderId IS NOT NULL THEN 'JO' + WHEN sil.stockTakeLineId IS NOT NULL THEN 'TKE' + WHEN sil.stockTransferId IS NOT NULL THEN 'TRANSFER' + WHEN UPPER(COALESCE(sil.type, '')) IN ('ADJ', 'OPEN') THEN UPPER(sil.type) + ELSE COALESCE(UPPER(sil.type), 'OTHER') + END AS type, + COALESCE(po.code, jo.code, st.code, si.code, '') AS refCode, + COALESCE(po.id, jo.id, st.id, si.id) AS refId, + COALESCE(sp_si.code, sp_po.code, '') AS supplierCode, + COALESCE(sp_si.name, sp_po.name, '') AS supplierName, + COALESCE(sil.dnNo, '') AS dnNo, + sil.receiptDate AS receiptDate, + COALESCE(sil.acceptedQty, 0) AS acceptedQty, + COALESCE(sil.status, '') AS status + FROM stock_in_line sil + LEFT JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 + LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 + LEFT JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + LEFT JOIN stock_take_line stl ON sil.stockTakeLineId = stl.id AND stl.deleted = 0 + LEFT JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0 + LEFT JOIN shop sp_si ON si.supplierId = sp_si.id AND sp_si.deleted = 0 + LEFT JOIN shop sp_po ON po.supplierId = sp_po.id AND sp_po.deleted = 0 + WHERE sil.inventoryLotId IN (:inventoryLotIds) AND sil.deleted = 0 + ORDER BY sil.receiptDate DESC, sil.id DESC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds)).map { row -> + ItemLotTraceOrigin( + stockInLineId = sql.longVal(row["stockInLineId"]) ?: 0L, + type = sql.str(row["type"]), + refCode = sql.str(row["refCode"]), + refId = sql.longVal(row["refId"]), + supplierCode = sql.str(row["supplierCode"]), + supplierName = sql.str(row["supplierName"]), + dnNo = sql.str(row["dnNo"]), + receiptDate = sql.formatDate(row["receiptDate"]), + acceptedQty = sql.decimal(row["acceptedQty"]), + status = sql.str(row["status"]), + ) + } + } + + open fun loadPurchaseEvents(stockInLineIds: List): List { + if (stockInLineIds.isEmpty()) return emptyList() + val query = """ + SELECT + pol.id AS purchaseOrderLineId, + po.id AS purchaseOrderId, + COALESCE(po.code, '') AS purchaseOrderCode, + COALESCE(i.code, pol.itemNo, '') AS itemCode, + COALESCE(i.name, '') AS itemName, + COALESCE(pol.qtyM18, pol.qty, 0) AS orderQty, + COALESCE(pa.putAwayQty, 0) AS putAwayQty, + COALESCE(ucm18.udfudesc, uc.udfudesc, '') AS purchaseUnit, + COALESCE(sp.code, '') AS supplierCode, + COALESCE(sp.name, '') AS supplierName, + po.orderDate AS orderDate, + COALESCE(pol.status, '') AS status + FROM stock_in_line sil + INNER JOIN purchase_order_line pol ON sil.purchaseOrderLineId = pol.id AND pol.deleted = 0 + INNER JOIN purchase_order po ON pol.purchaseOrderId = po.id AND po.deleted = 0 + LEFT JOIN shop sp ON po.supplierId = sp.id AND sp.deleted = 0 + LEFT JOIN items i ON pol.itemId = i.id AND i.deleted = 0 + LEFT JOIN item_uom iu ON iu.itemId = pol.itemId AND iu.purchaseUnit = true AND iu.deleted = 0 + LEFT JOIN uom_conversion uc ON uc.id = iu.uomId AND uc.deleted = 0 + LEFT JOIN uom_conversion ucm18 ON ucm18.id = pol.uomIdM18 AND ucm18.deleted = 0 + LEFT JOIN ( + SELECT + sil_pa.purchaseOrderLineId, + SUM( + COALESCE(ill.inQty, 0) + * COALESCE(iu_s.ratioN, 1) / NULLIF(COALESCE(iu_s.ratioD, 1), 0) + / NULLIF( + COALESCE(iu_p.ratioN, 1) / NULLIF(COALESCE(iu_p.ratioD, 1), 0), + 0 + ) + ) AS putAwayQty + FROM stock_in_line sil_pa + INNER JOIN inventory_lot_line ill ON sil_pa.inventoryLotLineId = ill.id AND ill.deleted = 0 + INNER JOIN items it_pa ON sil_pa.itemId = it_pa.id AND it_pa.deleted = 0 + LEFT JOIN item_uom iu_s ON iu_s.itemId = it_pa.id AND iu_s.stockUnit = true AND iu_s.deleted = 0 + LEFT JOIN item_uom iu_p ON iu_p.itemId = it_pa.id AND iu_p.purchaseUnit = true AND iu_p.deleted = 0 + WHERE sil_pa.deleted = 0 + AND sil_pa.purchaseOrderLineId IS NOT NULL + AND COALESCE(ill.inQty, 0) > 0 + GROUP BY sil_pa.purchaseOrderLineId + ) pa ON pa.purchaseOrderLineId = pol.id + WHERE sil.deleted = 0 + AND sil.id IN (:stockInLineIds) + AND sil.purchaseOrderLineId IS NOT NULL + GROUP BY + pol.id, po.id, po.code, i.code, pol.itemNo, i.name, + pol.qtyM18, pol.qty, pa.putAwayQty, ucm18.udfudesc, uc.udfudesc, + sp.code, sp.name, po.orderDate, pol.status + ORDER BY po.orderDate ASC, pol.id ASC + """.trimIndent() + val fromLines = jdbcDao.queryForList(query, mapOf("stockInLineIds" to stockInLineIds)).map { row -> + mapPurchaseEventRow(row) + } + val headerQuery = """ + SELECT + po.id AS purchaseOrderId, + COALESCE(po.code, '') AS purchaseOrderCode, + COALESCE(i.code, '') AS itemCode, + COALESCE(i.name, '') AS itemName, + COALESCE(SUM(sil.acceptedQty), 0) AS orderQty, + COALESCE(uc.udfudesc, '') AS purchaseUnit, + COALESCE(sp.code, '') AS supplierCode, + COALESCE(sp.name, '') AS supplierName, + po.orderDate AS orderDate, + COALESCE(MIN(sil.status), '') AS status + FROM stock_in_line sil + INNER JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 + LEFT JOIN shop sp ON po.supplierId = sp.id AND sp.deleted = 0 + LEFT JOIN items i ON sil.itemId = i.id AND i.deleted = 0 + LEFT JOIN item_uom iu ON iu.itemId = sil.itemId AND iu.purchaseUnit = true AND iu.deleted = 0 + LEFT JOIN uom_conversion uc ON uc.id = iu.uomId AND uc.deleted = 0 + WHERE sil.deleted = 0 + AND sil.id IN (:stockInLineIds) + AND sil.purchaseOrderId IS NOT NULL + AND sil.purchaseOrderLineId IS NULL + GROUP BY po.id, po.code, i.code, i.name, uc.udfudesc, sp.code, sp.name, po.orderDate + ORDER BY po.orderDate ASC, po.id ASC + """.trimIndent() + val fromHeader = jdbcDao.queryForList(headerQuery, mapOf("stockInLineIds" to stockInLineIds)).map { row -> + ItemLotTracePurchaseEvent( + purchaseOrderLineId = 0L, + purchaseOrderId = sql.longVal(row["purchaseOrderId"]), + purchaseOrderCode = sql.str(row["purchaseOrderCode"]), + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + orderQty = sql.decimal(row["orderQty"]), + putAwayQty = java.math.BigDecimal.ZERO, + purchaseUnit = sql.str(row["purchaseUnit"]), + supplierCode = sql.str(row["supplierCode"]), + supplierName = sql.str(row["supplierName"]), + orderDate = sql.formatDateTime(row["orderDate"]), + status = sql.str(row["status"]), + ) + } + return (fromLines + fromHeader).distinctBy { + if (it.purchaseOrderLineId > 0L) "line-${it.purchaseOrderLineId}" else "po-${it.purchaseOrderId}" + } + } + + private fun mapPurchaseEventRow(row: Map): ItemLotTracePurchaseEvent = + ItemLotTracePurchaseEvent( + purchaseOrderLineId = sql.longVal(row["purchaseOrderLineId"]) ?: 0L, + purchaseOrderId = sql.longVal(row["purchaseOrderId"]), + purchaseOrderCode = sql.str(row["purchaseOrderCode"]), + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + orderQty = sql.decimal(row["orderQty"]), + putAwayQty = sql.decimal(row["putAwayQty"]), + purchaseUnit = sql.str(row["purchaseUnit"]), + supplierCode = sql.str(row["supplierCode"]), + supplierName = sql.str(row["supplierName"]), + orderDate = sql.formatDateTime(row["orderDate"]), + status = sql.str(row["status"]), + ) + + open fun loadQcResults(stockInLineIds: List): List { + if (stockInLineIds.isEmpty()) return emptyList() + val query = """ + SELECT + qr.stockInLineId AS stockInLineId, + COALESCE(qr.qcPassed, 1) AS qcPassed, + COALESCE(qr.failQty, 0) AS failQty, + COALESCE(sil.acceptedQty, 0) AS acceptedQty, + COALESCE(qr.remarks, '') AS remarks, + COALESCE(qr.createdBy, '') AS handledBy, + qr.created AS created, + COALESCE(qi.code, '') AS qcItemCode, + COALESCE(qi.name, '') AS qcItemName, + COALESCE(qi.description, '') AS qcItemDescription, + CASE + WHEN sil.jobOrderId IS NOT NULL THEN 'EPQC' + WHEN sil.purchaseOrderId IS NOT NULL THEN 'IQC' + WHEN UPPER(COALESCE(qr.type, '')) IN ('IQC', 'IPQC', 'EPQC', 'FQC') THEN UPPER(qr.type) + ELSE 'IQC' + END AS qcType + FROM qc_result qr + LEFT JOIN qc_item qi ON qr.qcItemId = qi.id AND qi.deleted = 0 + LEFT JOIN stock_in_line sil ON qr.stockInLineId = sil.id AND sil.deleted = 0 + WHERE qr.deleted = 0 AND qr.stockInLineId IN (:stockInLineIds) + ORDER BY qr.created DESC, qi.code ASC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("stockInLineIds" to stockInLineIds)).map { row -> + ItemLotTraceQcResult( + stockInLineId = sql.longVal(row["stockInLineId"]) ?: 0L, + qcPassed = (row["qcPassed"] as? Number)?.toInt() != 0, + failQty = sql.decimal(row["failQty"]), + acceptedQty = sql.decimal(row["acceptedQty"]), + remarks = sql.str(row["remarks"]), + handledBy = sql.str(row["handledBy"]), + created = sql.formatDateTime(row["created"]), + qcItemCode = sql.str(row["qcItemCode"]), + qcItemName = sql.str(row["qcItemName"]), + qcItemDescription = sql.str(row["qcItemDescription"]), + qcType = sql.str(row["qcType"]), + ) + } + } + + open fun loadPutawayEvents(inventoryLotId: Long): List = + loadPutawayEventsByLotIds(listOf(inventoryLotId))[inventoryLotId].orEmpty() + + open fun loadPutawayEventsByLotIds(lotIds: List): Map> { + if (lotIds.isEmpty()) return emptyMap() + val fromIllSql = """ + SELECT + il.id AS inventoryLotId, + ill.id AS inventoryLotLineId, + COALESCE(sil_link.id, sil_primary.id) AS stockInLineId, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(ill.inQty, 0) AS qty, + COALESCE(ill.createdBy, sil_link.createdBy, sil_primary.createdBy, '') AS handledBy, + COALESCE(ill.created, sil_link.receiptDate, sil_primary.receiptDate, sil_link.created, sil_primary.created) AS timestamp, + CASE + WHEN COALESCE(sil_link.purchaseOrderId, sil_primary.purchaseOrderId) IS NOT NULL THEN 'PO' + WHEN COALESCE(sil_link.jobOrderId, sil_primary.jobOrderId) IS NOT NULL THEN 'JO' + WHEN COALESCE(sil_link.stockTakeLineId, sil_primary.stockTakeLineId) IS NOT NULL THEN 'TKE' + WHEN COALESCE(sil_link.stockTransferId, sil_primary.stockTransferId) IS NOT NULL THEN 'TRANSFER' + WHEN UPPER(COALESCE(sil_link.type, sil_primary.type, '')) IN ('ADJ', 'OPEN') THEN UPPER(COALESCE(sil_link.type, sil_primary.type)) + ELSE 'STOCK_IN' + END AS refType, + COALESCE(po.code, jo.code, st.code, si.code, '') AS refCode, + COALESCE(po.id, jo.id, st.id, si.id) AS refId, + COALESCE(sil_link.status, sil_primary.status, '') AS status + FROM inventory_lot_line ill + INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + LEFT JOIN stock_in_line sil_link ON sil_link.inventoryLotLineId = ill.id AND sil_link.deleted = 0 + LEFT JOIN stock_in_line sil_primary ON sil_primary.id = il.stockInLineId AND sil_primary.deleted = 0 + LEFT JOIN stock_in si ON COALESCE(sil_link.stockInId, sil_primary.stockInId) = si.id AND si.deleted = 0 + LEFT JOIN purchase_order po ON COALESCE(sil_link.purchaseOrderId, sil_primary.purchaseOrderId) = po.id AND po.deleted = 0 + LEFT JOIN job_order jo ON COALESCE(sil_link.jobOrderId, sil_primary.jobOrderId) = jo.id AND jo.deleted = 0 + LEFT JOIN stock_take_line stl ON COALESCE(sil_link.stockTakeLineId, sil_primary.stockTakeLineId) = stl.id AND stl.deleted = 0 + LEFT JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0 + WHERE il.id IN (:lotIds) AND ill.deleted = 0 + """.trimIndent() + val fromSilSql = """ + SELECT + il.id AS inventoryLotId, + COALESCE(ill.id, sil.inventoryLotLineId) AS inventoryLotLineId, + sil.id AS stockInLineId, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(ill.inQty, sil.acceptedQty, 0) AS qty, + COALESCE(ill.createdBy, sil.createdBy, '') AS handledBy, + COALESCE(ill.created, sil.receiptDate, sil.created) AS timestamp, + CASE + WHEN sil.purchaseOrderId IS NOT NULL THEN 'PO' + WHEN sil.jobOrderId IS NOT NULL THEN 'JO' + WHEN sil.stockTakeLineId IS NOT NULL THEN 'TKE' + WHEN sil.stockTransferId IS NOT NULL THEN 'TRANSFER' + WHEN UPPER(COALESCE(sil.type, '')) IN ('ADJ', 'OPEN') THEN UPPER(sil.type) + ELSE 'STOCK_IN' + END AS refType, + COALESCE(po.code, jo.code, st.code, si.code, '') AS refCode, + COALESCE(po.id, jo.id, st.id, si.id) AS refId, + COALESCE(sil.status, '') AS status + FROM stock_in_line sil + INNER JOIN inventory_lot il ON sil.inventoryLotId = il.id AND il.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + LEFT JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 + LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 + LEFT JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + LEFT JOIN stock_take_line stl ON sil.stockTakeLineId = stl.id AND stl.deleted = 0 + LEFT JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0 + WHERE il.id IN (:lotIds) AND sil.deleted = 0 AND sil.inventoryLotLineId IS NOT NULL + """.trimIndent() + + val grouped = mutableMapOf>() + val seen = mutableSetOf() + fun addRow(row: Map) { + val lotId = sql.longVal(row["inventoryLotId"]) ?: return + val illId = sql.longVal(row["inventoryLotLineId"]) ?: 0L + val timestamp = sql.formatDateTime(row["timestamp"]) + val dedupeKey = "$lotId-$illId-$timestamp" + if (!seen.add(dedupeKey)) return + grouped.getOrPut(lotId) { mutableListOf() }.add( + ItemLotTracePutawayEvent( + inventoryLotLineId = illId, + stockInLineId = sql.longVal(row["stockInLineId"]), + warehouseCode = sql.str(row["warehouseCode"]), + qty = sql.decimal(row["qty"]), + handledBy = sql.str(row["handledBy"]), + timestamp = timestamp, + refType = sql.str(row["refType"]), + refCode = sql.str(row["refCode"]), + refId = sql.longVal(row["refId"]), + status = sql.str(row["status"]), + ), + ) + } + jdbcDao.queryForList(fromIllSql, mapOf("lotIds" to lotIds)).forEach(::addRow) + jdbcDao.queryForList(fromSilSql, mapOf("lotIds" to lotIds)).forEach(::addRow) + grouped.values.forEach { list -> + list.sortWith(compareBy { it.timestamp ?: "" }.thenBy { it.inventoryLotLineId }) + } + return grouped + } + + open fun synthesizeMaterialPutawayEvent( + origin: ItemLotTraceOrigin, + pickedInventoryLotLineId: Long?, + pickedWarehouseCode: String, + ): ItemLotTracePutawayEvent = + ItemLotTracePutawayEvent( + inventoryLotLineId = pickedInventoryLotLineId ?: 0L, + stockInLineId = origin.stockInLineId, + warehouseCode = pickedWarehouseCode, + qty = origin.acceptedQty, + handledBy = "", + timestamp = origin.receiptDate, + refType = origin.type, + refCode = origin.refCode, + refId = origin.refId, + status = origin.status, + ) + + open fun resolveMaterialPutawayEvents( + lotId: Long?, + origin: ItemLotTraceOrigin?, + pickedInventoryLotLineId: Long?, + pickedWarehouseCode: String, + putawayByLot: Map>, + ): List { + if (lotId == null) return emptyList() + val fromLot = putawayByLot[lotId].orEmpty() + if (fromLot.isNotEmpty()) return fromLot + if (origin == null) return emptyList() + + val originType = origin.type.trim().uppercase() + val immediateInbound = originType in setOf("ADJ", "OPEN", "JO", "TRANSFER") + val poInbound = originType == "PO" + if (!immediateInbound && !poInbound) return emptyList() + + return listOf( + synthesizeMaterialPutawayEvent( + origin = origin, + pickedInventoryLotLineId = pickedInventoryLotLineId, + pickedWarehouseCode = pickedWarehouseCode, + ), + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt new file mode 100644 index 0000000..a431b1c --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt @@ -0,0 +1,357 @@ +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 MaterialInputRow( + val jobOrderCode: String, + val jobOrderId: Long?, + val materialItemCode: String, + val materialItemName: String, + val materialLotNo: String, + val materialStockInLineId: Long?, + val materialInventoryLotId: Long?, + val pickedInventoryLotLineId: Long?, + val pickedWarehouseCode: String, + val materialQty: BigDecimal, + val materialUom: String, + val bomQtyPerUnit: BigDecimal?, + val pickOrderCode: String, + val pickOrderId: Long?, + val consoCode: String, + val pickedAt: String?, + val processingStatus: String, + val matchStatus: String, +) + +@Component +open class TraceJoPreludeLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, + private val bomLoader: TraceBomLoader, + private val productionLoader: TraceProductionLoader, + private val inboundLoader: TraceInboundLoader, + private val lotResolver: TraceLotResolver, +) { + open fun loadJoPrelude(primaryStockInLineId: Long): ItemLotTraceJoPrelude? { + val joId = bomLoader.getJobOrderIdForFgStockIn(primaryStockInLineId) ?: return null + val jobOrder = loadJoContext(joId) ?: return null + val pickOrders = loadJoPickOrders(joId) + val materialInputs = loadJoMaterialInputs(primaryStockInLineId, mutableSetOf()) + return ItemLotTraceJoPrelude( + jobOrder = jobOrder, + pickOrders = pickOrders, + materialInputs = materialInputs, + ) + } + + open fun loadJoContext(jobOrderId: Long): ItemLotTraceJoContext? { + val query = """ + SELECT + jo.id AS jobOrderId, + COALESCE(jo.code, '') AS jobOrderCode, + jo.planStart AS planStart, + ( + SELECT MIN(jpo.created) + FROM pick_order po + INNER JOIN jo_pick_order jpo + ON jpo.pick_order_id = po.id AND jpo.deleted = 0 + WHERE po.joId = jo.id AND po.deleted = 0 + ) AS createdAt, + COALESCE(jo.reqQty, 0) AS reqQty, + COALESCE(jo.status, '') AS status + FROM job_order jo + WHERE jo.id = :jobOrderId AND jo.deleted = 0 + """.trimIndent() + val row = jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).firstOrNull() ?: return null + return ItemLotTraceJoContext( + jobOrderId = sql.longVal(row["jobOrderId"]) ?: jobOrderId, + jobOrderCode = sql.str(row["jobOrderCode"]), + planStart = sql.formatDateTime(row["planStart"]), + createdAt = sql.formatDateTime(row["createdAt"]), + reqQty = sql.decimal(row["reqQty"]), + status = sql.str(row["status"]), + ) + } + + open fun loadJoPickOrders(jobOrderId: Long): List { + val headerSql = """ + SELECT + po.id AS pickOrderId, + COALESCE(po.code, '') AS pickOrderCode, + COALESCE(po.consoCode, '') AS consoCode, + COALESCE(po.status, '') AS status, + po.targetDate AS targetDate, + po.completeDate AS completeDate, + po.releasedDate AS releasedDate + FROM pick_order po + WHERE po.joId = :jobOrderId AND po.deleted = 0 + AND po.type IN ('jo', 'JOB_ORDER') + ORDER BY po.code + """.trimIndent() + val headers = jdbcDao.queryForList(headerSql, mapOf("jobOrderId" to jobOrderId)) + if (headers.isEmpty()) return emptyList() + + val lineSql = """ + SELECT + pol.poId AS pickOrderId, + pol.id AS pickOrderLineId, + COALESCE(it.code, '') AS itemCode, + COALESCE(it.name, '') AS itemName, + COALESCE(pol.qty, 0) AS requiredQty, + COALESCE(pol.status, '') AS lineStatus, + COALESCE(SUM(sol.qty), 0) AS pickedQty + FROM pick_order_line pol + INNER JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 + LEFT JOIN items it ON pol.itemId = it.id AND it.deleted = 0 + LEFT JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0 + WHERE po.joId = :jobOrderId AND pol.deleted = 0 + AND po.type IN ('jo', 'JOB_ORDER') + GROUP BY pol.id, pol.poId, it.code, it.name, pol.qty, pol.status + ORDER BY it.code + """.trimIndent() + val linesByPo = jdbcDao.queryForList(lineSql, mapOf("jobOrderId" to jobOrderId)) + .groupBy { sql.longVal(it["pickOrderId"]) } + + return headers.map { row -> + val poId = sql.longVal(row["pickOrderId"]) ?: 0L + val lines = (linesByPo[poId] ?: emptyList()).map { lineRow -> + ItemLotTraceJoPickLine( + pickOrderLineId = sql.longVal(lineRow["pickOrderLineId"]) ?: 0L, + itemCode = sql.str(lineRow["itemCode"]), + itemName = sql.str(lineRow["itemName"]), + requiredQty = sql.decimal(lineRow["requiredQty"]), + pickedQty = sql.decimal(lineRow["pickedQty"]), + status = sql.str(lineRow["lineStatus"]), + ) + } + ItemLotTraceJoPickOrder( + pickOrderId = poId, + pickOrderCode = sql.str(row["pickOrderCode"]), + consoCode = sql.str(row["consoCode"]), + status = sql.str(row["status"]), + targetDate = sql.formatDate(row["targetDate"]), + completeDate = sql.formatDateTime(row["completeDate"]), + releasedDate = sql.formatDateTime(row["releasedDate"]), + lines = lines, + ) + } + } + + open fun loadJoMaterialInputs( + primaryStockInLineId: Long, + visitedStockInLineIds: MutableSet, + ): List { + if (primaryStockInLineId in visitedStockInLineIds) return emptyList() + visitedStockInLineIds.add(primaryStockInLineId) + + val query = """ + SELECT + COALESCE(jo.code, '') AS jobOrderCode, + jo.id AS jobOrderId, + COALESCE(mat_it.code, '') AS materialItemCode, + COALESCE(mat_it.name, '') AS materialItemName, + COALESCE(mat_il.lotNo, mat_sil.lotNo, '') AS materialLotNo, + mat_sil.id AS materialStockInLineId, + mat_il.id AS materialInventoryLotId, + mat_ill.id AS pickedInventoryLotLineId, + COALESCE(wh_pick.code, '') AS pickedWarehouseCode, + COALESCE(sol.qty, 0) AS materialQty, + COALESCE(mat_uc.udfudesc, '') AS materialUom, + bm.qty AS bomQtyPerUnit, + COALESCE(po.code, '') AS pickOrderCode, + po.id AS pickOrderId, + COALESCE(po.consoCode, '') AS consoCode, + COALESCE(sol.endTime, sol.pickTime, sol.created) AS pickedAt, + CASE + WHEN sol.status = 'completed' THEN 'completed' + WHEN sol.status = 'rejected' THEN 'rejected' + WHEN sol.status = 'created' THEN 'pending' + ELSE 'pending' + END AS processingStatus, + COALESCE(jpo.match_status, '') AS matchStatus + FROM stock_in_line fg_sil + INNER JOIN job_order jo ON fg_sil.jobOrderId = jo.id AND jo.deleted = 0 + INNER JOIN pick_order po ON po.joId = jo.id AND po.deleted = 0 + AND po.type IN ('jo', 'JOB_ORDER') + INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0 + INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0 + INNER JOIN inventory_lot_line mat_ill ON sol.inventoryLotLineId = mat_ill.id AND mat_ill.deleted = 0 + INNER JOIN inventory_lot mat_il ON mat_ill.inventoryLotId = mat_il.id AND mat_il.deleted = 0 + LEFT JOIN warehouse wh_pick ON mat_ill.warehouseId = wh_pick.id AND wh_pick.deleted = 0 + LEFT JOIN stock_in_line mat_sil ON mat_il.stockInLineId = mat_sil.id AND mat_sil.deleted = 0 + LEFT JOIN items mat_it ON mat_il.itemId = mat_it.id AND mat_it.deleted = 0 + LEFT JOIN item_uom mat_iu ON mat_it.id = mat_iu.itemId AND mat_iu.stockUnit = 1 AND mat_iu.deleted = 0 + LEFT JOIN uom_conversion mat_uc ON mat_iu.uomId = mat_uc.id AND mat_uc.deleted = 0 + LEFT JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 + LEFT JOIN bom_material bm ON bm.bomId = b.id AND bm.itemId = mat_it.id AND bm.deleted = 0 + LEFT JOIN jo_pick_order jpo ON jpo.pick_order_id = po.id + AND jpo.item_id = mat_it.id AND jpo.deleted = 0 + WHERE fg_sil.id = :primaryStockInLineId AND fg_sil.deleted = 0 + ORDER BY mat_it.code, mat_il.lotNo, sol.created + """.trimIndent() + val rows = jdbcDao.queryForList(query, mapOf("primaryStockInLineId" to primaryStockInLineId)).map { row -> + MaterialInputRow( + jobOrderCode = sql.str(row["jobOrderCode"]), + jobOrderId = sql.longVal(row["jobOrderId"]), + materialItemCode = sql.str(row["materialItemCode"]), + materialItemName = sql.str(row["materialItemName"]), + materialLotNo = sql.str(row["materialLotNo"]), + materialStockInLineId = sql.longVal(row["materialStockInLineId"]), + materialInventoryLotId = sql.longVal(row["materialInventoryLotId"]), + pickedInventoryLotLineId = sql.longVal(row["pickedInventoryLotLineId"]), + pickedWarehouseCode = sql.str(row["pickedWarehouseCode"]), + materialQty = sql.decimal(row["materialQty"]), + materialUom = sql.str(row["materialUom"]), + bomQtyPerUnit = row["bomQtyPerUnit"]?.let { sql.decimal(it) }, + pickOrderCode = sql.str(row["pickOrderCode"]), + pickOrderId = sql.longVal(row["pickOrderId"]), + consoCode = sql.str(row["consoCode"]), + pickedAt = sql.formatDateTime(row["pickedAt"]), + processingStatus = sql.str(row["processingStatus"]), + matchStatus = sql.str(row["matchStatus"]), + ) + } + if (rows.isEmpty()) return emptyList() + + val joIdForMapping = rows.firstNotNullOfOrNull { it.jobOrderId } + val stepAssignments = joIdForMapping?.let { bomLoader.loadBomMaterialStepAssignments(it) } ?: emptyMap() + + val lotIds = rows.mapNotNull { it.materialInventoryLotId }.distinct() + val silIdsByLot = lotResolver.loadStockInLineIdsForLots(lotIds) + val allSilIds = silIdsByLot.values.flatten().distinct() + val allOrigins = inboundLoader.loadOriginsForLots(lotIds) + val allQc = inboundLoader.loadQcResults(allSilIds) + val putawayByLot = inboundLoader.loadPutawayEventsByLotIds(lotIds) + + fun materialStockInOriginForLot(lotId: Long?): ItemLotTraceOrigin? { + if (lotId == null) return null + val silIds = silIdsByLot[lotId]?.toSet() ?: emptySet() + if (silIds.isEmpty()) return null + val origins = allOrigins.filter { it.stockInLineId in silIds } + if (origins.isEmpty()) return null + + fun earliestOfType(type: String): ItemLotTraceOrigin? = + origins + .filter { it.type.equals(type, ignoreCase = true) } + .minWithOrNull( + compareBy { it.receiptDate ?: "9999-12-31" } + .thenBy { it.stockInLineId }, + ) + + earliestOfType("PO")?.let { return it } + earliestOfType("JO")?.let { return it } + earliestOfType("TRANSFER")?.let { return it } + + return origins + .filter { o -> + val t = o.type.uppercase() + t !in setOf("ADJ", "OPEN", "TKE") + } + .minWithOrNull( + compareBy { it.receiptDate ?: "9999-12-31" } + .thenBy { it.stockInLineId }, + ) + ?: origins.minWithOrNull( + compareBy { it.receiptDate ?: "9999-12-31" } + .thenBy { it.stockInLineId }, + ) + } + + fun qcForLot(lotId: Long?): List { + if (lotId == null) return emptyList() + val silIds = silIdsByLot[lotId]?.toSet() ?: emptySet() + return allQc.filter { it.stockInLineId in silIds } + } + + fun purchaseForLot(lotId: Long?): List { + if (lotId == null) return emptyList() + val silIds = silIdsByLot[lotId].orEmpty() + return inboundLoader.loadPurchaseEvents(silIds) + } + + return rows.map { r -> + val stockInOrigin = materialStockInOriginForLot(r.materialInventoryLotId) + val resolvedSilId = bomLoader.resolveJoProducedStockInLineId( + r.materialStockInLineId, + r.materialInventoryLotId, + silIdsByLot, + ) + val joProduced = bomLoader.isJoProducedMaterial( + resolvedSilId, + r.materialInventoryLotId, + stockInOrigin, + ) + val producingJoId = resolvedSilId?.let { bomLoader.getJobOrderIdForFgStockIn(it) } + ?: r.materialInventoryLotId?.let { lotResolver.resolveJobOrderIdForLot(it, resolvedSilId) } + val nestedJoPrelude = if (joProduced && producingJoId != null) { + val jobOrder = loadJoContext(producingJoId) + if (jobOrder != null) { + val nestedSilId = resolvedSilId ?: producingJoId.let { joId -> + jdbcDao.queryForList( + """ + SELECT sil.id AS id FROM stock_in_line sil + WHERE sil.jobOrderId = :joId AND sil.deleted = 0 + ORDER BY sil.created DESC LIMIT 1 + """.trimIndent(), + mapOf("joId" to joId), + ).firstOrNull()?.let { sql.longVal(it["id"]) } + } + val materialInputs = nestedSilId + ?.let { loadJoMaterialInputs(it, visitedStockInLineIds) } + ?: emptyList() + ItemLotTraceJoPrelude( + jobOrder = jobOrder, + pickOrders = loadJoPickOrders(producingJoId), + materialInputs = materialInputs, + ) + } else { + null + } + } else { + null + } + val productionSteps = if (joProduced && producingJoId != null) { + productionLoader.loadProductionSteps(producingJoId) + } else { + emptyList() + } + val assignment = stepAssignments[bomLoader.itemCodeKey(r.materialItemCode)] + ItemLotTraceMaterialInput( + jobOrderCode = r.jobOrderCode, + jobOrderId = r.jobOrderId, + materialItemCode = r.materialItemCode, + materialItemName = r.materialItemName, + materialLotNo = r.materialLotNo, + materialStockInLineId = resolvedSilId ?: r.materialStockInLineId, + materialInventoryLotId = r.materialInventoryLotId, + materialQty = r.materialQty, + materialUom = r.materialUom, + bomQtyPerUnit = r.bomQtyPerUnit, + bomProcessId = assignment?.bomProcessId, + bomProcessSeqNo = assignment?.bomProcessSeqNo, + assignedStepName = assignment?.stepName ?: "", + pickOrderCode = r.pickOrderCode, + pickOrderId = r.pickOrderId, + consoCode = r.consoCode, + pickedAt = r.pickedAt, + processingStatus = r.processingStatus, + matchStatus = r.matchStatus, + stockInOrigin = stockInOrigin, + purchaseEvents = purchaseForLot(r.materialInventoryLotId), + qcResults = qcForLot(r.materialInventoryLotId), + putawayEvents = inboundLoader.resolveMaterialPutawayEvents( + lotId = r.materialInventoryLotId, + origin = stockInOrigin, + pickedInventoryLotLineId = r.pickedInventoryLotLineId, + pickedWarehouseCode = r.pickedWarehouseCode, + putawayByLot = putawayByLot, + ), + nestedJoPrelude = nestedJoPrelude, + productionSteps = productionSteps, + ) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt new file mode 100644 index 0000000..576b188 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt @@ -0,0 +1,319 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository +import com.ffii.fpsms.modules.stock.entity.InventoryLotRepository +import com.ffii.fpsms.modules.stock.entity.StockInLineRepository +import com.ffii.fpsms.modules.stock.web.model.* +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException + +@Component +open class TraceLotResolver( + private val jdbcDao: JdbcDao, + private val inventoryLotRepository: InventoryLotRepository, + private val inventoryLotLineRepository: InventoryLotLineRepository, + private val stockInLineRepository: StockInLineRepository, + private val sql: TraceSqlSupport, +) { + open fun resolveInventoryLotId( + stockInLineId: Long?, + inventoryLotLineId: Long?, + lotNo: String?, + itemId: Long?, + ): Long { + stockInLineId?.let { silId -> + val sil = stockInLineRepository.findById(silId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "stockInLineId $silId not found") + } + val lotId = sil.inventoryLot?.id + ?: inventoryLotRepository.findFirstByStockInLineIdAndDeletedFalseOrderByIdAsc(silId)?.id + return lotId ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "No inventory lot for stockInLineId $silId", + ) + } + + inventoryLotLineId?.let { illId -> + val ill = inventoryLotLineRepository.findById(illId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "inventoryLotLineId $illId not found") + } + return ill.inventoryLot?.id + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "No inventory lot for inventoryLotLineId $illId") + } + + if (!lotNo.isNullOrBlank() && itemId != null) { + val trimmed = lotNo.trim() + val inventoryLotId = resolvePrimaryInventoryLotIdByLotNo(trimmed, itemId) + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "Lot not found: lotNo=$trimmed itemId=$itemId", + ) + return inventoryLotId + } + + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Provide stockInLineId, inventoryLotLineId, or lotNo+itemId/itemCode", + ) + } + + /** lotNo+itemId is not unique — pick the lot with the most available qty (tie: lowest id). */ + open fun resolvePrimaryInventoryLotIdByLotNo(lotNo: String, itemId: Long): Long? { + val query = """ + SELECT il.id AS inventoryLotId + FROM inventory_lot il + LEFT JOIN inventory_lot_line ill ON ill.inventoryLotId = il.id AND ill.deleted = 0 + WHERE il.deleted = 0 + AND il.itemId = :itemId + AND il.lotNo = :lotNo + GROUP BY il.id + ORDER BY COALESCE(SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)), 0) DESC, il.id ASC + LIMIT 1 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("itemId" to itemId, "lotNo" to lotNo)) + .firstOrNull() + ?.let { sql.longVal(it["inventoryLotId"]) } + } + + open fun loadAlternateLocations( + itemId: Long, + lotNo: String, + currentInventoryLotId: Long, + ): List { + if (lotNo.isBlank()) return emptyList() + val query = """ + SELECT + il.id AS inventoryLotId, + il.stockInLineId AS stockInLineId, + ill.id AS inventoryLotLineId, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) AS availableQty + FROM inventory_lot il + INNER JOIN inventory_lot_line ill ON ill.inventoryLotId = il.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE il.deleted = 0 + AND il.itemId = :itemId + AND il.lotNo = :lotNo + AND il.id <> :currentInventoryLotId + ORDER BY wh.code, il.id, ill.id + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("itemId" to itemId, "lotNo" to lotNo, "currentInventoryLotId" to currentInventoryLotId), + ).map { row -> + ItemLotTraceAlternateLocation( + inventoryLotId = sql.longVal(row["inventoryLotId"]) ?: 0L, + stockInLineId = sql.longVal(row["stockInLineId"]), + inventoryLotLineId = sql.longVal(row["inventoryLotLineId"]) ?: 0L, + warehouseCode = sql.str(row["warehouseCode"]), + availableQty = sql.decimal(row["availableQty"]), + ) + } + } + + open fun loadLotInfo(inventoryLotId: Long): ItemLotTraceLotInfo { + val query = """ + SELECT + il.id AS inventoryLotId, + COALESCE(il.lotNo, '') AS lotNo, + it.id AS itemId, + COALESCE(it.code, '') AS itemCode, + COALESCE(it.name, '') AS itemName, + il.expiryDate AS expiryDate, + (SELECT sil.productionDate + FROM stock_in_line sil + WHERE sil.inventoryLotId = il.id + AND sil.jobOrderId IS NOT NULL + AND sil.deleted = 0 + ORDER BY sil.receiptDate DESC, sil.id DESC + LIMIT 1) AS productionDate, + il.stockInDate AS stockInDate, + COALESCE(uc.udfudesc, '') AS uom, + il.stockInLineId AS primaryStockInLineId + FROM inventory_lot il + INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0 + LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = 1 + LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + WHERE il.id = :inventoryLotId AND il.deleted = 0 + """.trimIndent() + val row = jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).firstOrNull() + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory lot $inventoryLotId not found") + + return ItemLotTraceLotInfo( + inventoryLotId = inventoryLotId, + lotNo = sql.str(row["lotNo"]), + itemId = sql.longVal(row["itemId"]) ?: inventoryLotId, + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + expiryDate = sql.formatDate(row["expiryDate"]), + productionDate = sql.formatDateTime(row["productionDate"]), + stockInDate = sql.formatDateTime(row["stockInDate"]), + uom = sql.str(row["uom"]), + primaryStockInLineId = sql.longVal(row["primaryStockInLineId"]), + ) + } + + open fun loadLotLineIds(inventoryLotId: Long): List { + val query = """ + SELECT id FROM inventory_lot_line + WHERE inventoryLotId = :inventoryLotId AND deleted = 0 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) + .mapNotNull { sql.longVal(it["id"]) } + } + + open fun loadStockInLineIds(inventoryLotId: Long): List { + val query = """ + SELECT sil.id AS id + FROM stock_in_line sil + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0 + WHERE sil.deleted = 0 + AND ( + sil.inventoryLotId = :inventoryLotId + OR ill.inventoryLotId = :inventoryLotId + ) + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) + .mapNotNull { sql.longVal(it["id"]) } + .distinct() + } + + open fun loadWarehouseLines(inventoryLotId: Long): List { + val query = """ + SELECT + ill.id AS inventoryLotLineId, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(ill.inQty, 0) AS inQty, + COALESCE(ill.outQty, 0) AS outQty, + COALESCE(ill.issueQty, 0) AS issueQty, + COALESCE(ill.status, '') AS status, + COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) AS availableQty + FROM inventory_lot_line ill + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE ill.inventoryLotId = :inventoryLotId AND ill.deleted = 0 + ORDER BY wh.code + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).map { row -> + ItemLotTraceWarehouseLine( + inventoryLotLineId = sql.longVal(row["inventoryLotLineId"]) ?: 0L, + warehouseCode = sql.str(row["warehouseCode"]), + inQty = sql.decimal(row["inQty"]), + outQty = sql.decimal(row["outQty"]), + issueQty = sql.decimal(row["issueQty"]), + status = sql.str(row["status"]), + availableQty = sql.decimal(row["availableQty"]), + ) + } + } + + /** Batch variant of [loadLotLineIds]. */ + open fun loadLotLineIdsForLots(inventoryLotIds: List): Map> { + if (inventoryLotIds.isEmpty()) return emptyMap() + val query = """ + SELECT inventoryLotId, id FROM inventory_lot_line + WHERE inventoryLotId IN (:inventoryLotIds) AND deleted = 0 + ORDER BY id + """.trimIndent() + val grouped = mutableMapOf>() + jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds)).forEach { row -> + val lotId = sql.longVal(row["inventoryLotId"]) ?: return@forEach + val illId = sql.longVal(row["id"]) ?: return@forEach + grouped.getOrPut(lotId) { mutableListOf() }.add(illId) + } + return grouped + } + + open fun loadStockInLineIdsForLots(inventoryLotIds: List): Map> { + if (inventoryLotIds.isEmpty()) return emptyMap() + val query = """ + SELECT inventoryLotId, id FROM stock_in_line + WHERE inventoryLotId IN (:inventoryLotIds) AND deleted = 0 + """.trimIndent() + val grouped = mutableMapOf>() + jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds)).forEach { row -> + val lotId = sql.longVal(row["inventoryLotId"]) ?: return@forEach + val silId = sql.longVal(row["id"]) ?: return@forEach + grouped.getOrPut(lotId) { mutableListOf() }.add(silId) + } + return grouped + } + + open fun loadLotInfoBatch(inventoryLotIds: List): Map { + if (inventoryLotIds.isEmpty()) return emptyMap() + val query = """ + SELECT + il.id AS inventoryLotId, + COALESCE(il.lotNo, '') AS lotNo, + it.id AS itemId, + COALESCE(it.code, '') AS itemCode, + COALESCE(it.name, '') AS itemName, + il.expiryDate AS expiryDate, + (SELECT sil.productionDate + FROM stock_in_line sil + WHERE sil.inventoryLotId = il.id + AND sil.jobOrderId IS NOT NULL + AND sil.deleted = 0 + ORDER BY sil.receiptDate DESC, sil.id DESC + LIMIT 1) AS productionDate, + il.stockInDate AS stockInDate, + COALESCE(uc.udfudesc, '') AS uom, + il.stockInLineId AS primaryStockInLineId + FROM inventory_lot il + INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0 + LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = 1 + LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + WHERE il.id IN (:inventoryLotIds) AND il.deleted = 0 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds)) + .associate { row -> + val lotId = sql.longVal(row["inventoryLotId"]) ?: return@associate 0L to ItemLotTraceLotInfo( + inventoryLotId = 0L, lotNo = "", itemId = 0L, itemCode = "", itemName = "", + expiryDate = null, productionDate = null, stockInDate = null, uom = "", + primaryStockInLineId = null, + ) + lotId to ItemLotTraceLotInfo( + inventoryLotId = lotId, + lotNo = sql.str(row["lotNo"]), + itemId = sql.longVal(row["itemId"]) ?: lotId, + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + expiryDate = sql.formatDate(row["expiryDate"]), + productionDate = sql.formatDateTime(row["productionDate"]), + stockInDate = sql.formatDateTime(row["stockInDate"]), + uom = sql.str(row["uom"]), + primaryStockInLineId = sql.longVal(row["primaryStockInLineId"]), + ) + } + } + + open fun resolveJobOrderIdForLot(inventoryLotId: Long, primaryStockInLineId: Long?): Long? { + primaryStockInLineId?.let { silId -> + jdbcDao.queryForList( + """ + SELECT jo.id AS jobOrderId FROM stock_in_line sil + INNER JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + WHERE sil.id = :primaryStockInLineId AND sil.deleted = 0 + """.trimIndent(), + mapOf("primaryStockInLineId" to silId), + ).firstOrNull()?.let { return sql.longVal(it["jobOrderId"]) } + } + val query = """ + SELECT jo.id AS jobOrderId + FROM stock_in_line sil + INNER JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0 + WHERE sil.deleted = 0 + AND sil.jobOrderId IS NOT NULL + AND ( + sil.inventoryLotId = :inventoryLotId + OR ill.inventoryLotId = :inventoryLotId + ) + ORDER BY sil.created DESC + LIMIT 1 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) + .firstOrNull()?.let { sql.longVal(it["jobOrderId"]) } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt new file mode 100644 index 0000000..8469394 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt @@ -0,0 +1,350 @@ +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 + +@Component +open class TraceMovementLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, +) { + open fun loadMovements(inventoryLotId: Long, lotLineIds: List): List { + if (lotLineIds.isEmpty()) return emptyList() + val query = """ + SELECT * FROM ( + SELECT + 'IN' AS direction, + COALESCE(sil.acceptedQty, ill.inQty, 0) AS qty, + COALESCE(UPPER(sil.type), 'IN') AS movementType, + CASE + WHEN sil.purchaseOrderId IS NOT NULL THEN 'PO' + WHEN sil.jobOrderId IS NOT NULL THEN 'JO' + WHEN sil.stockTakeLineId IS NOT NULL THEN 'TKE' + WHEN sil.stockTransferId IS NOT NULL THEN 'TRANSFER' + WHEN UPPER(COALESCE(sil.type, '')) IN ('ADJ', 'OPEN') THEN UPPER(sil.type) + ELSE 'STOCK_IN' + END AS refType, + COALESCE(po.code, jo.code, st.code, si.code, '') AS refCode, + COALESCE(po.id, jo.id, st.id, si.id) AS refId, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(sil.createdBy, '') AS handledBy, + COALESCE( + ( + SELECT MIN(jpo.created) + FROM pick_order po + INNER JOIN jo_pick_order jpo + ON jpo.pick_order_id = po.id AND jpo.deleted = 0 + WHERE po.joId = sil.jobOrderId AND po.deleted = 0 + ), + sil.created, + sil.modified, + sil.receiptDate + ) AS timestamp, + COALESCE(sil.remarks, '') AS remarks, + '' AS processingStatus, + '' AS matchStatus + FROM stock_in_line sil + LEFT JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 + LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 + LEFT JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + LEFT JOIN stock_take_line stl ON sil.stockTakeLineId = stl.id AND stl.deleted = 0 + LEFT JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE sil.deleted = 0 + AND ( + sil.inventoryLotId = :inventoryLotId + OR ill.inventoryLotId = :inventoryLotId + ) + + UNION ALL + + SELECT + 'OUT' AS direction, + COALESCE(sol.qty, 0) AS qty, + COALESCE(UPPER(so.type), UPPER(sol.type), 'OUT') AS movementType, + CASE + WHEN sol.pickOrderLineId IS NOT NULL AND UPPER(COALESCE(po.type, '')) = 'DO' THEN 'DO' + WHEN sol.pickOrderLineId IS NOT NULL AND UPPER(COALESCE(po.type, '')) = 'JO' THEN 'JO_PICK' + WHEN sol.pickOrderLineId IS NOT NULL AND po.joId IS NOT NULL THEN 'JO_PICK' + WHEN sol.pickOrderLineId IS NOT NULL AND po.doId IS NOT NULL THEN 'DO' + WHEN sol.pickOrderLineId IS NOT NULL AND dpo.id IS NOT NULL THEN 'DO' + WHEN sol.pickOrderLineId IS NOT NULL AND dpor.id IS NOT NULL THEN 'DO' + WHEN sol.pickOrderLineId IS NOT NULL THEN 'PO_PICK' + WHEN sol.stockTransferId IS NOT NULL THEN 'TRANSFER' + WHEN UPPER(COALESCE(so.type, '')) IN ('ADJ', 'TKE') THEN UPPER(so.type) + ELSE 'STOCK_OUT' + END AS refType, + COALESCE(po.code, do_tbl.code, so.consoPickOrderCode, so.deliveryOrderCode, '') AS refCode, + COALESCE(po.id, do_tbl.id, so.id) AS refId, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(picker_user.name, sol.createdBy, '') AS handledBy, + COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp, + COALESCE(so.remarks, '') AS remarks, + CASE + WHEN sol.status = 'completed' THEN 'completed' + WHEN sol.status = 'rejected' THEN 'rejected' + WHEN sol.status = 'created' THEN 'pending' + ELSE 'pending' + END AS processingStatus, + COALESCE(jpo.match_status, '') AS matchStatus + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 + LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 + LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 + LEFT JOIN do_pick_order dpo + ON po.id = dpo.pick_order_id AND dpo.deleted = 0 + LEFT JOIN do_pick_order_record dpor + ON po.id = dpor.pick_order_id AND dpor.deleted = 0 + LEFT JOIN delivery_order do_tbl + ON do_tbl.deleted = 0 + AND do_tbl.id = COALESCE(po.doId, dpo.do_order_id, dpor.do_order_id) + LEFT JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 + LEFT JOIN jo_pick_order jpo ON jpo.pick_order_id = po.id + AND jpo.item_id = il.itemId AND jpo.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 + WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds) + ) movements + ORDER BY timestamp DESC + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("inventoryLotId" to inventoryLotId, "lotLineIds" to lotLineIds), + ).map { row -> + fun col(vararg keys: String): Any? { + for (k in keys) { + if (row.containsKey(k)) return row[k] + } + val lower = row.keys.associateBy { it.lowercase() } + for (k in keys) { + lower[k.lowercase()]?.let { return row[it] } + } + return null + } + ItemLotTraceMovement( + direction = sql.str(col("direction")), + qty = sql.decimal(col("qty")), + movementType = sql.str(col("movementType")), + refType = sql.str(col("refType")), + refCode = sql.str(col("refCode")), + refId = sql.longVal(col("refId")), + warehouseCode = sql.str(col("warehouseCode")), + handledBy = sql.str(col("handledBy")), + timestamp = sql.formatDateTime(col("timestamp")), + remarks = sql.str(col("remarks")), + processingStatus = sql.str(col("processingStatus")), + matchStatus = sql.str(col("matchStatus")), + ) + } + } + + open fun loadStockTakeEvents(lotLineIds: List): List { + if (lotLineIds.isEmpty()) return emptyList() + val query = """ + SELECT + stl.id AS stockTakeLineId, + COALESCE(st.code, '') AS stockTakeCode, + COALESCE(il.lotNo, str.lotNo, '') AS lotNo, + COALESCE(it.code, str.itemCode, '') AS itemCode, + COALESCE(it.name, str.itemName, '') AS itemName, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(NULLIF(st.stockTakeSection, ''), NULLIF(str.stockTakeSection, ''), '') AS stockTakeSection, + COALESCE(st.stockTakeRoundId, str.stockTakeRoundId) AS stockTakeRoundId, + COALESCE(NULLIF(st.stockTakeRoundName, ''), '') AS stockTakeRoundName, + COALESCE(str.varianceQty, stl.finalQty - stl.initialQty, 0) AS varianceQty, + COALESCE(stl.initialQty, 0) AS beforeQty, + COALESCE(stl.finalQty, 0) AS afterQty, + COALESCE(NULLIF(str.approverName, ''), NULLIF(str.stockTakerName, ''), '') AS approver, + COALESCE(str.approverTime, stl.completeDate, stl.modified) AS timestamp, + COALESCE(str.bookQty, 0) AS bookQty, + str.pickerFirstStockTakeQty AS pickerFirstQty, + str.pickerFirstBadQty AS pickerFirstBadQty, + str.pickerSecondStockTakeQty AS pickerSecondQty, + str.pickerSecondBadQty AS pickerSecondBadQty, + str.approverStockTakeQty AS approverQty, + str.approverBadQty AS approverBadQty, + str.stockTakerName AS stockTakerName, + NULLIF(str.approverName, '') AS approverName, + str.stockTakeStartTime AS stockTakeStartTime, + str.stockTakeEndTime AS stockTakeEndTime, + str.approverTime AS approverTime, + str.status AS recordStatus, + str.lastSelect AS lastSelect, + str.varianceQty AS recordVarianceQty, + str.id AS stockTakeRecordId + FROM stock_take_line stl + INNER JOIN stock_take st ON stl.stockTakeId = st.id AND st.deleted = 0 + LEFT JOIN stocktakerecord str ON stl.stockTakeRecordId = str.id AND str.deleted = 0 + LEFT JOIN inventory_lot_line ill ON stl.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 + LEFT JOIN items it ON il.itemId = it.id AND it.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE stl.deleted = 0 AND stl.inventoryLotLineId IN (:lotLineIds) + ORDER BY timestamp DESC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("lotLineIds" to lotLineIds)).map { row -> + val stockTakeLineId = sql.longVal(row["stockTakeLineId"]) + val hasRecordDetail = sql.longVal(row["stockTakeRecordId"]) != null + ItemLotTraceStockTakeEvent( + stockTakeCode = sql.str(row["stockTakeCode"]), + lotNo = sql.str(row["lotNo"]), + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + warehouseCode = sql.str(row["warehouseCode"]), + stockTakeSection = sql.str(row["stockTakeSection"]), + stockTakeRoundId = sql.longVal(row["stockTakeRoundId"]), + stockTakeRoundName = sql.str(row["stockTakeRoundName"]), + varianceQty = sql.decimal(row["varianceQty"]), + beforeQty = sql.decimal(row["beforeQty"]), + afterQty = sql.decimal(row["afterQty"]), + approver = sql.str(row["approver"]), + timestamp = sql.formatDateTime(row["timestamp"]), + recordDetail = if (hasRecordDetail) { + ItemLotTraceStockTakeRecordDetail( + stockTakeLineId = stockTakeLineId ?: 0L, + bookQty = sql.decimal(row["bookQty"]), + pickerFirstQty = sql.decimalOrNull(row["pickerFirstQty"]), + pickerFirstBadQty = sql.decimalOrNull(row["pickerFirstBadQty"]), + pickerSecondQty = sql.decimalOrNull(row["pickerSecondQty"]), + pickerSecondBadQty = sql.decimalOrNull(row["pickerSecondBadQty"]), + approverQty = sql.decimalOrNull(row["approverQty"]), + approverBadQty = sql.decimalOrNull(row["approverBadQty"]), + stockTakerName = sql.strOrNull(row["stockTakerName"]), + approverName = sql.strOrNull(row["approverName"]), + stockTakeStartTime = sql.formatDateTime(row["stockTakeStartTime"]), + stockTakeEndTime = sql.formatDateTime(row["stockTakeEndTime"]), + approverTime = sql.formatDateTime(row["approverTime"]), + recordStatus = sql.strOrNull(row["recordStatus"]), + lastSelect = sql.intVal(row["lastSelect"]), + varianceQty = sql.decimalOrNull(row["recordVarianceQty"]), + ) + } else null, + ) + } + } + + open fun loadAdjustments(inventoryLotId: Long, lotLineIds: List): List { + if (lotLineIds.isEmpty()) return emptyList() + val query = """ + SELECT * FROM ( + SELECT + COALESCE(UPPER(sil.type), 'ADJ') AS adjustmentType, + 'IN' AS direction, + COALESCE(sil.acceptedQty, 0) AS qty, + COALESCE(sil.remarks, '') AS reason, + COALESCE(sil.createdBy, '') AS handledBy, + COALESCE(sil.created, sil.modified, sil.receiptDate) AS timestamp, + COALESCE(si.code, '') AS refCode, + COALESCE(wh.code, '') AS warehouseCode + FROM stock_in_line sil + INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE sil.deleted = 0 AND sil.inventoryLotId = :inventoryLotId + AND UPPER(COALESCE(sil.type, '')) = 'ADJ' + + UNION ALL + + SELECT + COALESCE(UPPER(so.type), 'ADJ') AS adjustmentType, + 'OUT' AS direction, + COALESCE(sol.qty, 0) AS qty, + COALESCE(so.remarks, '') AS reason, + COALESCE(sol.createdBy, '') AS handledBy, + COALESCE(sol.endTime, sol.created) AS timestamp, + COALESCE(so.consoPickOrderCode, so.deliveryOrderCode, '') AS refCode, + COALESCE(wh.code, '') AS warehouseCode + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds) + AND UPPER(COALESCE(so.type, sol.type, '')) IN ('ADJ', 'TKE') + ) adj + ORDER BY timestamp DESC + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("inventoryLotId" to inventoryLotId, "lotLineIds" to lotLineIds), + ).map { row -> + ItemLotTraceAdjustment( + adjustmentType = sql.str(row["adjustmentType"]), + direction = sql.str(row["direction"]), + qty = sql.decimal(row["qty"]), + reason = sql.str(row["reason"]), + handledBy = sql.str(row["handledBy"]), + timestamp = sql.formatDateTime(row["timestamp"]), + refCode = sql.str(row["refCode"]), + warehouseCode = sql.str(row["warehouseCode"]), + ) + } + } + + open fun loadTransfers(inventoryLotId: Long): List { + val query = """ + SELECT + COALESCE(str.startLocation, wh_from.code, '') AS fromWarehouse, + COALESCE(str.targetLocation, wh_to.code, '') AS toWarehouse, + COALESCE(str.transferredQty, 0) AS qty, + CONCAT('TR-', str.id) AS transferCode, + COALESCE(str.created, str.modified, sil.created, sil.modified, sil.receiptDate) AS timestamp + FROM stock_transfer_record str + LEFT JOIN stock_in_line sil ON str.stockInLineId = sil.id AND sil.deleted = 0 + LEFT JOIN stock_out_line sol ON str.stockOutLineId = sol.id AND sol.deleted = 0 + LEFT JOIN inventory_lot_line ill_from ON sol.inventoryLotLineId = ill_from.id AND ill_from.deleted = 0 + LEFT JOIN warehouse wh_from ON ill_from.warehouseId = wh_from.id AND wh_from.deleted = 0 + LEFT JOIN inventory_lot_line ill_to ON sil.inventoryLotLineId = ill_to.id AND ill_to.deleted = 0 + LEFT JOIN warehouse wh_to ON ill_to.warehouseId = wh_to.id AND wh_to.deleted = 0 + WHERE str.deleted = 0 + AND ( + sil.inventoryLotId = :inventoryLotId + OR ill_from.inventoryLotId = :inventoryLotId + ) + ORDER BY timestamp DESC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).map { row -> + ItemLotTraceTransfer( + fromWarehouse = sql.str(row["fromWarehouse"]), + toWarehouse = sql.str(row["toWarehouse"]), + qty = sql.decimal(row["qty"]), + transferCode = sql.str(row["transferCode"]), + timestamp = sql.formatDateTime(row["timestamp"]), + ) + } + } + + open fun loadOpenMovements(inventoryLotId: Long): List { + val query = """ + SELECT + sil.id AS stockInLineId, + COALESCE(sil.acceptedQty, 0) AS qty, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(si.code, '') AS refCode, + COALESCE(sil.createdBy, '') AS handledBy, + COALESCE(sil.created, sil.modified, sil.receiptDate) AS timestamp, + COALESCE(sil.remarks, '') AS remarks + FROM stock_in_line sil + INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + WHERE sil.deleted = 0 + AND sil.inventoryLotId = :inventoryLotId + AND UPPER(COALESCE(sil.type, '')) = 'OPEN' + ORDER BY timestamp DESC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).map { row -> + ItemLotTraceOpenMovement( + stockInLineId = sql.longVal(row["stockInLineId"]) ?: 0L, + qty = sql.decimal(row["qty"]), + warehouseCode = sql.str(row["warehouseCode"]), + refCode = sql.str(row["refCode"]), + handledBy = sql.str(row["handledBy"]), + timestamp = sql.formatDateTime(row["timestamp"]), + remarks = sql.str(row["remarks"]), + ) + } + } +} 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 new file mode 100644 index 0000000..f09b203 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt @@ -0,0 +1,410 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService +import com.ffii.fpsms.modules.deliveryOrder.service.DoReplenishmentService +import com.ffii.fpsms.modules.stock.web.model.* +import org.springframework.stereotype.Component + +@Component +open class TraceOutboundLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, + private val deliveryOrderService: DeliveryOrderService, + private val doReplenishmentService: DoReplenishmentService, +) { + open fun loadOutboundUsage(inventoryLotId: Long): List { + val materialSql = """ + SELECT + sol.id AS stockOutLineId, + COALESCE(sol.qty, 0) AS qty, + COALESCE(po.code, '') AS pickOrderCode, + po.id AS pickOrderId, + COALESCE(po.consoCode, '') AS consoCode, + COALESCE(jo.code, '') AS jobOrderCode, + jo.id AS jobOrderId, + '' AS deliveryOrderCode, + COALESCE(picker_user.name, jpo_handler_user.name, sol.createdBy, '') AS handler, + COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp, + 'MATERIAL' AS usageType + 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 ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 + LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 + LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 + LEFT JOIN job_order jo ON po.joId = jo.id AND jo.deleted = 0 + LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 + LEFT JOIN jo_pick_order jpo ON po.id = jpo.pick_order_id AND pol.itemId = jpo.item_id AND jpo.deleted = 0 + LEFT JOIN user jpo_handler_user ON jpo.handled_by = jpo_handler_user.id AND jpo_handler_user.deleted = 0 + WHERE sol.deleted = 0 AND il.id = :inventoryLotId + """.trimIndent() + + val fgSql = """ + SELECT + sol.id AS stockOutLineId, + COALESCE(sol.qty, 0) AS qty, + COALESCE(po.code, '') AS pickOrderCode, + po.id AS pickOrderId, + COALESCE(po.consoCode, '') AS consoCode, + '' AS jobOrderCode, + NULL AS jobOrderId, + COALESCE(do_tbl.code, '') AS deliveryOrderCode, + COALESCE(picker_user.name, sol.createdBy, '') AS handler, + COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp, + 'FG_DELIVERY' AS usageType + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 AND so.type = 'do' + 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 + LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 + LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 + LEFT JOIN delivery_order do_tbl ON po.doId = do_tbl.id AND do_tbl.deleted = 0 + LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 + WHERE sol.deleted = 0 AND il.id = :inventoryLotId + """.trimIndent() + + val args = mapOf("inventoryLotId" to inventoryLotId) + val material = jdbcDao.queryForList(materialSql, args) + val fg = jdbcDao.queryForList(fgSql, args) + return (material + fg).map { row -> mapOutboundUsage(row) } + } + + open fun mapOutboundUsage(row: Map): ItemLotTraceOutboundUsage = + ItemLotTraceOutboundUsage( + stockOutLineId = sql.longVal(row["stockOutLineId"]) ?: 0L, + qty = sql.decimal(row["qty"]), + pickOrderCode = sql.str(row["pickOrderCode"]), + pickOrderId = sql.longVal(row["pickOrderId"]), + consoCode = sql.str(row["consoCode"]), + jobOrderCode = sql.str(row["jobOrderCode"]), + jobOrderId = sql.longVal(row["jobOrderId"]), + deliveryOrderCode = sql.str(row["deliveryOrderCode"]), + handler = sql.str(row["handler"]), + timestamp = sql.formatDateTime(row["timestamp"]), + usageType = sql.str(row["usageType"]), + ) + + private fun doDeliverySelectSql(): String = """ + SELECT + sol.id AS stockOutLineId, + COALESCE(sol.qty, 0) AS qty, + COALESCE(po.code, '') AS pickOrderCode, + po.id AS pickOrderId, + COALESCE(po.consoCode, '') AS consoCode, + COALESCE(do_tbl.code, so.deliveryOrderCode, dpor.delivery_order_code, '') AS deliveryOrderCode, + do_tbl.id AS deliveryOrderId, + COALESCE(do_tbl.isExtra, 0) AS deliveryOrderIsExtra, + pol.id AS pickOrderLineId, + il.itemId AS itemId, + COALESCE(dop_direct.ticketNo, dop_conso.ticketNo) AS dopTicketNo, + COALESCE(dpo.ticket_no, dpor.ticket_no, '') AS legacyTicketNo, + COALESCE( + dop_direct.releaseType, + dop_conso.releaseType, + dpo.release_type, + dpor.release_type, + '' + ) AS releaseType, + COALESCE(dop_direct.id, dop_conso.id) AS deliveryOrderPickOrderId, + COALESCE(dop_direct.relationshipId, dop_conso.relationshipId) AS relationshipId, + COALESCE( + dop_direct.deliveryNoteCode, + dop_conso.deliveryNoteCode, + dpor.deliveryNoteCode, + '' + ) AS deliveryNoteCode, + dpor.id AS doPickOrderRecordId, + COALESCE(po.consoCode, '') AS pickOrderConsoCode, + 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(picker_user.name, sol.createdBy, '') AS handler, + COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.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 + LEFT JOIN pick_order_line pol ON sol.pickOrderLineId = pol.id AND pol.deleted = 0 + LEFT JOIN pick_order po ON pol.poId = po.id AND po.deleted = 0 + LEFT JOIN do_pick_order dpo + ON po.id = dpo.pick_order_id AND dpo.deleted = 0 + LEFT JOIN do_pick_order_record dpor + ON po.id = dpor.pick_order_id AND dpor.deleted = 0 + LEFT JOIN delivery_order do_tbl + ON do_tbl.deleted = 0 + AND do_tbl.id = COALESCE(po.doId, dpo.do_order_id, dpor.do_order_id) + LEFT JOIN delivery_order_pick_order dop_direct + 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 user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 + """.trimIndent() + + open fun loadDoDeliveries(inventoryLotId: Long): List { + val query = """ + ${doDeliverySelectSql()} + WHERE sol.deleted = 0 AND il.id = :inventoryLotId + AND ( + UPPER(COALESCE(so.type, '')) = 'DO' + OR po.doId IS NOT NULL + OR dpo.id IS NOT NULL + OR dpor.id IS NOT NULL + ) + ORDER BY timestamp DESC + """.trimIndent() + return mapDoDeliveryRows( + jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)), + ) + } + + /** Fallback when lot-scoped query misses DO picks still present in movements. */ + open fun loadDoDeliveriesByPickOrderCodes(pickOrderCodes: List): List { + val codes = pickOrderCodes.map { it.trim() }.filter { it.isNotEmpty() }.distinct() + if (codes.isEmpty()) return emptyList() + val query = """ + ${doDeliverySelectSql()} + WHERE sol.deleted = 0 + AND po.code IN (:pickOrderCodes) + AND ( + UPPER(COALESCE(so.type, '')) = 'DO' + OR po.doId IS NOT NULL + OR dpo.id IS NOT NULL + OR dpor.id IS NOT NULL + ) + ORDER BY timestamp DESC + """.trimIndent() + return mapDoDeliveryRows(jdbcDao.queryForList(query, mapOf("pickOrderCodes" to codes))) + } + + private fun mapDoDeliveryRows(rows: List>): List { + val deliveryOrderIds = rows.mapNotNull { sql.longVal(it["deliveryOrderId"]) }.distinct() + val replenishIndex = doReplenishmentService.buildReplenishPdfIndex(deliveryOrderIds) + return rows.map { row -> mapDoDelivery(row, replenishIndex) } + } + + private fun mapDoDelivery( + row: Map, + replenishIndex: DoReplenishmentService.ReplenishPdfIndex, + ): ItemLotTraceDoDelivery { + val deliveryOrderId = sql.longVal(row["deliveryOrderId"]) + val pickOrderLineId = sql.longVal(row["pickOrderLineId"]) + val itemId = sql.longVal(row["itemId"]) + val ticketNo = resolveOutboundTicketNo( + dopTicketNo = sql.str(row["dopTicketNo"]).takeIf { it.isNotBlank() }, + legacyTicketNo = sql.str(row["legacyTicketNo"]).takeIf { it.isNotBlank() }, + pickOrderConsoCode = sql.str(row["pickOrderConsoCode"]).takeIf { it.isNotBlank() }, + stockOutConsoCode = sql.str(row["stockOutConsoCode"]).takeIf { it.isNotBlank() }, + ) + val releaseType = sql.str(row["releaseType"]).takeIf { it.isNotBlank() } + val deliveryOrderIsExtra = truthy(row["deliveryOrderIsExtra"]) + val pickOrderConsoCode = sql.str(row["pickOrderConsoCode"]).takeIf { it.isNotBlank() } + val dopId = sql.longVal(row["deliveryOrderPickOrderId"]) + val relationshipId = sql.longVal(row["relationshipId"]) + val isExtra = resolveDoOutboundIsExtra( + ticketNo = ticketNo, + pickOrderConsoCode = pickOrderConsoCode, + releaseType = releaseType, + deliveryOrderIsExtra = deliveryOrderIsExtra, + dopId = dopId, + relationshipId = relationshipId, + ) + val isReplenish = deliveryOrderId != null && + replenishIndex.matches(deliveryOrderId, itemId, pickOrderLineId) + return ItemLotTraceDoDelivery( + stockOutLineId = sql.longVal(row["stockOutLineId"]) ?: 0L, + qty = sql.decimal(row["qty"]), + pickOrderCode = sql.str(row["pickOrderCode"]), + pickOrderId = sql.longVal(row["pickOrderId"]), + consoCode = sql.str(row["consoCode"]), + ticketNo = ticketNo.orEmpty(), + deliveryOrderCode = sql.str(row["deliveryOrderCode"]), + deliveryOrderId = deliveryOrderId, + shopCode = sql.str(row["shopCode"]), + shopName = sql.str(row["shopName"]), + handler = sql.str(row["handler"]), + timestamp = sql.formatDateTime(row["timestamp"]), + releaseType = releaseType, + deliveryOrderPickOrderId = dopId, + relationshipId = relationshipId, + deliveryNoteCode = sql.str(row["deliveryNoteCode"]), + doPickOrderRecordId = sql.longVal(row["doPickOrderRecordId"]), + isExtra = isExtra, + isReplenish = isReplenish, + ) + } + + private fun truthy(v: Any?): Boolean = when (v) { + null -> false + is Boolean -> v + is Number -> v.toInt() != 0 + else -> v.toString().equals("true", ignoreCase = true) || v.toString() == "1" + } + + companion object { + /** + * Ticket priority: workbench [delivery_order_pick_order] → legacy GoodPick ticket + * ([do_pick_order] / [do_pick_order_record]) → TI-* conso → other conso. + */ + internal fun resolveOutboundTicketNo( + dopTicketNo: String?, + legacyTicketNo: String? = null, + pickOrderConsoCode: String?, + stockOutConsoCode: String?, + ): String? { + dopTicketNo?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + legacyTicketNo?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + return sequenceOf(pickOrderConsoCode, stockOutConsoCode) + .mapNotNull { it?.trim()?.takeIf { s -> s.isNotEmpty() } } + .firstOrNull { it.startsWith("TI-", ignoreCase = true) } + ?: pickOrderConsoCode?.trim()?.takeIf { it.isNotEmpty() } + ?: stockOutConsoCode?.trim()?.takeIf { it.isNotEmpty() } + } + + internal fun isExtraReleaseType(releaseType: String?): Boolean = + when (releaseType?.trim()?.lowercase()) { + "isextra", "isextrabatch", "isextrasingle" -> true + else -> false + } + + internal fun parseTicketTypeLetter(ticketNo: String?): String? { + val parts = ticketNo?.trim()?.split("-").orEmpty() + return parts.getOrNull(1) + } + + internal fun isTiETicket(ticketNo: String?): Boolean = + parseTicketTypeLetter(ticketNo)?.equals("E", ignoreCase = true) == true || + ticketNo?.trim()?.startsWith("TI-E-", ignoreCase = true) == true + + internal fun isTiMTicket(ticketNo: String?): Boolean = + ticketNo?.trim()?.startsWith("TI-M-", ignoreCase = true) == true + + /** + * Aligns with [DeliveryOrderService.isExtraDeliveryTicket]: + * - TI-E / conso TI-E- → extra + * - TI-M → per DO [isExtra], conso TI-E-, or retired TI-E lineage via [relationshipId] + * - Other tickets → releaseType / DO flag + */ + internal fun resolveDoOutboundIsExtra( + ticketNo: String?, + pickOrderConsoCode: String?, + releaseType: String?, + deliveryOrderIsExtra: Boolean, + dopId: Long?, + relationshipId: Long?, + ): Boolean { + val resolvedTicket = ticketNo?.trim()?.takeIf { it.isNotEmpty() } + val conso = pickOrderConsoCode?.trim()?.takeIf { it.isNotEmpty() } + val consoIsTiE = isTiETicket(conso) + + if (isTiMTicket(resolvedTicket)) { + if (deliveryOrderIsExtra) return true + if (consoIsTiE) return true + if (dopId != null && relationshipId != null && relationshipId != dopId) return true + return false + } + + if (isTiETicket(resolvedTicket) || consoIsTiE) return true + + if (isExtraReleaseType(releaseType)) return true + return deliveryOrderIsExtra + } + } + + open fun loadReplenishmentEvents(inventoryLotId: Long): List { + val query = """ + SELECT + dr.id AS replenishmentId, + dr.code AS replenishmentCode, + COALESCE(dr.sourceDoCode, '') AS sourceDoCode, + dr.sourceDoId AS sourceDoId, + COALESCE(dr.shopCode, '') AS shopCode, + COALESCE(dr.shopName, '') AS shopName, + COALESCE(dr.itemNo, '') AS itemNo, + COALESCE(dr.itemName, '') AS itemName, + dr.itemId AS itemId, + 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 + 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 + ORDER BY dr.created DESC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) + .map { row -> mapReplenishmentEvent(row) } + } + + private fun mapReplenishmentEvent(row: Map): ItemLotTraceReplenishmentEvent = + ItemLotTraceReplenishmentEvent( + replenishmentId = sql.longVal(row["replenishmentId"]) ?: 0L, + replenishmentCode = sql.str(row["replenishmentCode"]), + sourceDoCode = sql.str(row["sourceDoCode"]), + sourceDoId = sql.longVal(row["sourceDoId"]), + shopCode = sql.str(row["shopCode"]), + shopName = sql.str(row["shopName"]), + itemNo = sql.str(row["itemNo"]), + itemName = sql.str(row["itemName"]), + reason = sql.str(row["reason"]), + replenishQty = sql.decimal(row["replenishQty"]), + handler = sql.str(row["handler"]), + timestamp = sql.formatDateTime(row["timestamp"]), + pickOrderLineId = sql.longVal(row["pickOrderLineId"]), + stockOutLineId = sql.longVal(row["stockOutLineId"]), + ) + + open fun loadReturnEvents(inventoryLotId: Long, lotLineIds: List): List { + if (lotLineIds.isEmpty()) return emptyList() + val query = """ + SELECT + sol.id AS stockOutLineId, + COALESCE(sol.qty, 0) AS qty, + COALESCE(so.consoPickOrderCode, so.deliveryOrderCode, '') AS refCode, + COALESCE(UPPER(so.type), UPPER(sol.type), 'RETURN') AS movementType, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(picker_user.name, sol.createdBy, '') AS handler, + COALESCE(sol.endTime, sol.pickTime, sol.created) AS timestamp, + COALESCE(so.remarks, '') AS remarks + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 + INNER JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + LEFT JOIN user picker_user ON sol.handled_by = picker_user.id AND picker_user.deleted = 0 + WHERE sol.deleted = 0 + AND ill.inventoryLotId = :inventoryLotId + AND sol.inventoryLotLineId IN (:lotLineIds) + AND ( + UPPER(COALESCE(so.type, '')) IN ('RETURN', 'RTN', 'SUPPLIER_RETURN') + OR UPPER(COALESCE(sol.type, '')) IN ('RETURN', 'RTN') + OR COALESCE(so.remarks, '') LIKE '%退貨%' + OR COALESCE(so.remarks, '') LIKE '%退货%' + OR COALESCE(so.remarks, '') LIKE '%return%' + ) + ORDER BY timestamp DESC + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("inventoryLotId" to inventoryLotId, "lotLineIds" to lotLineIds), + ).map { row -> + ItemLotTraceReturnEvent( + stockOutLineId = sql.longVal(row["stockOutLineId"]) ?: 0L, + qty = sql.decimal(row["qty"]), + refCode = sql.str(row["refCode"]), + movementType = sql.str(row["movementType"]), + warehouseCode = sql.str(row["warehouseCode"]), + handler = sql.str(row["handler"]), + timestamp = sql.formatDateTime(row["timestamp"]), + remarks = sql.str(row["remarks"]), + ) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt new file mode 100644 index 0000000..7117e9d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt @@ -0,0 +1,120 @@ +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 + +@Component +open class TraceProductionLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, + private val bomLoader: TraceBomLoader, +) { + open fun loadProductionSteps(jobOrderId: Long): List { + val stepMaterials = bomLoader.loadStepMaterialsByJobOrder(jobOrderId) + val query = """ + SELECT + ppl.id AS processLineId, + ppl.bomProcessId AS bomProcessId, + COALESCE(ppl.name, '') AS stepName, + COALESCE(ppl.description, '') AS description, + COALESCE(e.code, '') AS equipmentCode, + COALESCE(e.name, ppl.equipment_name, '') AS equipmentName, + COALESCE(u.name, '') AS operatorName, + ppl.startTime AS startTime, + ppl.endTime AS endTime, + COALESCE(ppl.outputFromProcessQty, 0) AS outputQty, + COALESCE(ppl.scrapQty, 0) AS scrapQty, + ( + COALESCE(ppl.defectQty, 0) + + COALESCE(ppl.defectQty2, 0) + + COALESCE(ppl.defectQty3, 0) + ) AS defectQty, + COALESCE(ppl.status, '') AS status, + ppl.seqNo AS seqNo + FROM productprocess pp + INNER JOIN productprocessline ppl ON ppl.productprocessid = pp.id AND ppl.deleted = 0 + LEFT JOIN equipment e ON ppl.equipmentId = e.id AND e.deleted = 0 + LEFT JOIN user u ON ppl.operatorId = u.id AND u.deleted = 0 + WHERE pp.jobOrderId = :jobOrderId AND pp.deleted = 0 + ORDER BY ppl.seqNo, ppl.id + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).map { row -> + val bomProcessId = sql.longVal(row["bomProcessId"]) + ItemLotTraceProductionStep( + processLineId = sql.longVal(row["processLineId"]) ?: 0L, + stepName = sql.str(row["stepName"]), + description = sql.str(row["description"]), + equipmentCode = sql.str(row["equipmentCode"]), + equipmentName = sql.str(row["equipmentName"]), + operatorName = sql.str(row["operatorName"]), + startTime = sql.formatDateTime(row["startTime"]), + endTime = sql.formatDateTime(row["endTime"]), + outputQty = sql.decimal(row["outputQty"]), + scrapQty = sql.decimal(row["scrapQty"]), + defectQty = sql.decimal(row["defectQty"]), + status = sql.str(row["status"]), + seqNo = sql.longVal(row["seqNo"]), + bomProcessId = bomProcessId, + stepMaterials = bomProcessId?.let { stepMaterials[it].orEmpty() } ?: emptyList(), + ) + } + } + + open fun loadByproductLots(jobOrderId: Long, currentItemId: Long): List { + val query = """ + SELECT + il.id AS inventoryLotId, + sil.id AS stockInLineId, + COALESCE(il.lotNo, '') AS lotNo, + COALESCE(bp_it.code, '') AS itemCode, + COALESCE(bp_it.name, '') AS itemName, + COALESCE(sil.acceptedQty, 0) AS qty, + COALESCE(uc.udfudesc, '') AS uom, + COALESCE(ppl.name, '') AS processStepName, + COALESCE(sil.created, sil.modified, sil.receiptDate) AS producedAt, + COALESCE(jo.code, '') AS jobOrderCode, + jo.id AS jobOrderId + FROM job_order jo + INNER JOIN stock_in_line sil ON sil.jobOrderId = jo.id AND sil.deleted = 0 + INNER JOIN inventory_lot il ON sil.inventoryLotId = il.id AND il.deleted = 0 + INNER JOIN items bp_it ON il.itemId = bp_it.id AND bp_it.deleted = 0 + LEFT JOIN item_uom iu ON bp_it.id = iu.itemId AND iu.stockUnit = 1 + LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + LEFT JOIN productprocess pp ON pp.jobOrderId = jo.id AND pp.deleted = 0 + LEFT JOIN productprocessline ppl ON ppl.productprocessid = pp.id + AND ppl.deleted = 0 + AND ppl.byproductId = bp_it.id + WHERE jo.id = :jobOrderId + AND jo.deleted = 0 + AND il.itemId <> :currentItemId + AND EXISTS ( + SELECT 1 + FROM productprocess pp2 + INNER JOIN productprocessline ppl2 ON ppl2.productprocessid = pp2.id AND ppl2.deleted = 0 + WHERE pp2.jobOrderId = jo.id + AND pp2.deleted = 0 + AND ppl2.byproductId = il.itemId + ) + ORDER BY producedAt, il.lotNo + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("jobOrderId" to jobOrderId, "currentItemId" to currentItemId), + ).map { row -> + ItemLotTraceByproductLot( + inventoryLotId = sql.longVal(row["inventoryLotId"]), + stockInLineId = sql.longVal(row["stockInLineId"]), + lotNo = sql.str(row["lotNo"]), + itemCode = sql.str(row["itemCode"]), + itemName = sql.str(row["itemName"]), + qty = sql.decimal(row["qty"]), + uom = sql.str(row["uom"]), + processStepName = sql.str(row["processStepName"]), + producedAt = sql.formatDateTime(row["producedAt"]), + jobOrderCode = sql.str(row["jobOrderCode"]), + jobOrderId = sql.longVal(row["jobOrderId"]), + ) + }.distinctBy { "${it.itemCode}:${it.lotNo}" } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceSqlSupport.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceSqlSupport.kt new file mode 100644 index 0000000..f4f9d4a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceSqlSupport.kt @@ -0,0 +1,61 @@ +package com.ffii.fpsms.modules.stock.trace + +import org.springframework.stereotype.Component +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Component +open class TraceSqlSupport { + val dateFmt: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE + val dateTimeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + open fun str(v: Any?): String = v?.toString()?.trim() ?: "" + + open fun strOrNull(v: Any?): String? = v?.toString()?.trim()?.ifBlank { null } + + open fun longVal(v: Any?): Long? = when (v) { + null -> null + is Number -> v.toLong() + else -> v.toString().toLongOrNull() + } + + open fun intVal(v: Any?): Int? = when (v) { + null -> null + is Number -> v.toInt() + else -> v.toString().toIntOrNull() + } + + open fun decimal(v: Any?): BigDecimal = when (v) { + null -> BigDecimal.ZERO + is BigDecimal -> v + is Number -> BigDecimal.valueOf(v.toDouble()) + else -> v.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO + } + + open fun decimalOrNull(v: Any?): BigDecimal? = when (v) { + null -> null + is BigDecimal -> v + is Number -> BigDecimal.valueOf(v.toDouble()) + else -> v.toString().toBigDecimalOrNull() + } + + open fun formatDate(v: Any?): String? = when (v) { + null -> null + is LocalDate -> v.format(dateFmt) + is java.sql.Date -> v.toLocalDate().format(dateFmt) + is LocalDateTime -> v.toLocalDate().format(dateFmt) + is java.sql.Timestamp -> v.toLocalDateTime().toLocalDate().format(dateFmt) + else -> v.toString().takeIf { it.isNotBlank() } + } + + open fun formatDateTime(v: Any?): String? = when (v) { + null -> null + is LocalDateTime -> v.format(dateTimeFmt) + is java.sql.Timestamp -> v.toLocalDateTime().format(dateTimeFmt) + is LocalDate -> v.atStartOfDay().format(dateTimeFmt) + is java.sql.Date -> v.toLocalDate().atStartOfDay().format(dateTimeFmt) + else -> v.toString().takeIf { it.isNotBlank() } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceUpstreamLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceUpstreamLoader.kt new file mode 100644 index 0000000..ba19682 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceUpstreamLoader.kt @@ -0,0 +1,246 @@ +package com.ffii.fpsms.modules.stock.trace + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Component + +/** + * Resolves upstream inventory lots for trace merge after warehouse transfer / lot split. + * Merges PO, JO, QC, putaway history from source lots into the traced (destination) lot view + * via [ItemLotTraceOrchestrator] calling [resolveUpstreamLotIds] (siblings with the same + * lotNo are left to alternateLocations / locationBlocks). + */ +@Component +open class TraceUpstreamLoader( + private val jdbcDao: JdbcDao, + private val sql: TraceSqlSupport, +) { + data class JoProvenance( + val jobOrderId: Long, + val joStockInLineId: Long, + val upstreamLotIds: List, + ) + + /** + * Inbound transfer sources for [inventoryLotId] (this lot received qty via transfer). + * Mirrors [TraceMovementLoader.loadTransfers] lot matching (sil + ill_to on dest, ill_from on source). + */ + open fun findInboundTransferSourceLotIds(inventoryLotId: Long): List { + val query = """ + SELECT ill_from.inventoryLotId AS sourceInventoryLotId + FROM stock_transfer_record str + LEFT JOIN stock_in_line sil ON str.stockInLineId = sil.id AND sil.deleted = 0 + LEFT JOIN stock_out_line sol ON str.stockOutLineId = sol.id AND sol.deleted = 0 + LEFT JOIN inventory_lot_line ill_from ON sol.inventoryLotLineId = ill_from.id AND ill_from.deleted = 0 + LEFT JOIN inventory_lot_line ill_to ON sil.inventoryLotLineId = ill_to.id AND ill_to.deleted = 0 + WHERE str.deleted = 0 + AND ill_from.inventoryLotId IS NOT NULL + AND ill_from.inventoryLotId <> :inventoryLotId + AND ( + sil.inventoryLotId = :inventoryLotId + OR ill_to.inventoryLotId = :inventoryLotId + ) + GROUP BY ill_from.inventoryLotId + ORDER BY MIN(str.id) ASC + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) + .mapNotNull { sql.longVal(it["sourceInventoryLotId"]) } + .distinct() + } + + /** Walk inbound transfer chain and collect every source lot (PO / JO / any origin). */ + open fun collectTransferUpstreamLotIds(tracedLotId: Long, maxDepth: Int = 12): List { + val found = linkedSetOf() + var frontier = listOf(tracedLotId) + val visited = mutableSetOf(tracedLotId) + repeat(maxDepth) { + val sources = frontier.flatMap { lotId -> findInboundTransferSourceLotIds(lotId) } + .filter { it !in visited } + if (sources.isEmpty()) return found.toList() + sources.forEach { id -> + found.add(id) + visited.add(id) + } + frontier = sources + } + return found.toList() + } + + /** Other inventory lots with the same lotNo+itemId (transfer split / multi-warehouse). */ + open fun findSiblingLotIdsBySameLotNo( + itemId: Long, + lotNo: String, + excludeInventoryLotId: Long, + ): List { + if (lotNo.isBlank()) return emptyList() + val query = """ + SELECT il.id AS inventoryLotId + FROM inventory_lot il + WHERE il.deleted = 0 + AND il.itemId = :itemId + AND il.lotNo = :lotNo + AND il.id <> :excludeInventoryLotId + ORDER BY il.stockInDate ASC, il.id ASC + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("itemId" to itemId, "lotNo" to lotNo, "excludeInventoryLotId" to excludeInventoryLotId), + ).mapNotNull { sql.longVal(it["inventoryLotId"]) }.distinct() + } + + /** JO receipt lots with same lotNo (legacy helper for JO-only provenance). */ + open fun findJoSourceLotIdsBySameLotNo( + itemId: Long, + lotNo: String, + excludeInventoryLotId: Long, + ): List { + if (lotNo.isBlank()) return emptyList() + val query = """ + SELECT il.id AS inventoryLotId + FROM inventory_lot il + INNER JOIN stock_in_line sil ON sil.inventoryLotId = il.id AND sil.deleted = 0 + WHERE il.deleted = 0 + AND il.itemId = :itemId + AND il.lotNo = :lotNo + AND il.id <> :excludeInventoryLotId + AND sil.jobOrderId IS NOT NULL + GROUP BY il.id + ORDER BY MIN(sil.receiptDate) ASC, il.id ASC + """.trimIndent() + return jdbcDao.queryForList( + query, + mapOf("itemId" to itemId, "lotNo" to lotNo, "excludeInventoryLotId" to excludeInventoryLotId), + ).mapNotNull { sql.longVal(it["inventoryLotId"]) }.distinct() + } + + open fun findJoSourceLotIdsByProductLotNo(tracedInventoryLotId: Long): List { + val query = """ + SELECT il_jo.id AS inventoryLotId + FROM stock_in_line sil_trace + INNER JOIN inventory_lot il_trace ON sil_trace.inventoryLotId = il_trace.id AND il_trace.deleted = 0 + INNER JOIN stock_in_line sil_jo ON sil_jo.deleted = 0 + AND sil_jo.jobOrderId IS NOT NULL + AND sil_jo.productLotNo IS NOT NULL + AND TRIM(sil_jo.productLotNo) <> '' + AND sil_jo.productLotNo = sil_trace.productLotNo + INNER JOIN inventory_lot il_jo ON sil_jo.inventoryLotId = il_jo.id AND il_jo.deleted = 0 + WHERE sil_trace.deleted = 0 + AND sil_trace.inventoryLotId = :tracedInventoryLotId + AND il_jo.id <> :tracedInventoryLotId + GROUP BY il_jo.id + ORDER BY MIN(sil_jo.receiptDate) ASC, il_jo.id ASC + """.trimIndent() + val fromSil = jdbcDao.queryForList(query, mapOf("tracedInventoryLotId" to tracedInventoryLotId)) + .mapNotNull { sql.longVal(it["inventoryLotId"]) } + if (fromSil.isNotEmpty()) return fromSil.distinct() + + val viaLotLine = """ + SELECT il_jo.id AS inventoryLotId + FROM inventory_lot_line ill_trace + INNER JOIN stock_in_line sil_trace ON sil_trace.inventoryLotLineId = ill_trace.id AND sil_trace.deleted = 0 + INNER JOIN stock_in_line sil_jo ON sil_jo.deleted = 0 + AND sil_jo.jobOrderId IS NOT NULL + AND sil_jo.productLotNo IS NOT NULL + AND TRIM(sil_jo.productLotNo) <> '' + AND sil_jo.productLotNo = sil_trace.productLotNo + INNER JOIN inventory_lot il_jo ON sil_jo.inventoryLotId = il_jo.id AND il_jo.deleted = 0 + WHERE ill_trace.deleted = 0 + AND ill_trace.inventoryLotId = :tracedInventoryLotId + AND il_jo.id <> :tracedInventoryLotId + GROUP BY il_jo.id + ORDER BY MIN(sil_jo.receiptDate) ASC, il_jo.id ASC + """.trimIndent() + return jdbcDao.queryForList(viaLotLine, mapOf("tracedInventoryLotId" to tracedInventoryLotId)) + .mapNotNull { sql.longVal(it["inventoryLotId"]) } + .distinct() + } + + /** + * All upstream lots whose inbound history should be merged into the trace response. + */ + open fun resolveUpstreamLotIds(tracedLotId: Long, itemId: Long, lotNo: String): List { + val ids = linkedSetOf() + collectTransferUpstreamLotIds(tracedLotId).forEach { ids.add(it) } + findSiblingLotIdsBySameLotNo(itemId, lotNo, tracedLotId).forEach { ids.add(it) } + findJoSourceLotIdsByProductLotNo(tracedLotId).forEach { ids.add(it) } + return ids.filter { it != tracedLotId } + } + + open fun findJoStockInLineId(inventoryLotId: Long): Long? { + val query = """ + SELECT sil.id AS stockInLineId + FROM stock_in_line sil + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0 + WHERE sil.deleted = 0 + AND sil.jobOrderId IS NOT NULL + AND ( + sil.inventoryLotId = :inventoryLotId + OR ill.inventoryLotId = :inventoryLotId + ) + ORDER BY sil.receiptDate DESC, sil.id DESC + LIMIT 1 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)) + .firstOrNull()?.let { sql.longVal(it["stockInLineId"]) } + } + + open fun findJobOrderIdForStockInLine(stockInLineId: Long): Long? { + val query = """ + SELECT jo.id AS jobOrderId + FROM stock_in_line sil + INNER JOIN job_order jo ON sil.jobOrderId = jo.id AND jo.deleted = 0 + WHERE sil.id = :stockInLineId AND sil.deleted = 0 + """.trimIndent() + return jdbcDao.queryForList(query, mapOf("stockInLineId" to stockInLineId)) + .firstOrNull()?.let { sql.longVal(it["jobOrderId"]) } + } + + private fun provenanceFromJoLot(joLotId: Long, tracedLotId: Long): JoProvenance? { + val joSil = findJoStockInLineId(joLotId) ?: return null + val joId = findJobOrderIdForStockInLine(joSil) ?: return null + val upstream = if (joLotId == tracedLotId) emptyList() else listOf(joLotId) + return JoProvenance(jobOrderId = joId, joStockInLineId = joSil, upstreamLotIds = upstream) + } + + private fun walkTransferChainForJo(tracedLotId: Long, maxDepth: Int): JoProvenance? { + val pathFromTraced = mutableListOf() + var current = tracedLotId + val visited = mutableSetOf() + + while (current !in visited && pathFromTraced.size < maxDepth) { + visited.add(current) + val joSil = findJoStockInLineId(current) + if (joSil != null) { + val joId = findJobOrderIdForStockInLine(joSil) ?: return null + val upstream = if (current == tracedLotId) { + emptyList() + } else { + (pathFromTraced + current).distinct().filter { it != tracedLotId } + } + return JoProvenance(jobOrderId = joId, joStockInLineId = joSil, upstreamLotIds = upstream) + } + val sources = findInboundTransferSourceLotIds(current).filter { it !in visited } + val next = sources.firstOrNull() ?: break + pathFromTraced.add(next) + current = next + } + return null + } + + open fun resolveJoProvenance( + tracedLotId: Long, + itemId: Long? = null, + lotNo: String? = null, + maxDepth: Int = 12, + ): JoProvenance? { + walkTransferChainForJo(tracedLotId, maxDepth)?.let { return it } + if (itemId != null && !lotNo.isNullOrBlank()) { + findJoSourceLotIdsBySameLotNo(itemId, lotNo, tracedLotId) + .firstOrNull() + ?.let { return provenanceFromJoLot(it, tracedLotId) } + } + findJoSourceLotIdsByProductLotNo(tracedLotId) + .firstOrNull() + ?.let { return provenanceFromJoLot(it, tracedLotId) } + return null + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt index 46bdf3c..d915258 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt @@ -7,6 +7,7 @@ import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import com.ffii.fpsms.modules.stock.entity.projection.InventoryLotLineInfo import com.ffii.fpsms.modules.stock.entity.projection.LotLineToQrcode import com.ffii.fpsms.modules.stock.service.InventoryLotLineService +import com.ffii.fpsms.modules.stock.service.ItemLotTraceService import com.ffii.fpsms.modules.stock.service.StockInLineService import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest import com.ffii.fpsms.modules.stock.web.model.LotLineInfo @@ -28,6 +29,8 @@ import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineReque import com.ffii.fpsms.modules.stock.web.model.UpdateInventoryLotLineStatusRequest import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse import org.springframework.http.HttpStatus import org.springframework.web.server.ResponseStatusException @@ -39,6 +42,7 @@ class InventoryLotLineController ( private val inventoryLotLineService: InventoryLotLineService, private val stockInLineRepository: StockInLineRepository, private val stockInLineService: StockInLineService, + private val itemLotTraceService: ItemLotTraceService, ){ // @PostMapping("/test") @@ -150,6 +154,28 @@ class InventoryLotLineController ( fun updateInventoryLotLineQuantities(@RequestBody request: UpdateInventoryLotLineQuantitiesRequest): MessageResponse { return inventoryLotLineService.updateInventoryLotLineQuantities(request) } + @GetMapping("/trace") + fun traceLot( + @RequestParam(required = false) stockInLineId: Long?, + @RequestParam(required = false) inventoryLotLineId: Long?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) itemId: Long?, + @RequestParam(required = false) itemCode: String?, + ): ItemLotTraceResponse { + return itemLotTraceService.trace(stockInLineId, inventoryLotLineId, lotNo, itemId, itemCode) + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 7 | v1.0.0 | 2026-07-14 + * Lazy-load: single location trace for an alternate inventory lot. + * Returns location-scoped trace data (movements, origins, QC, outbound, stock take, etc.) + * without the lot-level fields (BOM, JO prelude, production steps, byproducts). + */ + @GetMapping("/trace/location/{inventoryLotId}") + fun traceLocation(@PathVariable inventoryLotId: Long): ItemLotTraceLocationBlock { + return itemLotTraceService.traceLocation(inventoryLotId) + } + @PostMapping("/analyze-qr-code") fun analyzeQrCode(@RequestBody request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { return inventoryLotLineService.analyzeQrCode(request) 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 new file mode 100644 index 0000000..5d89bc3 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt @@ -0,0 +1,530 @@ +package com.ffii.fpsms.modules.stock.web.model + +import com.fasterxml.jackson.annotation.JsonProperty +import java.math.BigDecimal + +data class ItemLotTraceLotInfo( + val inventoryLotId: Long, + val lotNo: String, + val itemId: Long, + val itemCode: String, + val itemName: String, + val expiryDate: String?, + val productionDate: String?, + val stockInDate: String?, + val uom: String, + val primaryStockInLineId: Long?, +) + +data class ItemLotTraceWarehouseLine( + val inventoryLotLineId: Long, + val warehouseCode: String, + val inQty: BigDecimal, + val outQty: BigDecimal, + val issueQty: BigDecimal, + val status: String, + val availableQty: BigDecimal, +) + +data class ItemLotTraceOrigin( + val stockInLineId: Long, + val type: String, + val refCode: String, + val refId: Long?, + val supplierCode: String, + val supplierName: String, + val dnNo: String, + val receiptDate: String?, + val acceptedQty: BigDecimal, + val status: String, +) + +/** Purchase order line context before goods receipt (採購). */ +data class ItemLotTracePurchaseEvent( + val purchaseOrderLineId: Long, + val purchaseOrderId: Long?, + val purchaseOrderCode: String, + val itemCode: String, + val itemName: String, + val orderQty: BigDecimal, + val putAwayQty: BigDecimal, + val purchaseUnit: String, + val supplierCode: String, + val supplierName: String, + val orderDate: String?, + val status: String, +) + +data class ItemLotTraceQcResult( + val stockInLineId: Long, + val qcPassed: Boolean, + val failQty: BigDecimal, + val acceptedQty: BigDecimal, + val remarks: String, + val handledBy: String, + val created: String?, + val qcItemCode: String = "", + val qcItemName: String = "", + val qcItemDescription: String = "", + val qcType: String = "", +) + +data class ItemLotTraceMovement( + val direction: String, + val qty: BigDecimal, + val movementType: String, + val refType: String, + val refCode: String, + val refId: Long?, + val warehouseCode: String, + val handledBy: String, + val timestamp: String?, + val remarks: String, + /** Populated for DO refType — pick_order.consoCode / ticket metadata for outbound chips. */ + val pickConsoCode: String = "", + val outboundTicketNo: String = "", + val deliveryOrderPickOrderId: Long? = null, + val relationshipId: Long? = null, + @get:JsonProperty("doOutboundIsExtra") + val doOutboundIsExtra: Boolean = false, + @get:JsonProperty("doOutboundIsReplenish") + val doOutboundIsReplenish: Boolean = false, + val processingStatus: String = "", + val matchStatus: String = "", +) + +/** Warehouse putaway: inventory_lot_line created when stock is shelved after receipt/QC. */ +data class ItemLotTracePutawayEvent( + val inventoryLotLineId: Long, + val stockInLineId: Long?, + val warehouseCode: String, + val qty: BigDecimal, + val handledBy: String, + val timestamp: String?, + val refType: String, + val refCode: String, + val refId: Long?, + /** stock_in_line.status — received = pending putaway, completed = put away done. */ + val status: String = "", +) + +data class ItemLotTraceOutboundUsage( + val stockOutLineId: Long, + val qty: BigDecimal, + val pickOrderCode: String, + val pickOrderId: Long?, + val consoCode: String, + val jobOrderCode: String, + val jobOrderId: Long?, + val deliveryOrderCode: String, + val handler: String, + val timestamp: String?, + val usageType: String, +) + +data class ItemLotTraceStockTakeRecordDetail( + val stockTakeLineId: Long, + val bookQty: BigDecimal, + val pickerFirstQty: BigDecimal? = null, + val pickerFirstBadQty: BigDecimal? = null, + val pickerSecondQty: BigDecimal? = null, + val pickerSecondBadQty: BigDecimal? = null, + val approverQty: BigDecimal? = null, + val approverBadQty: BigDecimal? = null, + val stockTakerName: String? = null, + val approverName: String? = null, + val stockTakeStartTime: String? = null, + val stockTakeEndTime: String? = null, + val approverTime: String? = null, + val recordStatus: String? = null, + val lastSelect: Int? = null, + val varianceQty: BigDecimal? = null, +) + +data class ItemLotTraceStockTakeEvent( + val stockTakeCode: String, + val lotNo: String = "", + val itemCode: String = "", + val itemName: String = "", + val warehouseCode: String = "", + val stockTakeSection: String = "", + val stockTakeRoundId: Long? = null, + val stockTakeRoundName: String = "", + val varianceQty: BigDecimal, + val beforeQty: BigDecimal, + val afterQty: BigDecimal, + val approver: String, + val timestamp: String?, + val recordDetail: ItemLotTraceStockTakeRecordDetail? = null, +) + +data class ItemLotTraceAdjustment( + val adjustmentType: String, + val direction: String, + val qty: BigDecimal, + val reason: String, + val handledBy: String, + val timestamp: String?, + val refCode: String, + val warehouseCode: String = "", +) + +data class ItemLotTraceTransfer( + val fromWarehouse: String, + val toWarehouse: String, + val qty: BigDecimal, + val transferCode: String, + val timestamp: String?, +) + +data class ItemLotTraceBomDownstream( + val jobOrderCode: String, + val jobOrderId: Long?, + val finishedItemCode: String, + val finishedLotNo: String, + val finishedStockInLineId: Long?, + val fgQty: BigDecimal, + val fgUom: String = "", + val materialQtyUsed: BigDecimal, +) + +data class ItemLotTraceBomUpstream( + val jobOrderCode: String, + val jobOrderId: Long?, + val materialItemCode: String, + val materialLotNo: String, + val materialStockInLineId: Long?, + val materialQty: BigDecimal, + val bomQtyPerUnit: BigDecimal?, +) + +data class ItemLotTraceBomRecipeLine( + val materialItemCode: String, + val materialItemName: String, + val qtyPerUnit: BigDecimal, + val uom: String, + val bomProcessId: Long? = null, + val bomProcessSeqNo: Long? = null, + val assignedStepName: String = "", +) + +data class ItemLotTraceStepMaterialRef( + val materialItemCode: String, + val materialItemName: String, + val qtyPerUnit: BigDecimal, + val uom: String, +) + +data class ItemLotTraceBomTrace( + val direction: String, + val downstream: List, + val upstream: List, + val bomRecipe: List, +) + +data class ItemLotTraceJoContext( + val jobOrderId: Long, + val jobOrderCode: String, + val planStart: String?, + /** Earliest jo_pick_order.created for this JO (actual work-order creation time). */ + val createdAt: String?, + val reqQty: BigDecimal, + val status: String, +) + +data class ItemLotTraceJoPickLine( + val pickOrderLineId: Long, + val itemCode: String, + val itemName: String, + val requiredQty: BigDecimal, + val pickedQty: BigDecimal, + val status: String, +) + +data class ItemLotTraceJoPickOrder( + val pickOrderId: Long, + val pickOrderCode: String, + val consoCode: String, + val status: String, + val targetDate: String?, + val completeDate: String?, + val releasedDate: String?, + val lines: List, +) + +data class ItemLotTraceMaterialInput( + val jobOrderCode: String, + val jobOrderId: Long?, + val materialItemCode: String, + val materialItemName: String, + val materialLotNo: String, + val materialStockInLineId: Long?, + val materialInventoryLotId: Long?, + val materialQty: BigDecimal, + val materialUom: String = "", + val bomQtyPerUnit: BigDecimal?, + val bomProcessId: Long? = null, + val bomProcessSeqNo: Long? = null, + val assignedStepName: String = "", + val pickOrderCode: String, + val pickOrderId: Long?, + val consoCode: String, + val pickedAt: String?, + val processingStatus: String = "", + val matchStatus: String = "", + val stockInOrigin: ItemLotTraceOrigin?, + val purchaseEvents: List = emptyList(), + val qcResults: List, + val putawayEvents: List = emptyList(), + /** When this material was produced by another JO, its upstream pick/prelude chain. */ + val nestedJoPrelude: ItemLotTraceJoPrelude? = null, + /** Production steps from the JO that produced this material lot. */ + val productionSteps: List = emptyList(), +) + +data class ItemLotTraceJoPrelude( + val jobOrder: ItemLotTraceJoContext, + val pickOrders: List, + val materialInputs: List, +) + +data class ItemLotTraceProductionStep( + val processLineId: Long, + val stepName: String, + val description: String, + val equipmentCode: String, + val equipmentName: String, + val operatorName: String, + val startTime: String?, + val endTime: String?, + val outputQty: BigDecimal, + val scrapQty: BigDecimal, + val defectQty: BigDecimal, + val status: String, + val seqNo: Long?, + val bomProcessId: Long? = null, + val stepMaterials: List = emptyList(), +) + +/** Sibling byproduct lot from the same job order (independent item/lot, traceable). */ +data class ItemLotTraceByproductLot( + val inventoryLotId: Long?, + val stockInLineId: Long?, + val lotNo: String, + val itemCode: String, + val itemName: String, + val qty: BigDecimal, + val uom: String, + val processStepName: String, + val producedAt: String?, + val jobOrderCode: String, + val jobOrderId: Long?, +) + +/** Opening-inventory receipt (type OPEN), distinct from ADJ adjustments. */ +data class ItemLotTraceOpenMovement( + val stockInLineId: Long, + val qty: BigDecimal, + val warehouseCode: String, + val refCode: String, + val handledBy: String, + val timestamp: String?, + val remarks: String, +) + +/** Pick/warehouse failure (missing, broken, etc.) from fail_inventory_lot_line. */ +data class ItemLotTraceFailEvent( + val failId: Long, + val failType: String, + val category: String, + val qty: BigDecimal, + val handlerName: String, + val recordDate: String?, + val pickOrderCode: String, + val pickOrderId: Long?, + val warehouseCode: String = "", +) + +/** Finished-good delivery via DO pick path. */ +data class ItemLotTraceDoDelivery( + val stockOutLineId: Long, + val qty: BigDecimal, + val pickOrderCode: String, + val pickOrderId: Long?, + val consoCode: String, + /** Resolved outbound ticket (dop.ticketNo, legacy ticket_no, or TI-* conso). */ + val ticketNo: String = "", + val deliveryOrderCode: String, + val deliveryOrderId: Long?, + val shopCode: String, + val shopName: String, + val handler: String, + val timestamp: String?, + /** delivery_order_pick_order.releaseType when linked (else legacy release_type). */ + val releaseType: String? = null, + val deliveryOrderPickOrderId: Long? = null, + /** Merge lineage on delivery_order_pick_order (equals own id until retired into TI-M). */ + val relationshipId: Long? = null, + /** DN from workbench dop or legacy do_pick_order_record. */ + val deliveryNoteCode: String = "", + /** Present when outbound came from completed legacy GoodPick record. */ + val doPickOrderRecordId: Long? = null, + /** Matches delivery note sequence suffix (加單). */ + @get:JsonProperty("isExtra") + val isExtra: Boolean = false, + /** Matches delivery note sequence suffix (補貨). */ + @get:JsonProperty("isReplenish") + val isReplenish: Boolean = false, +) + +/** DO replenishment created for a source delivery order line (建立補貨). */ +data class ItemLotTraceReplenishmentEvent( + val replenishmentId: Long, + val replenishmentCode: String, + val sourceDoCode: String, + val sourceDoId: Long?, + val shopCode: String, + val shopName: String, + val itemNo: String, + val itemName: String, + val reason: String, + val replenishQty: BigDecimal, + val handler: String, + val timestamp: String?, + val pickOrderLineId: Long?, + /** Stock-out line that fulfilled this replenishment pick (links to DO_OUT node). */ + val stockOutLineId: Long?, +) + +/** Supplier / stock return (outbound not tied to JO pick or DO). */ +data class ItemLotTraceReturnEvent( + val stockOutLineId: Long, + val qty: BigDecimal, + val refCode: String, + val movementType: String, + val warehouseCode: String, + val handler: String, + val timestamp: String?, + val remarks: String, +) + +/** Repack / split: sibling lot linked by transfer, same JO, or non-PO lineage (not independent PO receipts). */ +data class ItemLotTraceLotRelation( + val relationType: String, + val inventoryLotId: Long?, + val lotNo: String, + val itemCode: String, + val itemName: String, + val qty: BigDecimal, + val productLotNo: String, + val timestamp: String?, +) + +/** Same lotNo+itemId may exist on multiple inventory_lot rows (e.g. after transfer / repack). */ +data class ItemLotTraceAlternateLocation( + val inventoryLotId: Long, + val stockInLineId: Long?, + val inventoryLotLineId: Long, + val warehouseCode: String, + val availableQty: BigDecimal, +) + +/** + * Per-location lifecycle trace block for one alternate inventory_lot sharing the same lotNo+itemId. + * + * Contains only location-scoped dimensions (warehouse lines, origins, purchase events, QC results, + * putaway events, movements, outbound usage, stock take events, adjustments, transfers, open + * movements, fail events, DO deliveries, return events). + * + * Does NOT duplicate lot-level dimensions (bomTrace, joPrelude, productionSteps, byproductLots, + * lotRelations, traceGraph) — those are already at the top level of [ItemLotTraceResponse]. + * Does NOT contain [alternateLocations] or nested [locationBlocks] to avoid infinite recursion. + */ +data class ItemLotTraceLocationBlock( + val inventoryLotId: Long, + val lotNo: String, + val itemCode: String, + val itemName: String = "", + val uom: String = "", + val expiryDate: String? = null, + val productionDate: String? = null, + val stockInDate: String? = null, + val stockInLineId: Long?, + val warehouseLines: List, + val origins: List, + val purchaseEvents: List = emptyList(), + val qcResults: List = emptyList(), + val putawayEvents: List = emptyList(), + val movements: List = emptyList(), + val outboundUsage: List = emptyList(), + val stockTakeEvents: List = emptyList(), + val adjustments: List = emptyList(), + val transfers: List = emptyList(), + val openMovements: List = emptyList(), + val failEvents: List = emptyList(), + val doDeliveries: List = emptyList(), + val replenishmentEvents: List = emptyList(), + val returnEvents: List = emptyList(), +) + +data class ItemLotTraceGraphEdge( + val fromKey: String, + val toKey: String, + val kind: String = "FLOW", +) + +data class ItemLotTraceGraphNodeRef( + val key: String, + val kind: String, + val scopeKey: String? = null, + val refCode: String? = null, + val traceLotNo: String? = null, + val traceItemCode: String? = null, + /** Warehouse code for multi-location visual differentiation. Non-null when this node + * originates from an alternate location's trace block. */ + val warehouseCode: String? = null, +) + +data class ItemLotTraceGraph( + val nodes: List, + val edges: List, +) + +/** + * Lot genealogy / operational lifecycle trace response. + * + * v1 commercial scope (intentional): qty, warehouse, QC, document refs, handlers, + * expiry/production dates, multi-location blocks, and compiled flow graph. + * Cost / unit price / currency / inventory valuation and external customer / + * sales-order / invoice fields are out of scope until a finance-module decision. + */ +data class ItemLotTraceResponse( + val lot: ItemLotTraceLotInfo, + val warehouseLines: List, + val origins: List, + val purchaseEvents: List = emptyList(), + val qcResults: List, + val putawayEvents: List, + val movements: List, + val outboundUsage: List, + val stockTakeEvents: List, + val adjustments: List, + val transfers: List, + val bomTrace: ItemLotTraceBomTrace, + val joPrelude: ItemLotTraceJoPrelude?, + val productionSteps: List, + val byproductLots: List, + val openMovements: List, + val failEvents: List, + val doDeliveries: List, + val replenishmentEvents: List = emptyList(), + val returnEvents: List, + val lotRelations: List, + val alternateLocations: List = emptyList(), + /** Per-location full trace blocks for each alternate [inventoryLotId] sharing the same lotNo+itemId. + * Excludes the primary lot (whose data is already at the top level). + * Lot-level dimensions (BOM, JO prelude, production, byproducts, lot relations, traceGraph) + * are NOT duplicated here — see [ItemLotTraceLocationBlock] for what IS included. */ + val locationBlocks: List = emptyList(), + val traceGraph: ItemLotTraceGraph? = null, +) diff --git a/src/main/resources/db/changelog/changes/20260716_item_tracing/01_item_tracing_authority.sql b/src/main/resources/db/changelog/changes/20260716_item_tracing/01_item_tracing_authority.sql new file mode 100644 index 0000000..1cb3f68 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260716_item_tracing/01_item_tracing_authority.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql + +--changeset fpsms:item_tracing_authority +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'ITEM_TRACING' +--comment: Add independent permission for item lot tracing page and APIs +INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`) +VALUES ('ITEM_TRACING', '批號追溯', 'INVENTORY', 'Allow access to item lot tracing'); diff --git a/src/main/resources/db/changelog/changes/20260716_item_tracing/02_item_lot_trace_log.sql b/src/main/resources/db/changelog/changes/20260716_item_tracing/02_item_lot_trace_log.sql new file mode 100644 index 0000000..ecb0525 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260716_item_tracing/02_item_lot_trace_log.sql @@ -0,0 +1,24 @@ +--liquibase formatted sql + +--changeset fpsms:item_lot_trace_log +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'item_lot_trace_log' +--comment: One row per successful item lot trace (who, what, when) +CREATE TABLE `item_lot_trace_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` varchar(30) DEFAULT NULL, + `version` int NOT NULL DEFAULT '0', + `modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` varchar(30) DEFAULT NULL, + `deleted` tinyint(1) NOT NULL DEFAULT '0', + `userId` bigint NOT NULL, + `username` varchar(64) NOT NULL, + `inventoryLotId` bigint DEFAULT NULL, + `lotNo` varchar(64) DEFAULT NULL, + `itemId` bigint DEFAULT NULL, + `itemCode` varchar(64) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_item_lot_trace_log_user_created` (`userId`, `created`), + KEY `idx_item_lot_trace_log_lot_created` (`inventoryLotId`, `created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;