Procházet zdrojové kódy

item trace , drink produce dashboard

production
tommy před 2 dny
rodič
revize
c63f8dfa8c
8 změnil soubory, kde provedl 256 přidání a 10 odebrání
  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 Zobrazit soubor

@@ -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()


+ 8
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt Zobrazit soubor

@@ -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)
}
}

+ 20
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt Zobrazit soubor

@@ -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(),
)

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/ItemLotTraceOrchestrator.kt Zobrazit soubor

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


+ 29
- 2
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt Zobrazit soubor

@@ -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


+ 7
- 7
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceMovementLoader.kt Zobrazit soubor

@@ -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"]),


+ 16
- 0
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceOutboundLoader.kt Zobrazit soubor

@@ -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,
)
}



+ 9
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/model/ItemLotTraceResponse.kt Zobrazit soubor

@@ -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 (建立補貨). */


Načítá se…
Zrušit
Uložit