| @@ -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 ===== | |||
| open fun getOperatorKpi(date: LocalDate?): List<OperatorKpiResponse> { | |||
| val targetDate = date ?: LocalDate.now() | |||
| @@ -306,4 +306,12 @@ class ProductProcessController( | |||
| fun getEquipmentStatus(): List<EquipmentStatusByTypeResponse> { | |||
| 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) | |||
| } | |||
| } | |||
| @@ -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<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(), | |||
| ) | |||
| @@ -223,6 +223,7 @@ open class ItemLotTraceOrchestrator( | |||
| relationshipId = meta.relationshipId, | |||
| doOutboundIsExtra = meta.isExtra, | |||
| doOutboundIsReplenish = meta.isReplenish, | |||
| doOutboundQtyChanged = meta.qtyChanged, | |||
| ) | |||
| } | |||
| } | |||
| @@ -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> = | |||
| 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>> { | |||
| 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 | |||
| @@ -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"]), | |||
| @@ -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, | |||
| ) | |||
| } | |||
| @@ -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 (建立補貨). */ | |||