diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index b789875..b65b0f2 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/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 { + 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() + 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 { it.itemCode ?: "" } + .thenBy { it.itemName ?: "" } + ) + } + + private fun resolveDrinkProductionUom( + bom: Bom?, + itemId: Long?, + uomDescByItemId: Map, + ): 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, + 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 ===== open fun getOperatorKpi(date: LocalDate?): List { val targetDate = date ?: LocalDate.now() diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt index 071be15..7ca007b 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt @@ -306,4 +306,12 @@ class ProductProcessController( fun getEquipmentStatus(): List { return productProcessService.getEquipmentStatusByType() } + + @GetMapping("/Demo/DrinkProductionQty") + fun getDrinkProductionQty(@RequestParam(required = false) date: String?): List { + val parsedDate = date?.takeIf { it.isNotBlank() }?.let { + LocalDate.parse(it, DateTimeFormatter.ISO_DATE) + } + return productProcessService.getDrinkProductionQty(parsedDate) + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt index 7f52f8d..d1d1361 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt +++ b/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.LocalDateTime +import java.math.BigDecimal import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonFormat data class SaveProductProcessRequest( @@ -335,4 +336,23 @@ data class EquipmentStatusByTypeResponse( val equipmentTypeId: Long, val equipmentTypeName: String?, val details: List, +) + +// ===== 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 = emptyList(), ) \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt index e17866b..74eff9c 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt @@ -223,6 +223,7 @@ open class ItemLotTraceOrchestrator( relationshipId = meta.relationshipId, doOutboundIsExtra = meta.isExtra, doOutboundIsReplenish = meta.isReplenish, + doOutboundQtyChanged = meta.qtyChanged, ) } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt index cd0d530..0b62350 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt +++ b/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 = 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): Map> { if (lotIds.isEmpty()) return emptyMap() + val illPutawayQty = putawayQtySql("ill.id") 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, + $illPutawayQty 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 @@ -266,7 +290,10 @@ open class TraceInboundLoader( COALESCE(ill.id, sil.inventoryLotLineId) AS inventoryLotLineId, sil.id AS stockInLineId, 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.created, sil.receiptDate, sil.created) AS timestamp, CASE diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt index 8469394..9a5a97b 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt +++ b/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(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(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(str.approverTime, stl.completeDate, stl.modified) AS timestamp, COALESCE(str.bookQty, 0) AS bookQty, @@ -197,15 +197,15 @@ open class TraceMovementLoader( 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"]), + 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"]), timestamp = sql.formatDateTime(row["timestamp"]), recordDetail = if (hasRecordDetail) { ItemLotTraceStockTakeRecordDetail( stockTakeLineId = stockTakeLineId ?: 0L, - bookQty = sql.decimal(row["bookQty"]), + bookQty = sql.decimalOrNull(row["bookQty"]) ?: java.math.BigDecimal.ZERO, pickerFirstQty = sql.decimalOrNull(row["pickerFirstQty"]), pickerFirstBadQty = sql.decimalOrNull(row["pickerFirstBadQty"]), pickerSecondQty = sql.decimalOrNull(row["pickerSecondQty"]), diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt index f09b203..e83c988 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt @@ -97,6 +97,13 @@ open class TraceOutboundLoader( do_tbl.id AS deliveryOrderId, COALESCE(do_tbl.isExtra, 0) AS deliveryOrderIsExtra, 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, COALESCE(dop_direct.ticketNo, dop_conso.ticketNo) AS dopTicketNo, COALESCE(dpo.ticket_no, dpor.ticket_no, '') AS legacyTicketNo, @@ -213,6 +220,14 @@ open class TraceOutboundLoader( ) val isReplenish = deliveryOrderId != null && 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( stockOutLineId = sql.longVal(row["stockOutLineId"]) ?: 0L, qty = sql.decimal(row["qty"]), @@ -233,6 +248,7 @@ open class TraceOutboundLoader( doPickOrderRecordId = sql.longVal(row["doPickOrderRecordId"]), isExtra = isExtra, isReplenish = isReplenish, + qtyChanged = qtyChanged, ) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt index 5d89bc3..eceda0c 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt @@ -89,6 +89,8 @@ data class ItemLotTraceMovement( val doOutboundIsExtra: Boolean = false, @get:JsonProperty("doOutboundIsReplenish") val doOutboundIsReplenish: Boolean = false, + @get:JsonProperty("doOutboundQtyChanged") + val doOutboundQtyChanged: Boolean = false, val processingStatus: String = "", val matchStatus: String = "", ) @@ -152,7 +154,7 @@ data class ItemLotTraceStockTakeEvent( val stockTakeRoundName: String = "", val varianceQty: BigDecimal, val beforeQty: BigDecimal, - val afterQty: BigDecimal, + val afterQty: BigDecimal? = null, val approver: String, val timestamp: String?, val recordDetail: ItemLotTraceStockTakeRecordDetail? = null, @@ -375,6 +377,12 @@ data class ItemLotTraceDoDelivery( /** Matches delivery note sequence suffix (補貨). */ @get:JsonProperty("isReplenish") 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 (建立補貨). */