tommy 18 години тому
джерело
коміт
7ef9ef8272
28 змінених файлів з 6049 додано та 2 видалено
  1. +4
    -0
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  2. +1
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt
  3. +284
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderLifecycleService.kt
  4. +28
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderLifecycleController.kt
  5. +50
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/PickOrderLifecycleResponse.kt
  6. +4
    -1
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotRepository.kt
  7. +36
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLog.kt
  8. +7
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLogRepository.kt
  9. +60
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceLogService.kt
  10. +48
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceService.kt
  11. +5
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  12. +317
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt
  13. +308
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt
  14. +112
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceExtendedLoader.kt
  15. +855
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt
  16. +1110
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt
  17. +369
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt
  18. +357
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt
  19. +319
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt
  20. +350
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt
  21. +410
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt
  22. +120
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt
  23. +61
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceSqlSupport.kt
  24. +246
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceUpstreamLoader.kt
  25. +26
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt
  26. +530
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt
  27. +8
    -0
      src/main/resources/db/changelog/changes/20260716_item_tracing/01_item_tracing_authority.sql
  28. +24
    -0
      src/main/resources/db/changelog/changes/20260716_item_tracing/02_item_lot_trace_log.sql

+ 4
- 0
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java Переглянути файл

@@ -113,6 +113,10 @@ public class SecurityConfig {
.hasAnyAuthority("TESTING", "ADMIN")
.requestMatchers(HttpMethod.POST, "/stockAdjustment/submit")
.hasAnyAuthority("ADMIN", "INVENTORY_ADJUST")
.requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace")
.hasAuthority("ITEM_TRACING")
.requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**")
.hasAuthority("ITEM_TRACING")
.anyRequest().authenticated())
.httpBasic(httpBasic -> httpBasic.authenticationEntryPoint(
(request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED")))


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt Переглянути файл

@@ -100,6 +100,7 @@ fun findAllByJobOrder_Id(jobOrderId: Long): List<PickOrder>
fun findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds: Collection<Long>): List<PickOrder>
fun findTopByJobOrder_IdOrderByCreatedDesc(jobOrderId: Long): PickOrder?
fun findAllByStatusIn(statuses: List<PickOrderStatus>): List<PickOrder>
fun findByCodeAndDeletedFalse(@Param("code") code: String): PickOrder?

@Query(
"""


+ 284
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderLifecycleService.kt Переглянути файл

@@ -0,0 +1,284 @@
package com.ffii.fpsms.modules.pickOrder.service

import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.enums.DoPickOrderStatus
import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRecordRepository
import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository
import com.ffii.fpsms.modules.jobOrder.enums.JoPickOrderStatus
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderGroupRepository
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository
import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus
import com.ffii.fpsms.modules.pickOrder.enums.PickOrderType
import com.ffii.fpsms.modules.pickOrder.web.models.*
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.format.DateTimeFormatter

@Service
@Transactional(readOnly = true)
open class PickOrderLifecycleService(
private val pickOrderRepository: PickOrderRepository,
private val pickOrderGroupRepository: PickOrderGroupRepository,
private val doPickOrderRepository: DoPickOrderRepository,
private val doPickOrderRecordRepository: DoPickOrderRecordRepository,
private val doPickOrderLineRepository: DoPickOrderLineRepository,
private val joPickOrderRepository: JoPickOrderRepository,
private val joPickOrderRecordRepository: JoPickOrderRecordRepository,
) {
private val dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

/** FP-MTMS Version Checklist | Functions Ref. No. 10 | v1.0.1 | 2026-07-14 */
open fun getLifecycle(pickOrderId: Long): PickOrderLifecycleResponse? {
val po = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return null

val groups = po.id?.let { pickOrderGroupRepository.findByPickOrderIdAndDeletedIsFalse(it) } ?: emptyList()
val groupName = groups.firstOrNull()?.name

val stages = buildPickOrderStages(po)
val doLifecycle = if (po.type == PickOrderType.DELIVERY_ORDER) buildDoLifecycle(pickOrderId) else null
val joLifecycle = if (po.type == PickOrderType.JOB_ORDER) buildJoLifecycle(pickOrderId) else null

return PickOrderLifecycleResponse(
pickOrderId = po.id!!,
pickOrderCode = po.code ?: "",
pickOrderType = po.type?.value ?: "",
currentStatus = po.status?.value ?: "",
targetDate = po.targetDate?.format(dtf),
completeDate = po.completeDate?.format(dtf),
consoCode = po.consoCode,
groupName = groupName,
totalLines = po.totalLines,
submittedLines = po.submittedLines,
stages = stages,
doLifecycle = doLifecycle,
joLifecycle = joLifecycle,
)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 10 | v1.0.1 | 2026-07-14 */
open fun getLifecycleByCode(code: String): PickOrderLifecycleResponse? {
val po = pickOrderRepository.findByCodeAndDeletedFalse(code) ?: return null
return po.id?.let { getLifecycle(it) }
}

private fun buildPickOrderStages(po: com.ffii.fpsms.modules.pickOrder.entity.PickOrder): List<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,
)
}
}
}

+ 28
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderLifecycleController.kt Переглянути файл

@@ -0,0 +1,28 @@
package com.ffii.fpsms.modules.pickOrder.web

import com.ffii.fpsms.modules.pickOrder.service.PickOrderLifecycleService
import com.ffii.fpsms.modules.pickOrder.web.models.PickOrderLifecycleResponse
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/pickOrder")
class PickOrderLifecycleController(
private val lifecycleService: PickOrderLifecycleService,
) {
/** FP-MTMS Version Checklist | Functions Ref. No. 10 | v1.0.1 | 2026-07-14 */
@GetMapping("/{id}/lifecycle")
fun getLifecycle(@PathVariable id: Long): ResponseEntity<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()
}
}

+ 50
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/PickOrderLifecycleResponse.kt Переглянути файл

@@ -0,0 +1,50 @@
package com.ffii.fpsms.modules.pickOrder.web.models

data class LifecycleStage(
val status: String,
val label: String,
val reached: Boolean,
val timestamp: String?,
val actor: String?,
val actorId: Long?,
val isCurrent: Boolean,
val details: Map<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?,
)

+ 4
- 1
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotRepository.kt Переглянути файл

@@ -21,7 +21,10 @@ interface InventoryLotRepository: AbstractRepository<InventoryLot, Long> {
WHERE il.lotNo = :lotNo
AND il.item.id = :itemId
AND il.deleted = false
ORDER BY il.id ASC
""")
fun findByLotNoAndItemId(lotNo: String, itemId: Long): InventoryLot?
fun findAllByLotNoAndItemIdAndDeletedFalseOrderByIdAsc(lotNo: String, itemId: Long): List<InventoryLot>

fun findFirstByStockInLineIdAndDeletedFalseOrderByIdAsc(stockInLineId: Serializable): InventoryLot?
fun findByIdAndDeletedFalse(id: Serializable): InventoryLot?
}

+ 36
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLog.kt Переглянути файл

@@ -0,0 +1,36 @@
package com.ffii.fpsms.modules.stock.entity

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size

@Entity
@Table(name = "item_lot_trace_log")
open class ItemLotTraceLog : BaseEntity<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
}

+ 7
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/ItemLotTraceLogRepository.kt Переглянути файл

@@ -0,0 +1,7 @@
package com.ffii.fpsms.modules.stock.entity

import com.ffii.core.support.AbstractRepository
import org.springframework.stereotype.Repository

@Repository
interface ItemLotTraceLogRepository : AbstractRepository<ItemLotTraceLog, Long>

+ 60
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceLogService.kt Переглянути файл

@@ -0,0 +1,60 @@
package com.ffii.fpsms.modules.stock.service

import com.ffii.fpsms.modules.stock.entity.ItemLotTraceLog
import com.ffii.fpsms.modules.stock.entity.ItemLotTraceLogRepository
import com.ffii.fpsms.modules.user.entity.User
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Isolation
import org.springframework.transaction.annotation.Transactional

@Service
open class ItemLotTraceLogService(
private val itemLotTraceLogRepository: ItemLotTraceLogRepository,
) {
private val log = LoggerFactory.getLogger(javaClass)

@Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = [Exception::class], readOnly = false)
open fun insert(
user: User,
inventoryLotId: Long?,
lotNo: String?,
itemId: Long?,
itemCode: String?,
) {
val entity = ItemLotTraceLog().apply {
this.userId = user.id
this.username = user.username
this.inventoryLotId = inventoryLotId
this.lotNo = lotNo?.takeIf { it.isNotBlank() }
this.itemId = itemId
this.itemCode = itemCode?.takeIf { it.isNotBlank() }
}
itemLotTraceLogRepository.save(entity)
}

/** Best-effort audit write; never fails the main trace. */
open fun insertQuietly(
user: User?,
inventoryLotId: Long?,
lotNo: String?,
itemId: Long?,
itemCode: String?,
) {
if (user?.id == null) {
log.warn("Skip item_lot_trace_log insert: no authenticated user")
return
}
try {
insert(
user = user,
inventoryLotId = inventoryLotId,
lotNo = lotNo,
itemId = itemId,
itemCode = itemCode,
)
} catch (e: Exception) {
log.warn("Failed to insert item_lot_trace_log: {}", e.message, e)
}
}
}

+ 48
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/ItemLotTraceService.kt Переглянути файл

@@ -0,0 +1,48 @@
package com.ffii.fpsms.modules.stock.service

import com.ffii.fpsms.modules.common.SecurityUtils
import com.ffii.fpsms.modules.stock.trace.ItemLotTraceOrchestrator
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse
import org.springframework.stereotype.Service

@Service
open class ItemLotTraceService(
private val orchestrator: ItemLotTraceOrchestrator,
private val itemLotTraceLogService: ItemLotTraceLogService,
) {
/** FP-MTMS Version Checklist | Functions Ref. No. 7 | v1.0.0 | 2026-07-14 */
open fun trace(
stockInLineId: Long?,
inventoryLotLineId: Long?,
lotNo: String?,
itemId: Long?,
itemCode: String?,
): ItemLotTraceResponse {
val response = orchestrator.trace(
stockInLineId,
inventoryLotLineId,
lotNo,
itemId,
itemCode,
)
val lot = response.lot
itemLotTraceLogService.insertQuietly(
user = SecurityUtils.getUser().orElse(null),
inventoryLotId = lot.inventoryLotId,
lotNo = lot.lotNo,
itemId = lot.itemId,
itemCode = lot.itemCode,
)
return response
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 7 | v1.0.0 | 2026-07-14
* Lazy-load endpoint: returns the full location-scoped trace block for a single inventory lot.
* Lot-level dimensions (BOM, JO prelude, production, byproducts) are excluded — those are only
* available via the main /trace endpoint.
*/
open fun traceLocation(inventoryLotId: Long): ItemLotTraceLocationBlock =
orchestrator.traceLocation(inventoryLotId)
}

+ 5
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt Переглянути файл

@@ -581,7 +581,11 @@ open class StockInLineService(
this.stockInLine = stockInLine
this.failQty = it.failQty
this.qcPassed = it.qcPassed
this.type = "qc"
this.type = when {
stockInLine.jobOrder != null -> "EPQC"
stockInLine.purchaseOrderLine != null -> "IQC"
else -> "IQC"
}
this.remarks = it.remarks
this.escalationLog = escalationLogService.find(escLogId).getOrNull()
}


+ 317
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt Переглянути файл

@@ -0,0 +1,317 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.fpsms.modules.master.entity.ItemsRepository
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException

@Component
open class ItemLotTraceOrchestrator(
private val lotResolver: TraceLotResolver,
private val inboundLoader: TraceInboundLoader,
private val movementLoader: TraceMovementLoader,
private val outboundLoader: TraceOutboundLoader,
private val bomLoader: TraceBomLoader,
private val productionLoader: TraceProductionLoader,
private val joPreludeLoader: TraceJoPreludeLoader,
private val extendedLoader: TraceExtendedLoader,
private val upstreamLoader: TraceUpstreamLoader,
private val itemsRepository: ItemsRepository,
private val traceGraphSemanticsBuilder: TraceGraphSemanticsBuilder,
) {
open fun trace(
stockInLineId: Long?,
inventoryLotLineId: Long?,
lotNo: String?,
itemId: Long?,
itemCode: String?,
): ItemLotTraceResponse {
val resolvedItemId = itemId ?: itemCode?.trim()?.takeIf { it.isNotEmpty() }?.let { code ->
itemsRepository.findByCodeAndDeletedFalse(code)?.id
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Item code not found: $code")
}

val inventoryLotId = lotResolver.resolveInventoryLotId(
stockInLineId,
inventoryLotLineId,
lotNo,
resolvedItemId,
)
val lot = lotResolver.loadLotInfo(inventoryLotId)
val lotLineIds = lotResolver.loadLotLineIds(inventoryLotId)
val stockInLineIds = lotResolver.loadStockInLineIds(inventoryLotId)

val joProvenance = upstreamLoader.resolveJoProvenance(
inventoryLotId,
itemId = lot.itemId,
lotNo = lot.lotNo,
)

val effectiveJoSilId = lot.primaryStockInLineId?.takeIf { bomLoader.hasJoStockIn(it) }
?: joProvenance?.joStockInLineId
val joId = lotResolver.resolveJobOrderIdForLot(inventoryLotId, lot.primaryStockInLineId)
?: joProvenance?.jobOrderId

val warehouseLines = lotResolver.loadWarehouseLines(inventoryLotId)
val alternateLocations = lotResolver.loadAlternateLocations(lot.itemId, lot.lotNo, inventoryLotId)
val altLotIdSet = alternateLocations.map { it.inventoryLotId }.toSet()

// Transfer-chain / JO upstream lots whose PO/QC/putaway should surface on the
// destination lot when its own stock-in is only TRANSFER (helpers were unused).
// Sibling same-lotNo lots already arrive via locationBlocks — exclude them here
// to avoid duplicating rows in merged tabs.
val upstreamMergeLotIds = (
upstreamLoader.resolveUpstreamLotIds(inventoryLotId, lot.itemId, lot.lotNo) +
(joProvenance?.upstreamLotIds.orEmpty())
)
.distinct()
.filter { it != inventoryLotId && it !in altLotIdSet }

val origins = inboundLoader.loadOriginsForLots(listOf(inventoryLotId) + upstreamMergeLotIds)
.distinctBy { it.stockInLineId }
val upstreamStockInLineIds = if (upstreamMergeLotIds.isNotEmpty()) {
lotResolver.loadStockInLineIdsForLots(upstreamMergeLotIds).values.flatten().distinct()
} else {
emptyList()
}
val mergedStockInLineIds = (stockInLineIds + upstreamStockInLineIds).distinct()
val purchaseEvents = inboundLoader.loadPurchaseEvents(mergedStockInLineIds)
.distinctBy { it.purchaseOrderLineId }
val qcResults = inboundLoader.loadQcResults(mergedStockInLineIds)
.distinctBy { "${it.stockInLineId}|${it.qcItemCode}|${it.qcType}|${it.created}" }
val putawayEvents = (
inboundLoader.loadPutawayEvents(inventoryLotId) +
upstreamMergeLotIds.flatMap { inboundLoader.loadPutawayEvents(it) }
)
.distinctBy { "${it.inventoryLotLineId}|${it.timestamp}|${it.refCode}|${it.qty}" }
val movements = movementLoader.loadMovements(inventoryLotId, lotLineIds)
val outboundUsage = outboundLoader.loadOutboundUsage(inventoryLotId)
val stockTakeEvents = movementLoader.loadStockTakeEvents(lotLineIds)
val adjustments = movementLoader.loadAdjustments(inventoryLotId, lotLineIds)
val transfers = movementLoader.loadTransfers(inventoryLotId)
val bomTrace = bomLoader.loadBomTrace(
inventoryLotId,
effectiveJoSilId ?: lot.primaryStockInLineId,
lot.itemId,
lotLineIds,
)
val joPrelude = effectiveJoSilId?.let { silId ->
if (bomLoader.hasJoStockIn(silId)) joPreludeLoader.loadJoPrelude(silId) else null
}
val productionSteps = joId?.let { productionLoader.loadProductionSteps(it) } ?: emptyList()
val byproductLots = joId?.let { productionLoader.loadByproductLots(it, lot.itemId) } ?: emptyList()
val openMovements = movementLoader.loadOpenMovements(inventoryLotId)
val failEvents = extendedLoader.loadFailEvents(inventoryLotId)
val rawMovements = movements.sortedByDescending { it.timestamp ?: "" }
val doDeliveries = resolveDoDeliveries(listOf(inventoryLotId), rawMovements)
val movementsWithDoMeta = enrichDoMovements(rawMovements, doDeliveries)
val returnEvents = outboundLoader.loadReturnEvents(inventoryLotId, lotLineIds)
val replenishmentEvents = outboundLoader.loadReplenishmentEvents(inventoryLotId)
val lotRelations = extendedLoader.loadLotRelations(inventoryLotId, lot.itemId, lot.lotNo)

// Batch pre-load alternate location data to avoid N+1 queries
val altLotIds = alternateLocations.map { it.inventoryLotId }
val altLotInfoByLotId = if (altLotIds.isNotEmpty()) lotResolver.loadLotInfoBatch(altLotIds) else emptyMap()
val altStockInLineIdsByLotId = if (altLotIds.isNotEmpty()) lotResolver.loadStockInLineIdsForLots(altLotIds) else emptyMap()
val altLotLineIdsByLotId = if (altLotIds.isNotEmpty()) lotResolver.loadLotLineIdsForLots(altLotIds) else emptyMap()
val altPutawayByLotId = if (altLotIds.isNotEmpty()) inboundLoader.loadPutawayEventsByLotIds(altLotIds) else emptyMap()

// Batch pre-load origins, purchase events, and QC results across ALL alternate locations
val altOriginsByLotId: Map<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,
)
}
}

+ 308
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt Переглянути файл

@@ -0,0 +1,308 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component

data class BomStepAssignment(
val bomProcessId: Long,
val bomProcessSeqNo: Long,
val stepName: String,
)

@Component
open class TraceBomLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
private val lotResolver: TraceLotResolver,
) {
open fun itemCodeKey(code: String): String = code.trim().uppercase()

open fun loadBomTrace(
inventoryLotId: Long,
primaryStockInLineId: Long?,
itemId: Long,
lotLineIds: List<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"]) }
}
}

+ 112
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceExtendedLoader.kt Переглянути файл

@@ -0,0 +1,112 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component

@Component
open class TraceExtendedLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
) {
open fun loadFailEvents(inventoryLotId: Long): List<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}" }
}
}

+ 855
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt Переглянути файл

@@ -0,0 +1,855 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceJoPrelude
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMaterialInput
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

internal data class TraceGraphNode(
val id: String,
val kind: String,
val timestamp: String?,
val sortKey: Long,
val refCode: String? = null,
val refId: Long? = null,
val traceLotNo: String? = null,
val traceItemCode: String? = null,
val consoCode: String? = null,
val jobOrderCode: String? = null,
val bomProcessId: Long? = null,
val bomProcessSeqNo: Long? = null,
val feedsProductionScopeLotNo: String? = null,
var doGroupId: String? = null,
val refType: String? = null,
val warehouseCode: String? = null,
val transferFromWarehouse: String? = null,
val transferToWarehouse: String? = null,
val replenishmentStockOutLineId: Long? = null,
)

internal object TraceGraphNodeBuilder {
private val MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS = setOf("TRANSFER", "ADJ", "OPEN", "TKE")
private val DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
private val DATE_ONLY = DateTimeFormatter.ISO_LOCAL_DATE

fun buildNodes(data: ItemLotTraceResponse): List<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() }
}
}

+ 1110
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt
Різницю між файлами не показано, бо вона завелика
Переглянути файл


+ 369
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt Переглянути файл

@@ -0,0 +1,369 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component

@Component
open class TraceInboundLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
) {
open fun loadOrigins(inventoryLotId: Long): List<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,
),
)
}
}

+ 357
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt Переглянути файл

@@ -0,0 +1,357 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component
import java.math.BigDecimal

data class MaterialInputRow(
val jobOrderCode: String,
val jobOrderId: Long?,
val materialItemCode: String,
val materialItemName: String,
val materialLotNo: String,
val materialStockInLineId: Long?,
val materialInventoryLotId: Long?,
val pickedInventoryLotLineId: Long?,
val pickedWarehouseCode: String,
val materialQty: BigDecimal,
val materialUom: String,
val bomQtyPerUnit: BigDecimal?,
val pickOrderCode: String,
val pickOrderId: Long?,
val consoCode: String,
val pickedAt: String?,
val processingStatus: String,
val matchStatus: String,
)

@Component
open class TraceJoPreludeLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
private val bomLoader: TraceBomLoader,
private val productionLoader: TraceProductionLoader,
private val inboundLoader: TraceInboundLoader,
private val lotResolver: TraceLotResolver,
) {
open fun loadJoPrelude(primaryStockInLineId: Long): ItemLotTraceJoPrelude? {
val joId = bomLoader.getJobOrderIdForFgStockIn(primaryStockInLineId) ?: return null
val jobOrder = loadJoContext(joId) ?: return null
val pickOrders = loadJoPickOrders(joId)
val materialInputs = loadJoMaterialInputs(primaryStockInLineId, mutableSetOf())
return ItemLotTraceJoPrelude(
jobOrder = jobOrder,
pickOrders = pickOrders,
materialInputs = materialInputs,
)
}

open fun loadJoContext(jobOrderId: Long): ItemLotTraceJoContext? {
val query = """
SELECT
jo.id AS jobOrderId,
COALESCE(jo.code, '') AS jobOrderCode,
jo.planStart AS planStart,
(
SELECT MIN(jpo.created)
FROM pick_order po
INNER JOIN jo_pick_order jpo
ON jpo.pick_order_id = po.id AND jpo.deleted = 0
WHERE po.joId = jo.id AND po.deleted = 0
) AS createdAt,
COALESCE(jo.reqQty, 0) AS reqQty,
COALESCE(jo.status, '') AS status
FROM job_order jo
WHERE jo.id = :jobOrderId AND jo.deleted = 0
""".trimIndent()
val row = jdbcDao.queryForList(query, mapOf("jobOrderId" to jobOrderId)).firstOrNull() ?: return null
return ItemLotTraceJoContext(
jobOrderId = sql.longVal(row["jobOrderId"]) ?: jobOrderId,
jobOrderCode = sql.str(row["jobOrderCode"]),
planStart = sql.formatDateTime(row["planStart"]),
createdAt = sql.formatDateTime(row["createdAt"]),
reqQty = sql.decimal(row["reqQty"]),
status = sql.str(row["status"]),
)
}

open fun loadJoPickOrders(jobOrderId: Long): List<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,
)
}
}
}

+ 319
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt Переглянути файл

@@ -0,0 +1,319 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository
import com.ffii.fpsms.modules.stock.entity.InventoryLotRepository
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException

@Component
open class TraceLotResolver(
private val jdbcDao: JdbcDao,
private val inventoryLotRepository: InventoryLotRepository,
private val inventoryLotLineRepository: InventoryLotLineRepository,
private val stockInLineRepository: StockInLineRepository,
private val sql: TraceSqlSupport,
) {
open fun resolveInventoryLotId(
stockInLineId: Long?,
inventoryLotLineId: Long?,
lotNo: String?,
itemId: Long?,
): Long {
stockInLineId?.let { silId ->
val sil = stockInLineRepository.findById(silId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "stockInLineId $silId not found")
}
val lotId = sil.inventoryLot?.id
?: inventoryLotRepository.findFirstByStockInLineIdAndDeletedFalseOrderByIdAsc(silId)?.id
return lotId ?: throw ResponseStatusException(
HttpStatus.NOT_FOUND,
"No inventory lot for stockInLineId $silId",
)
}

inventoryLotLineId?.let { illId ->
val ill = inventoryLotLineRepository.findById(illId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "inventoryLotLineId $illId not found")
}
return ill.inventoryLot?.id
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "No inventory lot for inventoryLotLineId $illId")
}

if (!lotNo.isNullOrBlank() && itemId != null) {
val trimmed = lotNo.trim()
val inventoryLotId = resolvePrimaryInventoryLotIdByLotNo(trimmed, itemId)
?: throw ResponseStatusException(
HttpStatus.NOT_FOUND,
"Lot not found: lotNo=$trimmed itemId=$itemId",
)
return inventoryLotId
}

throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Provide stockInLineId, inventoryLotLineId, or lotNo+itemId/itemCode",
)
}

/** lotNo+itemId is not unique — pick the lot with the most available qty (tie: lowest id). */
open fun resolvePrimaryInventoryLotIdByLotNo(lotNo: String, itemId: Long): Long? {
val query = """
SELECT il.id AS inventoryLotId
FROM inventory_lot il
LEFT JOIN inventory_lot_line ill ON ill.inventoryLotId = il.id AND ill.deleted = 0
WHERE il.deleted = 0
AND il.itemId = :itemId
AND il.lotNo = :lotNo
GROUP BY il.id
ORDER BY COALESCE(SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)), 0) DESC, il.id ASC
LIMIT 1
""".trimIndent()
return jdbcDao.queryForList(query, mapOf("itemId" to itemId, "lotNo" to lotNo))
.firstOrNull()
?.let { sql.longVal(it["inventoryLotId"]) }
}

open fun loadAlternateLocations(
itemId: Long,
lotNo: String,
currentInventoryLotId: Long,
): List<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"]) }
}
}

+ 350
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt Переглянути файл

@@ -0,0 +1,350 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component

@Component
open class TraceMovementLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
) {
open fun loadMovements(inventoryLotId: Long, lotLineIds: List<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"]),
)
}
}
}

+ 410
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt Переглянути файл

@@ -0,0 +1,410 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService
import com.ffii.fpsms.modules.deliveryOrder.service.DoReplenishmentService
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component

@Component
open class TraceOutboundLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
private val deliveryOrderService: DeliveryOrderService,
private val doReplenishmentService: DoReplenishmentService,
) {
open fun loadOutboundUsage(inventoryLotId: Long): List<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"]),
)
}
}
}

+ 120
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt Переглянути файл

@@ -0,0 +1,120 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.stock.web.model.*
import org.springframework.stereotype.Component

@Component
open class TraceProductionLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
private val bomLoader: TraceBomLoader,
) {
open fun loadProductionSteps(jobOrderId: Long): List<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}" }
}
}

+ 61
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceSqlSupport.kt Переглянути файл

@@ -0,0 +1,61 @@
package com.ffii.fpsms.modules.stock.trace

import org.springframework.stereotype.Component
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

@Component
open class TraceSqlSupport {
val dateFmt: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE
val dateTimeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

open fun str(v: Any?): String = v?.toString()?.trim() ?: ""

open fun strOrNull(v: Any?): String? = v?.toString()?.trim()?.ifBlank { null }

open fun longVal(v: Any?): Long? = when (v) {
null -> null
is Number -> v.toLong()
else -> v.toString().toLongOrNull()
}

open fun intVal(v: Any?): Int? = when (v) {
null -> null
is Number -> v.toInt()
else -> v.toString().toIntOrNull()
}

open fun decimal(v: Any?): BigDecimal = when (v) {
null -> BigDecimal.ZERO
is BigDecimal -> v
is Number -> BigDecimal.valueOf(v.toDouble())
else -> v.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO
}

open fun decimalOrNull(v: Any?): BigDecimal? = when (v) {
null -> null
is BigDecimal -> v
is Number -> BigDecimal.valueOf(v.toDouble())
else -> v.toString().toBigDecimalOrNull()
}

open fun formatDate(v: Any?): String? = when (v) {
null -> null
is LocalDate -> v.format(dateFmt)
is java.sql.Date -> v.toLocalDate().format(dateFmt)
is LocalDateTime -> v.toLocalDate().format(dateFmt)
is java.sql.Timestamp -> v.toLocalDateTime().toLocalDate().format(dateFmt)
else -> v.toString().takeIf { it.isNotBlank() }
}

open fun formatDateTime(v: Any?): String? = when (v) {
null -> null
is LocalDateTime -> v.format(dateTimeFmt)
is java.sql.Timestamp -> v.toLocalDateTime().format(dateTimeFmt)
is LocalDate -> v.atStartOfDay().format(dateTimeFmt)
is java.sql.Date -> v.toLocalDate().atStartOfDay().format(dateTimeFmt)
else -> v.toString().takeIf { it.isNotBlank() }
}
}

+ 246
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceUpstreamLoader.kt Переглянути файл

@@ -0,0 +1,246 @@
package com.ffii.fpsms.modules.stock.trace

import com.ffii.core.support.JdbcDao
import org.springframework.stereotype.Component

/**
* Resolves upstream inventory lots for trace merge after warehouse transfer / lot split.
* Merges PO, JO, QC, putaway history from source lots into the traced (destination) lot view
* via [ItemLotTraceOrchestrator] calling [resolveUpstreamLotIds] (siblings with the same
* lotNo are left to alternateLocations / locationBlocks).
*/
@Component
open class TraceUpstreamLoader(
private val jdbcDao: JdbcDao,
private val sql: TraceSqlSupport,
) {
data class JoProvenance(
val jobOrderId: Long,
val joStockInLineId: Long,
val upstreamLotIds: List<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
}
}

+ 26
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt Переглянути файл

@@ -7,6 +7,7 @@ import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import com.ffii.fpsms.modules.stock.entity.projection.InventoryLotLineInfo
import com.ffii.fpsms.modules.stock.entity.projection.LotLineToQrcode
import com.ffii.fpsms.modules.stock.service.InventoryLotLineService
import com.ffii.fpsms.modules.stock.service.ItemLotTraceService
import com.ffii.fpsms.modules.stock.service.StockInLineService
import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest
import com.ffii.fpsms.modules.stock.web.model.LotLineInfo
@@ -28,6 +29,8 @@ import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineReque
import com.ffii.fpsms.modules.stock.web.model.UpdateInventoryLotLineStatusRequest
import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest
import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceResponse
import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceLocationBlock
import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
@@ -39,6 +42,7 @@ class InventoryLotLineController (
private val inventoryLotLineService: InventoryLotLineService,
private val stockInLineRepository: StockInLineRepository,
private val stockInLineService: StockInLineService,
private val itemLotTraceService: ItemLotTraceService,

){
// @PostMapping("/test")
@@ -150,6 +154,28 @@ class InventoryLotLineController (
fun updateInventoryLotLineQuantities(@RequestBody request: UpdateInventoryLotLineQuantitiesRequest): MessageResponse {
return inventoryLotLineService.updateInventoryLotLineQuantities(request)
}
@GetMapping("/trace")
fun traceLot(
@RequestParam(required = false) stockInLineId: Long?,
@RequestParam(required = false) inventoryLotLineId: Long?,
@RequestParam(required = false) lotNo: String?,
@RequestParam(required = false) itemId: Long?,
@RequestParam(required = false) itemCode: String?,
): ItemLotTraceResponse {
return itemLotTraceService.trace(stockInLineId, inventoryLotLineId, lotNo, itemId, itemCode)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 7 | v1.0.0 | 2026-07-14
* Lazy-load: single location trace for an alternate inventory lot.
* Returns location-scoped trace data (movements, origins, QC, outbound, stock take, etc.)
* without the lot-level fields (BOM, JO prelude, production steps, byproducts).
*/
@GetMapping("/trace/location/{inventoryLotId}")
fun traceLocation(@PathVariable inventoryLotId: Long): ItemLotTraceLocationBlock {
return itemLotTraceService.traceLocation(inventoryLotId)
}

@PostMapping("/analyze-qr-code")
fun analyzeQrCode(@RequestBody request: QrCodeAnalysisRequest): QrCodeAnalysisResponse {
return inventoryLotLineService.analyzeQrCode(request)


+ 530
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt Переглянути файл

@@ -0,0 +1,530 @@
package com.ffii.fpsms.modules.stock.web.model

import com.fasterxml.jackson.annotation.JsonProperty
import java.math.BigDecimal

data class ItemLotTraceLotInfo(
val inventoryLotId: Long,
val lotNo: String,
val itemId: Long,
val itemCode: String,
val itemName: String,
val expiryDate: String?,
val productionDate: String?,
val stockInDate: String?,
val uom: String,
val primaryStockInLineId: Long?,
)

data class ItemLotTraceWarehouseLine(
val inventoryLotLineId: Long,
val warehouseCode: String,
val inQty: BigDecimal,
val outQty: BigDecimal,
val issueQty: BigDecimal,
val status: String,
val availableQty: BigDecimal,
)

data class ItemLotTraceOrigin(
val stockInLineId: Long,
val type: String,
val refCode: String,
val refId: Long?,
val supplierCode: String,
val supplierName: String,
val dnNo: String,
val receiptDate: String?,
val acceptedQty: BigDecimal,
val status: String,
)

/** Purchase order line context before goods receipt (採購). */
data class ItemLotTracePurchaseEvent(
val purchaseOrderLineId: Long,
val purchaseOrderId: Long?,
val purchaseOrderCode: String,
val itemCode: String,
val itemName: String,
val orderQty: BigDecimal,
val putAwayQty: BigDecimal,
val purchaseUnit: String,
val supplierCode: String,
val supplierName: String,
val orderDate: String?,
val status: String,
)

data class ItemLotTraceQcResult(
val stockInLineId: Long,
val qcPassed: Boolean,
val failQty: BigDecimal,
val acceptedQty: BigDecimal,
val remarks: String,
val handledBy: String,
val created: String?,
val qcItemCode: String = "",
val qcItemName: String = "",
val qcItemDescription: String = "",
val qcType: String = "",
)

data class ItemLotTraceMovement(
val direction: String,
val qty: BigDecimal,
val movementType: String,
val refType: String,
val refCode: String,
val refId: Long?,
val warehouseCode: String,
val handledBy: String,
val timestamp: String?,
val remarks: String,
/** Populated for DO refType — pick_order.consoCode / ticket metadata for outbound chips. */
val pickConsoCode: String = "",
val outboundTicketNo: String = "",
val deliveryOrderPickOrderId: Long? = null,
val relationshipId: Long? = null,
@get:JsonProperty("doOutboundIsExtra")
val doOutboundIsExtra: Boolean = false,
@get:JsonProperty("doOutboundIsReplenish")
val doOutboundIsReplenish: Boolean = false,
val processingStatus: String = "",
val matchStatus: String = "",
)

/** Warehouse putaway: inventory_lot_line created when stock is shelved after receipt/QC. */
data class ItemLotTracePutawayEvent(
val inventoryLotLineId: Long,
val stockInLineId: Long?,
val warehouseCode: String,
val qty: BigDecimal,
val handledBy: String,
val timestamp: String?,
val refType: String,
val refCode: String,
val refId: Long?,
/** stock_in_line.status — received = pending putaway, completed = put away done. */
val status: String = "",
)

data class ItemLotTraceOutboundUsage(
val stockOutLineId: Long,
val qty: BigDecimal,
val pickOrderCode: String,
val pickOrderId: Long?,
val consoCode: String,
val jobOrderCode: String,
val jobOrderId: Long?,
val deliveryOrderCode: String,
val handler: String,
val timestamp: String?,
val usageType: String,
)

data class ItemLotTraceStockTakeRecordDetail(
val stockTakeLineId: Long,
val bookQty: BigDecimal,
val pickerFirstQty: BigDecimal? = null,
val pickerFirstBadQty: BigDecimal? = null,
val pickerSecondQty: BigDecimal? = null,
val pickerSecondBadQty: BigDecimal? = null,
val approverQty: BigDecimal? = null,
val approverBadQty: BigDecimal? = null,
val stockTakerName: String? = null,
val approverName: String? = null,
val stockTakeStartTime: String? = null,
val stockTakeEndTime: String? = null,
val approverTime: String? = null,
val recordStatus: String? = null,
val lastSelect: Int? = null,
val varianceQty: BigDecimal? = null,
)

data class ItemLotTraceStockTakeEvent(
val stockTakeCode: String,
val lotNo: String = "",
val itemCode: String = "",
val itemName: String = "",
val warehouseCode: String = "",
val stockTakeSection: String = "",
val stockTakeRoundId: Long? = null,
val stockTakeRoundName: String = "",
val varianceQty: BigDecimal,
val beforeQty: BigDecimal,
val afterQty: BigDecimal,
val approver: String,
val timestamp: String?,
val recordDetail: ItemLotTraceStockTakeRecordDetail? = null,
)

data class ItemLotTraceAdjustment(
val adjustmentType: String,
val direction: String,
val qty: BigDecimal,
val reason: String,
val handledBy: String,
val timestamp: String?,
val refCode: String,
val warehouseCode: String = "",
)

data class ItemLotTraceTransfer(
val fromWarehouse: String,
val toWarehouse: String,
val qty: BigDecimal,
val transferCode: String,
val timestamp: String?,
)

data class ItemLotTraceBomDownstream(
val jobOrderCode: String,
val jobOrderId: Long?,
val finishedItemCode: String,
val finishedLotNo: String,
val finishedStockInLineId: Long?,
val fgQty: BigDecimal,
val fgUom: String = "",
val materialQtyUsed: BigDecimal,
)

data class ItemLotTraceBomUpstream(
val jobOrderCode: String,
val jobOrderId: Long?,
val materialItemCode: String,
val materialLotNo: String,
val materialStockInLineId: Long?,
val materialQty: BigDecimal,
val bomQtyPerUnit: BigDecimal?,
)

data class ItemLotTraceBomRecipeLine(
val materialItemCode: String,
val materialItemName: String,
val qtyPerUnit: BigDecimal,
val uom: String,
val bomProcessId: Long? = null,
val bomProcessSeqNo: Long? = null,
val assignedStepName: String = "",
)

data class ItemLotTraceStepMaterialRef(
val materialItemCode: String,
val materialItemName: String,
val qtyPerUnit: BigDecimal,
val uom: String,
)

data class ItemLotTraceBomTrace(
val direction: String,
val downstream: List<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,
)

+ 8
- 0
src/main/resources/db/changelog/changes/20260716_item_tracing/01_item_tracing_authority.sql Переглянути файл

@@ -0,0 +1,8 @@
--liquibase formatted sql

--changeset fpsms:item_tracing_authority
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'ITEM_TRACING'
--comment: Add independent permission for item lot tracing page and APIs
INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`)
VALUES ('ITEM_TRACING', '批號追溯', 'INVENTORY', 'Allow access to item lot tracing');

+ 24
- 0
src/main/resources/db/changelog/changes/20260716_item_tracing/02_item_lot_trace_log.sql Переглянути файл

@@ -0,0 +1,24 @@
--liquibase formatted sql

--changeset fpsms:item_lot_trace_log
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'item_lot_trace_log'
--comment: One row per successful item lot trace (who, what, when)
CREATE TABLE `item_lot_trace_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`createdBy` varchar(30) DEFAULT NULL,
`version` int NOT NULL DEFAULT '0',
`modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modifiedBy` varchar(30) DEFAULT NULL,
`deleted` tinyint(1) NOT NULL DEFAULT '0',
`userId` bigint NOT NULL,
`username` varchar(64) NOT NULL,
`inventoryLotId` bigint DEFAULT NULL,
`lotNo` varchar(64) DEFAULT NULL,
`itemId` bigint DEFAULT NULL,
`itemCode` varchar(64) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_item_lot_trace_log_user_created` (`userId`, `created`),
KEY `idx_item_lot_trace_log_lot_created` (`inventoryLotId`, `created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Завантаження…
Відмінити
Зберегти