浏览代码

item trace , drink produce dashboard

production
tommy 2 天前
父节点
当前提交
c63f8dfa8c
共有 8 个文件被更改,包括 256 次插入10 次删除
  1. +166
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  2. +8
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt
  3. +20
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt
  4. +1
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt
  5. +29
    -2
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt
  6. +7
    -7
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt
  7. +16
    -0
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt
  8. +9
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt

+ 166
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt 查看文件

@@ -2464,6 +2464,172 @@ open class ProductProcessService(


} }


// ===== Drink Production Qty Dashboard =====
open fun getDrinkProductionQty(date: LocalDate?): List<DrinkProductionQtyResponse> {
val targetDate = date ?: LocalDate.now()
val startOfDay = targetDate.atStartOfDay()
val endOfDayExclusive = targetDate.plusDays(1).atStartOfDay()

// 依「生產日」彙總:與既有 dashboard 的用法一致,採半開區間 [startOfDay, endExclusive)
val jobOrders = jobOrderRepository.findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc(
planStartFrom = startOfDay,
planStartToExclusive = endOfDayExclusive,
)

if (jobOrders.isEmpty()) {
return emptyList()
}

data class GroupKey(
val itemCode: String?,
val itemName: String?,
)

data class DrinkOrderRow(
val itemCode: String?,
val itemName: String?,
val uom: String?,
val reqQty: BigDecimal,
val productionQty: BigDecimal,
val jobOrderId: Long?,
val jobOrderCode: String?,
val productionDate: LocalDate?,
)

val drinkOrders = jobOrders
.filter { it.bom?.isDrink == true }
.filter { it.status != JobOrderStatus.PLANNING }

if (drinkOrders.isEmpty()) {
return emptyList()
}

val jobOrderIds = drinkOrders.mapNotNull { it.id }
val itemIds = drinkOrders.mapNotNull { it.bom?.item?.id }.distinct()

val processesByJobOrderId = productProcessRepository
.findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds)
.groupBy { it.jobOrder?.id }
.mapValues { (_, processes) -> processes.firstOrNull() }

val processIds = processesByJobOrderId.values.mapNotNull { it?.id }.distinct()
val linesByProcessId = if (processIds.isNotEmpty()) {
productProcessLineRepository.findByProductProcess_IdIn(processIds)
.groupBy { it.productProcess?.id ?: 0L }
} else {
emptyMap()
}

val stockInLineByJobOrderId = if (jobOrderIds.isNotEmpty()) {
stockInLineRepository.findAllByJobOrder_IdInAndDeletedFalse(jobOrderIds)
.associateBy { it.jobOrder?.id }
} else {
emptyMap()
}

val stockItemUoms = if (itemIds.isNotEmpty()) {
itemUomRepository.findAllByItemIdInAndStockUnitIsTrueAndDeletedIsFalse(itemIds)
} else {
emptyList()
}
val itemUomByItemId = mutableMapOf<Long, com.ffii.fpsms.modules.master.entity.ItemUom>()
stockItemUoms.forEach { iu ->
val itemId = iu.item?.id ?: return@forEach
itemUomByItemId.putIfAbsent(itemId, iu)
}
val uomIds = itemUomByItemId.values.mapNotNull { it.uom?.id }.distinct()
val uomById = if (uomIds.isNotEmpty()) {
uomConversionRepository.findAllByIdInAndDeletedFalse(uomIds).associateBy { it.id }
} else {
emptyMap()
}
val uomDescByItemId = itemUomByItemId.mapValues { (_, iu) ->
uomById[iu.uom?.id]?.udfudesc
}

val drinkRows = drinkOrders.map { jo ->
val bom = jo.bom
val itemId = bom?.item?.id
val process = processesByJobOrderId[jo.id]
val lines = process?.id?.let { linesByProcessId[it].orEmpty() }.orEmpty()
val stockInLine = stockInLineByJobOrderId[jo.id]

DrinkOrderRow(
itemCode = bom?.item?.code,
itemName = bom?.item?.name,
uom = resolveDrinkProductionUom(bom, itemId, uomDescByItemId),
reqQty = jo.reqQty ?: BigDecimal.ZERO,
productionQty = resolveDrinkProductionQty(jo, lines, stockInLine),
jobOrderId = jo.id,
jobOrderCode = jo.code,
productionDate = jo.planStart?.toLocalDate(),
)
}

return drinkRows
.groupBy { GroupKey(it.itemCode, it.itemName) }
.map { (key, rows) ->
DrinkProductionQtyResponse(
itemCode = key.itemCode,
itemName = key.itemName,
uom = rows.firstNotNullOfOrNull { it.uom?.takeIf { u -> u.isNotBlank() } },
totalReqQty = rows.fold(BigDecimal.ZERO) { acc, row ->
acc + row.reqQty
},
totalQty = rows.fold(BigDecimal.ZERO) { acc, row ->
acc + row.productionQty
},
jobOrders = rows.map { row ->
DrinkProductionQtyJobOrderDetail(
jobOrderId = row.jobOrderId ?: 0L,
jobOrderCode = row.jobOrderCode,
productionDate = row.productionDate,
reqQty = row.reqQty,
productionQty = row.productionQty,
)
}.sortedBy { it.jobOrderCode ?: "" },
)
}
.sortedWith(
compareBy<DrinkProductionQtyResponse> { it.itemCode ?: "" }
.thenBy { it.itemName ?: "" }
)
}

private fun resolveDrinkProductionUom(
bom: Bom?,
itemId: Long?,
uomDescByItemId: Map<Long, String?>,
): String? {
bom?.outputQtyUom?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
bom?.excelUom?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
itemId?.let { uomDescByItemId[it] }?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
return null
}

/** 實際生產數量:優先工序產出 → QC 入庫接受量 → 工單 reqQty(當日計劃生產量) */
private fun resolveDrinkProductionQty(
jobOrder: com.ffii.fpsms.modules.jobOrder.entity.JobOrder,
lines: List<ProductProcessLine>,
stockInLine: com.ffii.fpsms.modules.stock.entity.StockInLine?,
): BigDecimal {
val lineOutputQty = lines
.sortedByDescending { it.seqNo ?: 0L }
.firstNotNullOfOrNull { line ->
line.outputFromProcessQty?.takeIf { it > 0 }?.toBigDecimal()
}
if (lineOutputQty != null) {
return lineOutputQty
}

val acceptedQty = stockInLine?.acceptedQty
if (acceptedQty != null && acceptedQty > BigDecimal.ZERO) {
return acceptedQty
}

return jobOrder.reqQty ?: BigDecimal.ZERO
}

// ===== Operator KPI Dashboard ===== // ===== Operator KPI Dashboard =====
open fun getOperatorKpi(date: LocalDate?): List<OperatorKpiResponse> { open fun getOperatorKpi(date: LocalDate?): List<OperatorKpiResponse> {
val targetDate = date ?: LocalDate.now() val targetDate = date ?: LocalDate.now()


+ 8
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt 查看文件

@@ -306,4 +306,12 @@ class ProductProcessController(
fun getEquipmentStatus(): List<EquipmentStatusByTypeResponse> { fun getEquipmentStatus(): List<EquipmentStatusByTypeResponse> {
return productProcessService.getEquipmentStatusByType() return productProcessService.getEquipmentStatusByType()
} }

@GetMapping("/Demo/DrinkProductionQty")
fun getDrinkProductionQty(@RequestParam(required = false) date: String?): List<DrinkProductionQtyResponse> {
val parsedDate = date?.takeIf { it.isNotBlank() }?.let {
LocalDate.parse(it, DateTimeFormatter.ISO_DATE)
}
return productProcessService.getDrinkProductionQty(parsedDate)
}
} }

+ 20
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt 查看文件

@@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.productProcess.web.model


import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.math.BigDecimal
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonFormat
data class SaveProductProcessRequest( data class SaveProductProcessRequest(
@@ -335,4 +336,23 @@ data class EquipmentStatusByTypeResponse(
val equipmentTypeId: Long, val equipmentTypeId: Long,
val equipmentTypeName: String?, val equipmentTypeName: String?,
val details: List<EquipmentStatusPerDetail>, val details: List<EquipmentStatusPerDetail>,
)

// ===== Drink Production Qty Dashboard =====
data class DrinkProductionQtyJobOrderDetail(
val jobOrderId: Long,
val jobOrderCode: String?,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
val productionDate: LocalDate?,
val reqQty: BigDecimal,
val productionQty: BigDecimal,
)

data class DrinkProductionQtyResponse(
val itemCode: String?,
val itemName: String?,
val uom: String?,
val totalReqQty: BigDecimal,
val totalQty: BigDecimal,
val jobOrders: List<DrinkProductionQtyJobOrderDetail> = emptyList(),
) )

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt 查看文件

@@ -223,6 +223,7 @@ open class ItemLotTraceOrchestrator(
relationshipId = meta.relationshipId, relationshipId = meta.relationshipId,
doOutboundIsExtra = meta.isExtra, doOutboundIsExtra = meta.isExtra,
doOutboundIsReplenish = meta.isReplenish, doOutboundIsReplenish = meta.isReplenish,
doOutboundQtyChanged = meta.qtyChanged,
) )
} }
} }


+ 29
- 2
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt 查看文件

@@ -223,18 +223,42 @@ open class TraceInboundLoader(
} }
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 22 | v1.0.0 | 2026-07-20 */
open fun loadPutawayEvents(inventoryLotId: Long): List<ItemLotTracePutawayEvent> = open fun loadPutawayEvents(inventoryLotId: Long): List<ItemLotTracePutawayEvent> =
loadPutawayEventsByLotIds(listOf(inventoryLotId))[inventoryLotId].orEmpty() loadPutawayEventsByLotIds(listOf(inventoryLotId))[inventoryLotId].orEmpty()


/**
* Putaway card qty must be the original shelf-in amount, not the live [InventoryLotLine.inQty].
* Stock-take 盤盈 adds variance onto the same lot line inQty (see applyVarianceAdjustment),
* which would otherwise inflate the 已上架 node (e.g. 150 → 1500).
*/
private fun putawayQtySql(illIdExpr: String): String = """
GREATEST(
COALESCE(ill.inQty, 0) - COALESCE((
SELECT SUM(COALESCE(sil_tke.acceptedQty, 0))
FROM stock_in_line sil_tke
INNER JOIN stock_take_line stl_tke
ON sil_tke.stockTakeLineId = stl_tke.id AND stl_tke.deleted = 0
WHERE stl_tke.inventoryLotLineId = $illIdExpr
AND sil_tke.deleted = 0
AND UPPER(COALESCE(sil_tke.type, '')) = 'TKE'
AND COALESCE(sil_tke.acceptedQty, 0) > 0
), 0),
0
)
""".trimIndent()

/** FP-MTMS Version Checklist | Functions Ref. No. 22 | v1.0.0 | 2026-07-20 */
open fun loadPutawayEventsByLotIds(lotIds: List<Long>): Map<Long, List<ItemLotTracePutawayEvent>> { open fun loadPutawayEventsByLotIds(lotIds: List<Long>): Map<Long, List<ItemLotTracePutawayEvent>> {
if (lotIds.isEmpty()) return emptyMap() if (lotIds.isEmpty()) return emptyMap()
val illPutawayQty = putawayQtySql("ill.id")
val fromIllSql = """ val fromIllSql = """
SELECT SELECT
il.id AS inventoryLotId, il.id AS inventoryLotId,
ill.id AS inventoryLotLineId, ill.id AS inventoryLotLineId,
COALESCE(sil_link.id, sil_primary.id) AS stockInLineId, COALESCE(sil_link.id, sil_primary.id) AS stockInLineId,
COALESCE(wh.code, '') AS warehouseCode, COALESCE(wh.code, '') AS warehouseCode,
COALESCE(ill.inQty, 0) AS qty,
$illPutawayQty AS qty,
COALESCE(ill.createdBy, sil_link.createdBy, sil_primary.createdBy, '') AS handledBy, 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, COALESCE(ill.created, sil_link.receiptDate, sil_primary.receiptDate, sil_link.created, sil_primary.created) AS timestamp,
CASE CASE
@@ -266,7 +290,10 @@ open class TraceInboundLoader(
COALESCE(ill.id, sil.inventoryLotLineId) AS inventoryLotLineId, COALESCE(ill.id, sil.inventoryLotLineId) AS inventoryLotLineId,
sil.id AS stockInLineId, sil.id AS stockInLineId,
COALESCE(wh.code, '') AS warehouseCode, COALESCE(wh.code, '') AS warehouseCode,
COALESCE(ill.inQty, sil.acceptedQty, 0) AS qty,
CASE
WHEN ill.id IS NOT NULL THEN $illPutawayQty
ELSE COALESCE(sil.acceptedQty, 0)
END AS qty,
COALESCE(ill.createdBy, sil.createdBy, '') AS handledBy, COALESCE(ill.createdBy, sil.createdBy, '') AS handledBy,
COALESCE(ill.created, sil.receiptDate, sil.created) AS timestamp, COALESCE(ill.created, sil.receiptDate, sil.created) AS timestamp,
CASE CASE


+ 7
- 7
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt 查看文件

@@ -154,9 +154,9 @@ open class TraceMovementLoader(
COALESCE(NULLIF(st.stockTakeSection, ''), NULLIF(str.stockTakeSection, ''), '') AS stockTakeSection, COALESCE(NULLIF(st.stockTakeSection, ''), NULLIF(str.stockTakeSection, ''), '') AS stockTakeSection,
COALESCE(st.stockTakeRoundId, str.stockTakeRoundId) AS stockTakeRoundId, COALESCE(st.stockTakeRoundId, str.stockTakeRoundId) AS stockTakeRoundId,
COALESCE(NULLIF(st.stockTakeRoundName, ''), '') AS stockTakeRoundName, 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(str.varianceQty, stl.finalQty - stl.initialQty) AS varianceQty,
stl.initialQty AS beforeQty,
stl.finalQty AS afterQty,
COALESCE(NULLIF(str.approverName, ''), NULLIF(str.stockTakerName, ''), '') AS approver, COALESCE(NULLIF(str.approverName, ''), NULLIF(str.stockTakerName, ''), '') AS approver,
COALESCE(str.approverTime, stl.completeDate, stl.modified) AS timestamp, COALESCE(str.approverTime, stl.completeDate, stl.modified) AS timestamp,
COALESCE(str.bookQty, 0) AS bookQty, COALESCE(str.bookQty, 0) AS bookQty,
@@ -197,15 +197,15 @@ open class TraceMovementLoader(
stockTakeSection = sql.str(row["stockTakeSection"]), stockTakeSection = sql.str(row["stockTakeSection"]),
stockTakeRoundId = sql.longVal(row["stockTakeRoundId"]), stockTakeRoundId = sql.longVal(row["stockTakeRoundId"]),
stockTakeRoundName = sql.str(row["stockTakeRoundName"]), stockTakeRoundName = sql.str(row["stockTakeRoundName"]),
varianceQty = sql.decimal(row["varianceQty"]),
beforeQty = sql.decimal(row["beforeQty"]),
afterQty = sql.decimal(row["afterQty"]),
varianceQty = sql.decimalOrNull(row["varianceQty"]) ?: java.math.BigDecimal.ZERO,
beforeQty = sql.decimalOrNull(row["beforeQty"]) ?: java.math.BigDecimal.ZERO,
afterQty = sql.decimalOrNull(row["afterQty"]),
approver = sql.str(row["approver"]), approver = sql.str(row["approver"]),
timestamp = sql.formatDateTime(row["timestamp"]), timestamp = sql.formatDateTime(row["timestamp"]),
recordDetail = if (hasRecordDetail) { recordDetail = if (hasRecordDetail) {
ItemLotTraceStockTakeRecordDetail( ItemLotTraceStockTakeRecordDetail(
stockTakeLineId = stockTakeLineId ?: 0L, stockTakeLineId = stockTakeLineId ?: 0L,
bookQty = sql.decimal(row["bookQty"]),
bookQty = sql.decimalOrNull(row["bookQty"]) ?: java.math.BigDecimal.ZERO,
pickerFirstQty = sql.decimalOrNull(row["pickerFirstQty"]), pickerFirstQty = sql.decimalOrNull(row["pickerFirstQty"]),
pickerFirstBadQty = sql.decimalOrNull(row["pickerFirstBadQty"]), pickerFirstBadQty = sql.decimalOrNull(row["pickerFirstBadQty"]),
pickerSecondQty = sql.decimalOrNull(row["pickerSecondQty"]), pickerSecondQty = sql.decimalOrNull(row["pickerSecondQty"]),


+ 16
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt 查看文件

@@ -97,6 +97,13 @@ open class TraceOutboundLoader(
do_tbl.id AS deliveryOrderId, do_tbl.id AS deliveryOrderId,
COALESCE(do_tbl.isExtra, 0) AS deliveryOrderIsExtra, COALESCE(do_tbl.isExtra, 0) AS deliveryOrderIsExtra,
pol.id AS pickOrderLineId, pol.id AS pickOrderLineId,
pol.qty AS pickOrderLineQty,
(
SELECT COALESCE(SUM(sol_all.qty), 0)
FROM stock_out_line sol_all
WHERE sol_all.pickOrderLineId = pol.id
AND sol_all.deleted = 0
) AS pickOrderLinePickedQty,
il.itemId AS itemId, il.itemId AS itemId,
COALESCE(dop_direct.ticketNo, dop_conso.ticketNo) AS dopTicketNo, COALESCE(dop_direct.ticketNo, dop_conso.ticketNo) AS dopTicketNo,
COALESCE(dpo.ticket_no, dpor.ticket_no, '') AS legacyTicketNo, COALESCE(dpo.ticket_no, dpor.ticket_no, '') AS legacyTicketNo,
@@ -213,6 +220,14 @@ open class TraceOutboundLoader(
) )
val isReplenish = deliveryOrderId != null && val isReplenish = deliveryOrderId != null &&
replenishIndex.matches(deliveryOrderId, itemId, pickOrderLineId) replenishIndex.matches(deliveryOrderId, itemId, pickOrderLineId)
// Multi-lot picks: compare POL.qty to SUM(SOL.qty) for that line, not a single SOL.
val pickOrderLineQty = sql.decimalOrNull(row["pickOrderLineQty"])
val pickOrderLinePickedQty = sql.decimalOrNull(row["pickOrderLinePickedQty"])
val qtyChanged =
pickOrderLineId != null &&
pickOrderLineQty != null &&
pickOrderLinePickedQty != null &&
pickOrderLineQty.compareTo(pickOrderLinePickedQty) != 0
return ItemLotTraceDoDelivery( return ItemLotTraceDoDelivery(
stockOutLineId = sql.longVal(row["stockOutLineId"]) ?: 0L, stockOutLineId = sql.longVal(row["stockOutLineId"]) ?: 0L,
qty = sql.decimal(row["qty"]), qty = sql.decimal(row["qty"]),
@@ -233,6 +248,7 @@ open class TraceOutboundLoader(
doPickOrderRecordId = sql.longVal(row["doPickOrderRecordId"]), doPickOrderRecordId = sql.longVal(row["doPickOrderRecordId"]),
isExtra = isExtra, isExtra = isExtra,
isReplenish = isReplenish, isReplenish = isReplenish,
qtyChanged = qtyChanged,
) )
} }




+ 9
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt 查看文件

@@ -89,6 +89,8 @@ data class ItemLotTraceMovement(
val doOutboundIsExtra: Boolean = false, val doOutboundIsExtra: Boolean = false,
@get:JsonProperty("doOutboundIsReplenish") @get:JsonProperty("doOutboundIsReplenish")
val doOutboundIsReplenish: Boolean = false, val doOutboundIsReplenish: Boolean = false,
@get:JsonProperty("doOutboundQtyChanged")
val doOutboundQtyChanged: Boolean = false,
val processingStatus: String = "", val processingStatus: String = "",
val matchStatus: String = "", val matchStatus: String = "",
) )
@@ -152,7 +154,7 @@ data class ItemLotTraceStockTakeEvent(
val stockTakeRoundName: String = "", val stockTakeRoundName: String = "",
val varianceQty: BigDecimal, val varianceQty: BigDecimal,
val beforeQty: BigDecimal, val beforeQty: BigDecimal,
val afterQty: BigDecimal,
val afterQty: BigDecimal? = null,
val approver: String, val approver: String,
val timestamp: String?, val timestamp: String?,
val recordDetail: ItemLotTraceStockTakeRecordDetail? = null, val recordDetail: ItemLotTraceStockTakeRecordDetail? = null,
@@ -375,6 +377,12 @@ data class ItemLotTraceDoDelivery(
/** Matches delivery note sequence suffix (補貨). */ /** Matches delivery note sequence suffix (補貨). */
@get:JsonProperty("isReplenish") @get:JsonProperty("isReplenish")
val isReplenish: Boolean = false, val isReplenish: Boolean = false,
/**
* True when pick_order_line.qty differs from SUM(stock_out_line.qty) for that line
* (改數). Multi-lot picks are aggregated so splitting across lots alone is not 改數.
*/
@get:JsonProperty("qtyChanged")
val qtyChanged: Boolean = false,
) )


/** DO replenishment created for a source delivery order line (建立補貨). */ /** DO replenishment created for a source delivery order line (建立補貨). */


正在加载...
取消
保存