| @@ -113,6 +113,10 @@ public class SecurityConfig { | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | .hasAnyAuthority("TESTING", "ADMIN") | ||||
| .requestMatchers(HttpMethod.POST, "/stockAdjustment/submit") | .requestMatchers(HttpMethod.POST, "/stockAdjustment/submit") | ||||
| .hasAnyAuthority("ADMIN", "INVENTORY_ADJUST") | .hasAnyAuthority("ADMIN", "INVENTORY_ADJUST") | ||||
| .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace") | |||||
| .hasAuthority("ITEM_TRACING") | |||||
| .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**") | |||||
| .hasAuthority("ITEM_TRACING") | |||||
| .anyRequest().authenticated()) | .anyRequest().authenticated()) | ||||
| .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | ||||
| (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | (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 findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds: Collection<Long>): List<PickOrder> | ||||
| fun findTopByJobOrder_IdOrderByCreatedDesc(jobOrderId: Long): PickOrder? | fun findTopByJobOrder_IdOrderByCreatedDesc(jobOrderId: Long): PickOrder? | ||||
| fun findAllByStatusIn(statuses: List<PickOrderStatus>): List<PickOrder> | fun findAllByStatusIn(statuses: List<PickOrderStatus>): List<PickOrder> | ||||
| fun findByCodeAndDeletedFalse(@Param("code") code: String): PickOrder? | |||||
| @Query( | @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 | WHERE il.lotNo = :lotNo | ||||
| AND il.item.id = :itemId | AND il.item.id = :itemId | ||||
| AND il.deleted = false | 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? | 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.stockInLine = stockInLine | ||||
| this.failQty = it.failQty | this.failQty = it.failQty | ||||
| this.qcPassed = it.qcPassed | 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.remarks = it.remarks | ||||
| this.escalationLog = escalationLogService.find(escLogId).getOrNull() | 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.InventoryLotLineInfo | ||||
| import com.ffii.fpsms.modules.stock.entity.projection.LotLineToQrcode | import com.ffii.fpsms.modules.stock.entity.projection.LotLineToQrcode | ||||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | 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.service.StockInLineService | ||||
| import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest | import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.LotLineInfo | 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.UpdateInventoryLotLineStatusRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest | 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.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 com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse | ||||
| import org.springframework.http.HttpStatus | import org.springframework.http.HttpStatus | ||||
| import org.springframework.web.server.ResponseStatusException | import org.springframework.web.server.ResponseStatusException | ||||
| @@ -39,6 +42,7 @@ class InventoryLotLineController ( | |||||
| private val inventoryLotLineService: InventoryLotLineService, | private val inventoryLotLineService: InventoryLotLineService, | ||||
| private val stockInLineRepository: StockInLineRepository, | private val stockInLineRepository: StockInLineRepository, | ||||
| private val stockInLineService: StockInLineService, | private val stockInLineService: StockInLineService, | ||||
| private val itemLotTraceService: ItemLotTraceService, | |||||
| ){ | ){ | ||||
| // @PostMapping("/test") | // @PostMapping("/test") | ||||
| @@ -150,6 +154,28 @@ class InventoryLotLineController ( | |||||
| fun updateInventoryLotLineQuantities(@RequestBody request: UpdateInventoryLotLineQuantitiesRequest): MessageResponse { | fun updateInventoryLotLineQuantities(@RequestBody request: UpdateInventoryLotLineQuantitiesRequest): MessageResponse { | ||||
| return inventoryLotLineService.updateInventoryLotLineQuantities(request) | 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") | @PostMapping("/analyze-qr-code") | ||||
| fun analyzeQrCode(@RequestBody request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { | fun analyzeQrCode(@RequestBody request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { | ||||
| return inventoryLotLineService.analyzeQrCode(request) | 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; | |||||