| @@ -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"))) | |||
| @@ -100,6 +100,7 @@ fun findAllByJobOrder_Id(jobOrderId: Long): List<PickOrder> | |||
| fun findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds: Collection<Long>): List<PickOrder> | |||
| fun findTopByJobOrder_IdOrderByCreatedDesc(jobOrderId: Long): PickOrder? | |||
| fun findAllByStatusIn(statuses: List<PickOrderStatus>): List<PickOrder> | |||
| fun findByCodeAndDeletedFalse(@Param("code") code: String): PickOrder? | |||
| @Query( | |||
| """ | |||
| @@ -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<LifecycleStage> { | |||
| 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<String, String?> { | |||
| val map = mutableMapOf<String, String?>() | |||
| 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<LifecycleStage> { | |||
| 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<LifecycleStage> { | |||
| 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, | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -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<PickOrderLifecycleResponse> { | |||
| 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<PickOrderLifecycleResponse> { | |||
| val result = lifecycleService.getLifecycleByCode(code) | |||
| return if (result != null) ResponseEntity.ok(result) | |||
| else ResponseEntity.notFound().build() | |||
| } | |||
| } | |||
| @@ -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<String, String?> = 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<LifecycleStage>, | |||
| ) | |||
| data class JoLifecycleInfo( | |||
| val joPickOrderId: Long?, | |||
| val jobOrderCode: String?, | |||
| val matchStatus: String?, | |||
| val matchQty: Int?, | |||
| val matchBy: String?, | |||
| val stages: List<LifecycleStage>, | |||
| ) | |||
| 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<LifecycleStage>, | |||
| val doLifecycle: DoLifecycleInfo?, | |||
| val joLifecycle: JoLifecycleInfo?, | |||
| ) | |||
| @@ -21,7 +21,10 @@ interface InventoryLotRepository: AbstractRepository<InventoryLot, Long> { | |||
| 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<InventoryLot> | |||
| fun findFirstByStockInLineIdAndDeletedFalseOrderByIdAsc(stockInLineId: Serializable): InventoryLot? | |||
| fun findByIdAndDeletedFalse(id: Serializable): InventoryLot? | |||
| } | |||
| @@ -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<Long>() { | |||
| @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 | |||
| } | |||
| @@ -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<ItemLotTraceLog, Long> | |||
| @@ -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) | |||
| } | |||
| } | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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() | |||
| } | |||
| @@ -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<Long, List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceOrigin>> = | |||
| 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<Long, List<com.ffii.fpsms.modules.stock.web.model.ItemLotTracePurchaseEvent>> = | |||
| if (allAltStockInLineIds.isNotEmpty()) { | |||
| inboundLoader.loadPurchaseEvents(allAltStockInLineIds) | |||
| .groupBy { it.purchaseOrderLineId } | |||
| } else emptyMap() | |||
| val altQcResultsBySilId: Map<Long, List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult>> = | |||
| 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<Long>, | |||
| movements: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMovement>, | |||
| ): List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceDoDelivery> { | |||
| 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<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMovement>, | |||
| doDeliveries: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceDoDelivery>, | |||
| ): List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMovement> { | |||
| 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<Long>? = null, | |||
| /** Pre-loaded from batch query — avoids per-location lotLineIds query. */ | |||
| preloadedLotLineIds: List<Long>? = null, | |||
| /** Pre-loaded from batch query — avoids per-location putaway query. */ | |||
| preloadedPutawayEvents: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTracePutawayEvent>? = null, | |||
| /** Pre-loaded from batch query — avoids per-location origins query. */ | |||
| preloadedOrigins: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceOrigin>? = null, | |||
| /** Pre-loaded from batch query — avoids per-location purchaseEvents query. */ | |||
| preloadedPurchaseEvents: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTracePurchaseEvent>? = null, | |||
| /** Pre-loaded from batch query — avoids per-location qcResults query. */ | |||
| preloadedQcResults: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult>? = 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, | |||
| ) | |||
| } | |||
| } | |||
| @@ -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<Long>, | |||
| ): 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, List<Long>>, | |||
| ): 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<Long>): 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<Long>): List<ItemLotTraceBomDownstream> { | |||
| 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<ItemLotTraceBomUpstream> { | |||
| 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<ItemLotTraceBomRecipeLine> { | |||
| val joId = getJobOrderIdForFgStockIn(primaryStockInLineId) | |||
| return if (joId != null) loadBomRecipeForJobOrder(joId) else emptyList() | |||
| } | |||
| open fun loadBomRecipeForJobOrder(jobOrderId: Long): List<ItemLotTraceBomRecipeLine> { | |||
| 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<String, BomStepAssignment> { | |||
| 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<String, BomStepAssignment>() | |||
| 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<Long, List<ItemLotTraceStepMaterialRef>> { | |||
| 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<Long, MutableList<ItemLotTraceStepMaterialRef>>() | |||
| 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"]) } | |||
| } | |||
| } | |||
| @@ -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<ItemLotTraceFailEvent> { | |||
| 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<ItemLotTraceLotRelation> { | |||
| 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}" } | |||
| } | |||
| } | |||
| @@ -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<TraceGraphNode> { | |||
| 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<TraceGraphNode> { it.sortKey }.thenBy { it.id }, | |||
| ) | |||
| } | |||
| internal fun materialInputsHaveProduction(inputs: List<ItemLotTraceMaterialInput>): Boolean = | |||
| inputs.any { m -> | |||
| m.productionSteps.isNotEmpty() || | |||
| (m.nestedJoPrelude?.materialInputs?.let { materialInputsHaveProduction(it) } == true) | |||
| } | |||
| private fun buildTraceGraphNodes(data: ItemLotTraceResponse): List<TraceGraphNode> { | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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<ItemLotTraceQcResult>, | |||
| seqStart: Int, | |||
| ): Pair<List<TraceGraphNode>, Int> { | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceProductionStep>, | |||
| byproductLots: List<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceByproductLot>, | |||
| lotUom: String, | |||
| ): List<TraceGraphNode> { | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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<TraceGraphNode> { | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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<Long> = mutableSetOf(), | |||
| val seenInboundLots: MutableSet<Long> = mutableSetOf(), | |||
| val seenReceiptLots: MutableSet<Long> = mutableSetOf(), | |||
| val seenQcKeys: MutableSet<String> = mutableSetOf(), | |||
| val seenPutawayKeys: MutableSet<String> = mutableSetOf(), | |||
| var seq: Int = 0, | |||
| ) | |||
| private data class LotPickRef( | |||
| val pickOrderCode: String, | |||
| val pickOrderId: Long?, | |||
| val consoCode: String, | |||
| ) | |||
| private fun buildJoPreludeGraphNodes(joPrelude: ItemLotTraceJoPrelude): List<TraceGraphNode> { | |||
| 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<TraceGraphNode> { it.sortKey }.thenBy { it.id }) | |||
| } | |||
| private fun buildLotPickOrderMap( | |||
| materialInputs: List<ItemLotTraceMaterialInput>, | |||
| ): Map<String, List<LotPickRef>> { | |||
| val map = mutableMapOf<String, MutableList<LotPickRef>>() | |||
| 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<String, MutableList<LotPickRef>>, | |||
| source: Map<String, List<LotPickRef>>, | |||
| ) { | |||
| 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<ItemLotTraceMaterialInput>, | |||
| lotPicks: Map<String, List<LotPickRef>>, | |||
| ctx: PreludeBuildContext, | |||
| ): List<TraceGraphNode> { | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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<TraceGraphNode> { | |||
| 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<String, List<LotPickRef>>, | |||
| ctx: PreludeBuildContext, | |||
| ): List<TraceGraphNode> { | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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<ItemLotTraceQcResult>): List<List<ItemLotTraceQcResult>> { | |||
| val map = linkedMapOf<String, MutableList<ItemLotTraceQcResult>>() | |||
| 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<com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock>, | |||
| ): List<TraceGraphNode> { | |||
| if (locationBlocks.isEmpty()) return emptyList() | |||
| val nodes = mutableListOf<TraceGraphNode>() | |||
| 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() } | |||
| } | |||
| } | |||
| @@ -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<ItemLotTraceOrigin> = | |||
| loadOriginsForLots(listOf(inventoryLotId)) | |||
| open fun loadOriginsForLots(inventoryLotIds: List<Long>): List<ItemLotTraceOrigin> { | |||
| 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<Long>): List<ItemLotTracePurchaseEvent> { | |||
| 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<String, Any?>): 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<Long>): List<ItemLotTraceQcResult> { | |||
| 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<ItemLotTracePutawayEvent> = | |||
| loadPutawayEventsByLotIds(listOf(inventoryLotId))[inventoryLotId].orEmpty() | |||
| open fun loadPutawayEventsByLotIds(lotIds: List<Long>): Map<Long, List<ItemLotTracePutawayEvent>> { | |||
| 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<Long, MutableList<ItemLotTracePutawayEvent>>() | |||
| val seen = mutableSetOf<String>() | |||
| fun addRow(row: Map<String, Any?>) { | |||
| 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<ItemLotTracePutawayEvent> { 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<Long, List<ItemLotTracePutawayEvent>>, | |||
| ): List<ItemLotTracePutawayEvent> { | |||
| 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, | |||
| ), | |||
| ) | |||
| } | |||
| } | |||
| @@ -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<ItemLotTraceJoPickOrder> { | |||
| 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<Long>, | |||
| ): List<ItemLotTraceMaterialInput> { | |||
| 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<ItemLotTraceOrigin> { 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<ItemLotTraceOrigin> { it.receiptDate ?: "9999-12-31" } | |||
| .thenBy { it.stockInLineId }, | |||
| ) | |||
| ?: origins.minWithOrNull( | |||
| compareBy<ItemLotTraceOrigin> { it.receiptDate ?: "9999-12-31" } | |||
| .thenBy { it.stockInLineId }, | |||
| ) | |||
| } | |||
| fun qcForLot(lotId: Long?): List<ItemLotTraceQcResult> { | |||
| if (lotId == null) return emptyList() | |||
| val silIds = silIdsByLot[lotId]?.toSet() ?: emptySet() | |||
| return allQc.filter { it.stockInLineId in silIds } | |||
| } | |||
| fun purchaseForLot(lotId: Long?): List<ItemLotTracePurchaseEvent> { | |||
| 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, | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -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<ItemLotTraceAlternateLocation> { | |||
| 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<Long> { | |||
| 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<Long> { | |||
| 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<ItemLotTraceWarehouseLine> { | |||
| 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<Long>): Map<Long, List<Long>> { | |||
| 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<Long, MutableList<Long>>() | |||
| 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<Long>): Map<Long, List<Long>> { | |||
| 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<Long, MutableList<Long>>() | |||
| 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<Long>): Map<Long, ItemLotTraceLotInfo> { | |||
| 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"]) } | |||
| } | |||
| } | |||
| @@ -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<Long>): List<ItemLotTraceMovement> { | |||
| 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<Long>): List<ItemLotTraceStockTakeEvent> { | |||
| 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<Long>): List<ItemLotTraceAdjustment> { | |||
| 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<ItemLotTraceTransfer> { | |||
| 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<ItemLotTraceOpenMovement> { | |||
| 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"]), | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -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<ItemLotTraceOutboundUsage> { | |||
| 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<String, Any?>): 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<ItemLotTraceDoDelivery> { | |||
| 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<String>): List<ItemLotTraceDoDelivery> { | |||
| 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<Map<String, Any?>>): List<ItemLotTraceDoDelivery> { | |||
| 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<String, Any?>, | |||
| 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<ItemLotTraceReplenishmentEvent> { | |||
| 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<String, Any?>): 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<Long>): List<ItemLotTraceReturnEvent> { | |||
| 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"]), | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -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<ItemLotTraceProductionStep> { | |||
| 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<ItemLotTraceByproductLot> { | |||
| 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}" } | |||
| } | |||
| } | |||
| @@ -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() } | |||
| } | |||
| } | |||
| @@ -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<Long>, | |||
| ) | |||
| /** | |||
| * 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<Long> { | |||
| 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<Long> { | |||
| val found = linkedSetOf<Long>() | |||
| 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<Long> { | |||
| 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<Long> { | |||
| 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<Long> { | |||
| 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<Long> { | |||
| val ids = linkedSetOf<Long>() | |||
| 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<Long>() | |||
| var current = tracedLotId | |||
| val visited = mutableSetOf<Long>() | |||
| 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 | |||
| } | |||
| } | |||
| @@ -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) | |||
| @@ -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<ItemLotTraceBomDownstream>, | |||
| val upstream: List<ItemLotTraceBomUpstream>, | |||
| val bomRecipe: List<ItemLotTraceBomRecipeLine>, | |||
| ) | |||
| 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<ItemLotTraceJoPickLine>, | |||
| ) | |||
| 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<ItemLotTracePurchaseEvent> = emptyList(), | |||
| val qcResults: List<ItemLotTraceQcResult>, | |||
| val putawayEvents: List<ItemLotTracePutawayEvent> = 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<ItemLotTraceProductionStep> = emptyList(), | |||
| ) | |||
| data class ItemLotTraceJoPrelude( | |||
| val jobOrder: ItemLotTraceJoContext, | |||
| val pickOrders: List<ItemLotTraceJoPickOrder>, | |||
| val materialInputs: List<ItemLotTraceMaterialInput>, | |||
| ) | |||
| 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<ItemLotTraceStepMaterialRef> = 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<ItemLotTraceWarehouseLine>, | |||
| val origins: List<ItemLotTraceOrigin>, | |||
| val purchaseEvents: List<ItemLotTracePurchaseEvent> = emptyList(), | |||
| val qcResults: List<ItemLotTraceQcResult> = emptyList(), | |||
| val putawayEvents: List<ItemLotTracePutawayEvent> = emptyList(), | |||
| val movements: List<ItemLotTraceMovement> = emptyList(), | |||
| val outboundUsage: List<ItemLotTraceOutboundUsage> = emptyList(), | |||
| val stockTakeEvents: List<ItemLotTraceStockTakeEvent> = emptyList(), | |||
| val adjustments: List<ItemLotTraceAdjustment> = emptyList(), | |||
| val transfers: List<ItemLotTraceTransfer> = emptyList(), | |||
| val openMovements: List<ItemLotTraceOpenMovement> = emptyList(), | |||
| val failEvents: List<ItemLotTraceFailEvent> = emptyList(), | |||
| val doDeliveries: List<ItemLotTraceDoDelivery> = emptyList(), | |||
| val replenishmentEvents: List<ItemLotTraceReplenishmentEvent> = emptyList(), | |||
| val returnEvents: List<ItemLotTraceReturnEvent> = 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<ItemLotTraceGraphNodeRef>, | |||
| val edges: List<ItemLotTraceGraphEdge>, | |||
| ) | |||
| /** | |||
| * 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<ItemLotTraceWarehouseLine>, | |||
| val origins: List<ItemLotTraceOrigin>, | |||
| val purchaseEvents: List<ItemLotTracePurchaseEvent> = emptyList(), | |||
| val qcResults: List<ItemLotTraceQcResult>, | |||
| val putawayEvents: List<ItemLotTracePutawayEvent>, | |||
| val movements: List<ItemLotTraceMovement>, | |||
| val outboundUsage: List<ItemLotTraceOutboundUsage>, | |||
| val stockTakeEvents: List<ItemLotTraceStockTakeEvent>, | |||
| val adjustments: List<ItemLotTraceAdjustment>, | |||
| val transfers: List<ItemLotTraceTransfer>, | |||
| val bomTrace: ItemLotTraceBomTrace, | |||
| val joPrelude: ItemLotTraceJoPrelude?, | |||
| val productionSteps: List<ItemLotTraceProductionStep>, | |||
| val byproductLots: List<ItemLotTraceByproductLot>, | |||
| val openMovements: List<ItemLotTraceOpenMovement>, | |||
| val failEvents: List<ItemLotTraceFailEvent>, | |||
| val doDeliveries: List<ItemLotTraceDoDelivery>, | |||
| val replenishmentEvents: List<ItemLotTraceReplenishmentEvent> = emptyList(), | |||
| val returnEvents: List<ItemLotTraceReturnEvent>, | |||
| val lotRelations: List<ItemLotTraceLotRelation>, | |||
| val alternateLocations: List<ItemLotTraceAlternateLocation> = 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<ItemLotTraceLocationBlock> = emptyList(), | |||
| val traceGraph: ItemLotTraceGraph? = null, | |||
| ) | |||
| @@ -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'); | |||
| @@ -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; | |||