| @@ -0,0 +1,400 @@ | |||
| package com.ffii.fpsms.modules.productProcess.service | |||
| import com.ffii.core.support.JdbcDao | |||
| import com.ffii.fpsms.modules.productProcess.web.model.DrinkShipmentQtyDeliveryDetail | |||
| import com.ffii.fpsms.modules.productProcess.web.model.DrinkShipmentQtyResponse | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.transaction.annotation.Transactional | |||
| import java.math.BigDecimal | |||
| import java.sql.Date | |||
| import java.sql.Timestamp | |||
| import java.time.LocalDate | |||
| @Service | |||
| @Transactional(readOnly = true) | |||
| open class DrinkShipmentQtyService( | |||
| private val jdbcDao: JdbcDao, | |||
| ) { | |||
| /** 訂單列:預計送貨日=選取日,或當日有掃出。實際出貨=當日 stock_ledger.outQty。 */ | |||
| open fun getDrinkShipmentQty(date: LocalDate?): List<DrinkShipmentQtyResponse> { | |||
| val targetDate = date ?: LocalDate.now() | |||
| val params = mapOf("targetDate" to targetDate.toString()) | |||
| val rows = jdbcDao.queryForList(dolSql, params) + | |||
| jdbcDao.queryForList(replenishmentOnlySql, params) | |||
| if (rows.isEmpty()) { | |||
| return emptyList() | |||
| } | |||
| data class GroupKey( | |||
| val itemCode: String?, | |||
| val itemName: String?, | |||
| ) | |||
| data class DeliveryKey( | |||
| val deliveryOrderId: Long, | |||
| val itemCode: String?, | |||
| ) | |||
| val deliveriesByItem = linkedMapOf<GroupKey, MutableMap<DeliveryKey, Pair<DrinkShipmentQtyDeliveryDetail, String?>>>() | |||
| rows.forEach { row -> | |||
| val itemCode = jdbcString(row, "itemCode") | |||
| val itemName = jdbcString(row, "itemName") | |||
| val uom = jdbcString(row, "uom") | |||
| val itemKey = GroupKey(itemCode, itemName) | |||
| val detail = DrinkShipmentQtyDeliveryDetail( | |||
| deliveryOrderId = jdbcLong(row, "deliveryOrderId"), | |||
| deliveryOrderCode = jdbcString(row, "deliveryOrderCode"), | |||
| deliveryDate = jdbcDate(row, "deliveryDate"), | |||
| shopCode = jdbcString(row, "shopCode"), | |||
| shopName = shopLabel( | |||
| jdbcString(row, "shopCode"), | |||
| jdbcString(row, "shopName"), | |||
| ), | |||
| deliveryOrderStatus = jdbcString(row, "deliveryOrderStatus"), | |||
| orderQty = jdbcDecimal(row, "orderQty"), | |||
| shippedQty = jdbcDecimal(row, "shippedQty"), | |||
| ) | |||
| val deliveryKey = DeliveryKey(detail.deliveryOrderId, itemCode) | |||
| val byDelivery = deliveriesByItem.getOrPut(itemKey) { linkedMapOf() } | |||
| val existing = byDelivery[deliveryKey] | |||
| if (existing == null) { | |||
| byDelivery[deliveryKey] = detail to uom | |||
| } else { | |||
| val merged = existing.first.copy( | |||
| orderQty = existing.first.orderQty + detail.orderQty, | |||
| shippedQty = existing.first.shippedQty + detail.shippedQty, | |||
| shopName = existing.first.shopName ?: detail.shopName, | |||
| deliveryOrderCode = existing.first.deliveryOrderCode ?: detail.deliveryOrderCode, | |||
| deliveryOrderStatus = existing.first.deliveryOrderStatus ?: detail.deliveryOrderStatus, | |||
| ) | |||
| byDelivery[deliveryKey] = merged to (existing.second ?: uom) | |||
| } | |||
| } | |||
| return deliveriesByItem | |||
| .map { (key, byDelivery) -> | |||
| val deliveries = byDelivery.values | |||
| .map { it.first } | |||
| .sortedBy { it.deliveryOrderCode ?: "" } | |||
| DrinkShipmentQtyResponse( | |||
| itemCode = key.itemCode, | |||
| itemName = key.itemName, | |||
| uom = byDelivery.values.firstNotNullOfOrNull { it.second }, | |||
| totalOrderQty = deliveries.fold(BigDecimal.ZERO) { acc, row -> acc + row.orderQty }, | |||
| totalShippedQty = deliveries.fold(BigDecimal.ZERO) { acc, row -> acc + row.shippedQty }, | |||
| deliveries = deliveries, | |||
| ) | |||
| } | |||
| .sortedWith( | |||
| compareBy<DrinkShipmentQtyResponse> { it.itemCode ?: "" } | |||
| .thenBy { it.itemName ?: "" }, | |||
| ) | |||
| } | |||
| private fun shopLabel(shopCode: String?, shopName: String?): String? { | |||
| val code = shopCode?.trim()?.takeIf { it.isNotEmpty() } | |||
| val name = shopName?.trim()?.takeIf { it.isNotEmpty() } | |||
| return when { | |||
| code != null && name != null && name.startsWith("$code -") -> name | |||
| code != null && name != null -> "$code - $name" | |||
| name != null -> name | |||
| else -> code | |||
| } | |||
| } | |||
| private fun jdbcString(row: Map<String, Any?>, key: String): String? { | |||
| val value = row[key] ?: row[key.lowercase()] ?: return null | |||
| val text = value.toString().trim() | |||
| return text.takeIf { it.isNotEmpty() && !it.equals("null", ignoreCase = true) } | |||
| } | |||
| private fun jdbcLong(row: Map<String, Any?>, key: String): Long { | |||
| val value = row[key] ?: row[key.lowercase()] ?: return 0L | |||
| return when (value) { | |||
| is Number -> value.toLong() | |||
| else -> value.toString().toLongOrNull() ?: 0L | |||
| } | |||
| } | |||
| private fun jdbcDecimal(row: Map<String, Any?>, key: String): BigDecimal { | |||
| val value = row[key] ?: row[key.lowercase()] ?: return BigDecimal.ZERO | |||
| return when (value) { | |||
| is BigDecimal -> value | |||
| is Number -> BigDecimal(value.toString()) | |||
| else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO | |||
| } | |||
| } | |||
| private fun jdbcDate(row: Map<String, Any?>, key: String): LocalDate? { | |||
| val value = row[key] ?: row[key.lowercase()] ?: return null | |||
| return when (value) { | |||
| is LocalDate -> value | |||
| is Date -> value.toLocalDate() | |||
| is Timestamp -> value.toLocalDateTime().toLocalDate() | |||
| else -> runCatching { LocalDate.parse(value.toString().take(10)) }.getOrNull() | |||
| } | |||
| } | |||
| companion object { | |||
| private const val DRINK_ITEM_JOIN = """ | |||
| INNER JOIN ( | |||
| SELECT itemId, MIN(id) AS bomId | |||
| FROM bom | |||
| WHERE deleted = 0 | |||
| AND IFNULL(isDrink, 0) = 1 | |||
| AND itemId IS NOT NULL | |||
| GROUP BY itemId | |||
| ) drink_item ON drink_item.itemId = it.id | |||
| LEFT JOIN bom b ON b.id = drink_item.bomId | |||
| LEFT JOIN item_uom iu | |||
| ON iu.id = ( | |||
| SELECT MIN(iu2.id) | |||
| FROM item_uom iu2 | |||
| WHERE iu2.itemId = it.id | |||
| AND iu2.stockUnit = 1 | |||
| AND iu2.deleted = 0 | |||
| ) | |||
| LEFT JOIN uom_conversion uc ON uc.id = iu.uomId | |||
| """ | |||
| private const val SOL_DO_TYPE_FILTER = """ | |||
| AND LOWER(IFNULL(sol.type, 'nor')) NOT IN ('trf', 'adj', 'tke', 'stocktake', 'jo') | |||
| AND ( | |||
| so.id IS NULL | |||
| OR ( | |||
| IFNULL(so.deleted, 0) = 0 | |||
| AND LOWER(IFNULL(so.type, 'do')) = 'do' | |||
| ) | |||
| ) | |||
| """ | |||
| /** | |||
| * 當天掃出去的量:優先 stock_ledger.outQty(跨日提料只計當日增量)。 | |||
| * 無 ledger 的舊資料才 fallback 到當日 pickTime 的 sol.qty。 | |||
| */ | |||
| private const val SHIPPED_QTY_FOR_DO = """ | |||
| IFNULL(( | |||
| SELECT SUM( | |||
| CASE | |||
| WHEN sl.id IS NOT NULL THEN IFNULL(sl.outQty, 0) | |||
| WHEN DATE(IFNULL(sol.pickTime, sol.created)) = :targetDate | |||
| AND NOT EXISTS ( | |||
| SELECT 1 | |||
| FROM stock_ledger slx | |||
| WHERE slx.stockOutLineId = sol.id | |||
| AND IFNULL(slx.deleted, 0) = 0 | |||
| ) | |||
| THEN IFNULL(sol.qty, 0) | |||
| ELSE 0 | |||
| END | |||
| ) | |||
| FROM pick_order po | |||
| INNER JOIN pick_order_line pol | |||
| ON pol.poId = po.id | |||
| AND pol.deleted = 0 | |||
| AND pol.itemId = it.id | |||
| INNER JOIN stock_out_line sol | |||
| ON sol.pickOrderLineId = pol.id | |||
| AND sol.deleted = 0 | |||
| LEFT JOIN stock_out so | |||
| ON so.id = sol.stockOutId | |||
| LEFT JOIN stock_ledger sl | |||
| ON sl.stockOutLineId = sol.id | |||
| AND IFNULL(sl.deleted, 0) = 0 | |||
| AND DATE(sl.date) = :targetDate | |||
| AND IFNULL(sl.outQty, 0) > 0 | |||
| WHERE po.deleted = 0 | |||
| AND po.doId = do.id | |||
| AND LOWER(IFNULL(po.type, 'do')) = 'do' | |||
| $SOL_DO_TYPE_FILTER | |||
| ), 0) | |||
| """ | |||
| private const val SCANNED_TODAY_FOR_DO = """ | |||
| EXISTS ( | |||
| SELECT 1 | |||
| FROM pick_order po | |||
| INNER JOIN pick_order_line pol | |||
| ON pol.poId = po.id | |||
| AND pol.deleted = 0 | |||
| AND pol.itemId = it.id | |||
| INNER JOIN stock_out_line sol | |||
| ON sol.pickOrderLineId = pol.id | |||
| AND sol.deleted = 0 | |||
| LEFT JOIN stock_out so | |||
| ON so.id = sol.stockOutId | |||
| LEFT JOIN stock_ledger sl | |||
| ON sl.stockOutLineId = sol.id | |||
| AND IFNULL(sl.deleted, 0) = 0 | |||
| AND DATE(sl.date) = :targetDate | |||
| AND IFNULL(sl.outQty, 0) > 0 | |||
| WHERE po.deleted = 0 | |||
| AND po.doId = do.id | |||
| AND LOWER(IFNULL(po.type, 'do')) = 'do' | |||
| $SOL_DO_TYPE_FILTER | |||
| AND ( | |||
| sl.id IS NOT NULL | |||
| OR ( | |||
| IFNULL(sol.qty, 0) > 0 | |||
| AND DATE(IFNULL(sol.pickTime, sol.created)) = :targetDate | |||
| AND NOT EXISTS ( | |||
| SELECT 1 | |||
| FROM stock_ledger slx | |||
| WHERE slx.stockOutLineId = sol.id | |||
| AND IFNULL(slx.deleted, 0) = 0 | |||
| ) | |||
| ) | |||
| ) | |||
| ) | |||
| """ | |||
| private val dolSql = """ | |||
| SELECT | |||
| it.code AS itemCode, | |||
| it.name AS itemName, | |||
| COALESCE( | |||
| NULLIF(TRIM(b.outputQtyUom), ''), | |||
| NULLIF(TRIM(b.excelUom), ''), | |||
| uc.udfudesc | |||
| ) AS uom, | |||
| do.id AS deliveryOrderId, | |||
| do.code AS deliveryOrderCode, | |||
| do.status AS deliveryOrderStatus, | |||
| DATE(IFNULL(do.estimatedArrivalDate, do.orderDate)) AS deliveryDate, | |||
| IFNULL(sp.code, '') AS shopCode, | |||
| IFNULL(sp.name, '') AS shopName, | |||
| SUM(IFNULL(dol.qty, 0)) | |||
| + IFNULL(( | |||
| SELECT SUM(IFNULL(r.replenishQty, 0)) | |||
| FROM do_replenishment r | |||
| WHERE IFNULL(r.deleted, 0) = 0 | |||
| AND r.targetDoId = do.id | |||
| AND r.itemId = it.id | |||
| ), 0) AS orderQty, | |||
| $SHIPPED_QTY_FOR_DO AS shippedQty | |||
| FROM delivery_order_line dol | |||
| INNER JOIN delivery_order do | |||
| ON dol.deliveryOrderId = do.id | |||
| AND do.deleted = 0 | |||
| INNER JOIN items it | |||
| ON dol.itemId = it.id | |||
| AND it.deleted = 0 | |||
| $DRINK_ITEM_JOIN | |||
| LEFT JOIN shop sp | |||
| ON do.shopId = sp.id | |||
| AND sp.deleted = 0 | |||
| WHERE dol.deleted = 0 | |||
| AND ( | |||
| DATE(IFNULL(do.estimatedArrivalDate, do.orderDate)) = :targetDate | |||
| OR $SCANNED_TODAY_FOR_DO | |||
| ) | |||
| GROUP BY | |||
| it.id, | |||
| it.code, | |||
| it.name, | |||
| b.outputQtyUom, | |||
| b.excelUom, | |||
| uc.udfudesc, | |||
| do.id, | |||
| do.code, | |||
| do.status, | |||
| DATE(IFNULL(do.estimatedArrivalDate, do.orderDate)), | |||
| sp.code, | |||
| sp.name | |||
| ORDER BY it.code, do.code | |||
| """.trimIndent() | |||
| private val replenishmentOnlySql = """ | |||
| SELECT | |||
| it.code AS itemCode, | |||
| it.name AS itemName, | |||
| COALESCE( | |||
| NULLIF(TRIM(b.outputQtyUom), ''), | |||
| NULLIF(TRIM(b.excelUom), ''), | |||
| uc.udfudesc | |||
| ) AS uom, | |||
| COALESCE(r.targetDoId, r.sourceDoId, 0) AS deliveryOrderId, | |||
| COALESCE(do.code, r.targetDoCode, r.sourceDoCode) AS deliveryOrderCode, | |||
| do.status AS deliveryOrderStatus, | |||
| r.deliveryDate AS deliveryDate, | |||
| IFNULL(r.shopCode, IFNULL(sp.code, '')) AS shopCode, | |||
| IFNULL(r.shopName, IFNULL(sp.name, '')) AS shopName, | |||
| SUM(IFNULL(r.replenishQty, 0)) AS orderQty, | |||
| IFNULL(MAX(ship.shippedQty), 0) AS shippedQty | |||
| FROM do_replenishment r | |||
| INNER JOIN items it | |||
| ON r.itemId = it.id | |||
| AND it.deleted = 0 | |||
| $DRINK_ITEM_JOIN | |||
| LEFT JOIN delivery_order do | |||
| ON do.id = COALESCE(r.targetDoId, r.sourceDoId) | |||
| AND do.deleted = 0 | |||
| LEFT JOIN shop sp | |||
| ON do.shopId = sp.id | |||
| AND sp.deleted = 0 | |||
| LEFT JOIN ( | |||
| SELECT | |||
| COALESCE(r2.targetDoId, r2.sourceDoId, 0) AS deliveryOrderId, | |||
| r2.itemId AS itemId, | |||
| SUM( | |||
| CASE | |||
| WHEN sl.id IS NOT NULL THEN IFNULL(sl.outQty, 0) | |||
| WHEN DATE(IFNULL(sol.pickTime, sol.created)) = :targetDate | |||
| AND NOT EXISTS ( | |||
| SELECT 1 | |||
| FROM stock_ledger slx | |||
| WHERE slx.stockOutLineId = sol.id | |||
| AND IFNULL(slx.deleted, 0) = 0 | |||
| ) | |||
| THEN IFNULL(sol.qty, 0) | |||
| ELSE 0 | |||
| END | |||
| ) AS shippedQty | |||
| FROM do_replenishment r2 | |||
| INNER JOIN stock_out_line sol | |||
| ON sol.pickOrderLineId = r2.pickOrderLineId | |||
| AND sol.deleted = 0 | |||
| LEFT JOIN stock_out so | |||
| ON so.id = sol.stockOutId | |||
| LEFT JOIN stock_ledger sl | |||
| ON sl.stockOutLineId = sol.id | |||
| AND IFNULL(sl.deleted, 0) = 0 | |||
| AND DATE(sl.date) = :targetDate | |||
| AND IFNULL(sl.outQty, 0) > 0 | |||
| WHERE IFNULL(r2.deleted, 0) = 0 | |||
| $SOL_DO_TYPE_FILTER | |||
| GROUP BY | |||
| COALESCE(r2.targetDoId, r2.sourceDoId, 0), | |||
| r2.itemId | |||
| ) ship | |||
| ON ship.deliveryOrderId = COALESCE(r.targetDoId, r.sourceDoId, 0) | |||
| AND ship.itemId = it.id | |||
| WHERE IFNULL(r.deleted, 0) = 0 | |||
| AND ( | |||
| r.deliveryDate = :targetDate | |||
| OR IFNULL(ship.shippedQty, 0) > 0 | |||
| ) | |||
| AND NOT EXISTS ( | |||
| SELECT 1 | |||
| FROM delivery_order_line dol | |||
| WHERE dol.deleted = 0 | |||
| AND dol.itemId = r.itemId | |||
| AND dol.deliveryOrderId = COALESCE(r.targetDoId, r.sourceDoId) | |||
| ) | |||
| GROUP BY | |||
| it.id, | |||
| it.code, | |||
| it.name, | |||
| b.outputQtyUom, | |||
| b.excelUom, | |||
| uc.udfudesc, | |||
| COALESCE(r.targetDoId, r.sourceDoId, 0), | |||
| COALESCE(do.code, r.targetDoCode, r.sourceDoCode), | |||
| do.status, | |||
| r.deliveryDate, | |||
| IFNULL(r.shopCode, IFNULL(sp.code, '')), | |||
| IFNULL(r.shopName, IFNULL(sp.name, '')) | |||
| ORDER BY it.code, COALESCE(do.code, r.targetDoCode, r.sourceDoCode) | |||
| """.trimIndent() | |||
| } | |||
| } | |||
| @@ -85,10 +85,6 @@ open class ProductProcessService( | |||
| private val stockInLineRepository: StockInLineRepository, | |||
| private val qcResultRepository: QcResultRepository, | |||
| ) { | |||
| companion object { | |||
| private const val PACKAGING_PROCESS_NAME = "包裝" | |||
| private const val DRINK_PLANNED_LOOKBACK_DAYS = 30L | |||
| } | |||
| open fun findAll(pageable: Pageable): Page<ProductProcess> { | |||
| println(" Service: Finding all ProductProcess with page: ${pageable.pageNumber}, size: ${pageable.pageSize}") | |||
| @@ -1250,7 +1246,6 @@ open class ProductProcessService( | |||
| ) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 */ | |||
| open fun updateProductProcessLineStatus(productProcessLineId: Long, status: String): MessageResponse { | |||
| println(" Service: Updating ProductProcessLine Status: $productProcessLineId") | |||
| val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) | |||
| @@ -1259,12 +1254,6 @@ open class ProductProcessService( | |||
| // productProcessLine.endTime = LocalDateTime.now() | |||
| productProcessLineRepository.save(productProcessLine) | |||
| println(" Service: ProductProcessLine Status Updated: ${productProcessLine.status}") | |||
| // One packaging Complete/Pass → autoPass remaining packaging lines on the same JO. | |||
| if (isManualLineDone(status) && isPackagingLine(productProcessLine)) { | |||
| autoPassSiblingPackagingLines(productProcessLineId) | |||
| } | |||
| syncProductProcessStatusFromLines(productProcessLine.productProcess?.id ?: 0L) | |||
| return MessageResponse( | |||
| @@ -1319,7 +1308,7 @@ open class ProductProcessService( | |||
| val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | |||
| println(" Service: ProductProcessLines: $productProcessLines") | |||
| if (productProcessLines.all { isLineDone(it.status) }) { | |||
| if (productProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { | |||
| productProcess.status = ProductProcessStatus.COMPLETED | |||
| if (productProcess.endTime == null) { | |||
| productProcess.endTime = LocalDateTime.now() | |||
| @@ -1511,9 +1500,8 @@ open class ProductProcessService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 | |||
| * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass/autoPass 决定。 | |||
| * (包裝 Complete/Pass 時會自動 autoPass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。) | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 | |||
| * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 | |||
| * | |||
| * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 | |||
| */ | |||
| @@ -1663,6 +1651,10 @@ open class ProductProcessService( | |||
| val linesByJobOrder = candidateLines.groupBy { it.productProcess.jobOrder?.id ?: 0L } | |||
| val done = { s: String? -> | |||
| val x = s?.trim()?.lowercase() | |||
| x == "completed" || x == "pass" | |||
| } | |||
| val qcKeys = candidateAggregates.mapNotNull { agg -> | |||
| val jobOrderId = agg.jobOrderId | |||
| val stockInLine = stockInLineByJobOrderId[jobOrderId] | |||
| @@ -1684,12 +1676,20 @@ open class ProductProcessService( | |||
| val lineAggregate = lineStatusByJobOrderId[jobOrderId] | |||
| val totalLines = lineAggregate?.totalLines ?: 0L | |||
| // After packaging Complete/Pass autoPasses sibling 包裝 lines, qcReady is simply all lines done. | |||
| val done = { s: String? -> | |||
| val x = s?.trim()?.lowercase() | |||
| x == "completed" || x == "pass" | |||
| } | |||
| val jobLines = linesByJobOrder[jobOrderId].orEmpty() | |||
| val allLinesDone = jobLines.isNotEmpty() && jobLines.all { isLineDone(it.status) } | |||
| val packing = jobLines.filter { (it.name ?: "").trim() == "包裝" } | |||
| val nonPacking = jobLines.filter { (it.name ?: "").trim() != "包裝" } | |||
| val ready = includedInList && stockInLine != null && allLinesDone | |||
| val nonPackingOk = nonPacking.isEmpty() || nonPacking.all { done(it.status) } | |||
| val packingOk = packing.isEmpty() || packing.any { done(it.status) } | |||
| val ready = includedInList && stockInLine != null && nonPackingOk && packingOk | |||
| JobOrderQcKey( | |||
| jobOrderId = jobOrderId, | |||
| @@ -1877,7 +1877,10 @@ open class ProductProcessService( | |||
| val joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList() | |||
| val productProcessLines = linesByProcessId[productProcess.id ?: 0L].orEmpty() | |||
| val finishedCount = productProcessLines.count { isLineDone(it.status) } | |||
| val finishedCount = productProcessLines.count { | |||
| val s = it.status?.trim()?.lowercase() | |||
| s == "completed" || s == "pass" | |||
| } | |||
| val bomIsDrink = productProcess.bom?.isDrink | |||
| val matchStatus = if (joPickOrdersList.isNotEmpty() && | |||
| @@ -1951,7 +1954,7 @@ open class ProductProcessService( | |||
| } | |||
| /** | |||
| * FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass/autoPass, stock-in exists and not completed/rejected; | |||
| * FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass, stock-in exists and not completed/rejected; | |||
| * only job orders that have a product process dated **today or yesterday** (server local date). | |||
| */ | |||
| open fun findJobOrderFgQcAndPutAwayAlertsForTodayYesterday(): JobOrderFgAlertsResponse { | |||
| @@ -2005,7 +2008,7 @@ open class ProductProcessService( | |||
| val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() } | |||
| val allLinesDone = jobOrderLines.isNotEmpty() && | |||
| jobOrderLines.all { isLineDone(it.status) } | |||
| jobOrderLines.all { it.status == "Completed" || it.status == "Pass" } | |||
| if (!allLinesDone) continue | |||
| val maxDate = processes.mapNotNull { it.date }.maxOrNull() | |||
| @@ -2107,12 +2110,6 @@ open class ProductProcessService( | |||
| status?.trim()?.lowercase()?.replace(" ", "") ?: "" | |||
| private fun isLineDone(status: String?): Boolean { | |||
| val n = normalizeLineStatus(status) | |||
| return n == "completed" || n == "pass" || n == "autopass" | |||
| } | |||
| /** Manual Complete/Pass only — does not include autoPass (avoids cascade loops). */ | |||
| private fun isManualLineDone(status: String?): Boolean { | |||
| val n = normalizeLineStatus(status) | |||
| return n == "completed" || n == "pass" | |||
| } | |||
| @@ -2122,54 +2119,9 @@ open class ProductProcessService( | |||
| return n == "inprogress" || n == "paused" | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 */ | |||
| private fun isPackagingLine(line: ProductProcessLine): Boolean = | |||
| (line.name ?: "").trim() == PACKAGING_PROCESS_NAME | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 | |||
| * When one 包裝 line is Completed/Pass, set other unfinished 包裝 lines on the same job order to autoPass, | |||
| * then sync each affected ProductProcess.status (autoPass counts as done → COMPLETED). | |||
| */ | |||
| private fun autoPassSiblingPackagingLines(triggerLineId: Long) { | |||
| val trigger = productProcessLineRepository.findById(triggerLineId).orElse(null) ?: return | |||
| if (!isPackagingLine(trigger)) return | |||
| val jobOrderId = trigger.productProcess?.jobOrder?.id ?: return | |||
| val processes = productProcessRepository.findByJobOrder_Id(jobOrderId) | |||
| .filter { it.deleted != true } | |||
| val processIds = processes.mapNotNull { it.id } | |||
| if (processIds.isEmpty()) return | |||
| val allLines = productProcessLineRepository.findByProductProcess_IdIn(processIds) | |||
| val siblings = allLines.filter { line -> | |||
| line.id != null && | |||
| line.id != triggerLineId && | |||
| isPackagingLine(line) && | |||
| !isLineDone(line.status) | |||
| } | |||
| if (siblings.isEmpty()) return | |||
| val now = LocalDateTime.now() | |||
| val affectedProcessIds = linkedSetOf<Long>() | |||
| for (line in siblings) { | |||
| if (line.startTime == null) { | |||
| line.startTime = now | |||
| } | |||
| line.endTime = now | |||
| line.status = "autoPass" | |||
| productProcessLineRepository.save(line) | |||
| line.productProcess?.id?.let { affectedProcessIds.add(it) } | |||
| } | |||
| for (processId in affectedProcessIds) { | |||
| syncProductProcessStatusFromLines(processId) | |||
| } | |||
| } | |||
| /** | |||
| * Align parent [ProductProcess.status] with all line states. | |||
| * - All Completed/Pass/autoPass -> completed (via [ifAllLinesCompletedOrPassed]) | |||
| * - All Completed/Pass -> completed (via [ifAllLinesCompletedOrPassed]) | |||
| * - Any line started but not all done -> in_progress | |||
| * - Does not override STOPPED or CANCELLED on the parent. | |||
| */ | |||
| @@ -2249,8 +2201,8 @@ open class ProductProcessService( | |||
| // 获取所有 product process lines | |||
| val allproductProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | |||
| // 检查是否所有 lines 都是 Completed / Pass / autoPass | |||
| if (allproductProcessLines.all { isLineDone(it.status) }) { | |||
| // 检查是否所有 lines 都是 "Completed" 或 "Pass" | |||
| if (allproductProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { | |||
| // 更新 product process 的 endTime 和状态 | |||
| updateProductProcessEndTime(productProcessId) | |||
| updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED) | |||
| @@ -2679,6 +2631,9 @@ open class ProductProcessService( | |||
| } | |||
| // ===== Drink Production Qty Dashboard ===== | |||
| private companion object { | |||
| const val DRINK_PLANNED_LOOKBACK_DAYS = 30L | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */ | |||
| open fun getDrinkProductionQty( | |||
| @@ -2765,6 +2720,7 @@ open class ProductProcessService( | |||
| } | |||
| val drinkOrders = candidateJobOrders | |||
| .filter { it.isHidden != true } | |||
| .filter { it.bom?.isDrink == true } | |||
| .filter { jo -> | |||
| isPlannedView || jo.status != JobOrderStatus.PLANNING | |||
| @@ -3091,7 +3047,7 @@ open class ProductProcessService( | |||
| } | |||
| val currentProcesses = operatorLines | |||
| .filter { it.endTime == null || (it.status != null && !isLineDone(it.status)) } | |||
| .filter { it.endTime == null || (it.status != null && it.status != "Completed" && it.status != "Pass") } | |||
| .map { line -> | |||
| val productProcess = line.productProcess | |||
| val jobOrder = productProcess.jobOrder | |||
| @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.productProcess.web | |||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcess | |||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessLine | |||
| import com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus | |||
| import com.ffii.fpsms.modules.productProcess.service.DrinkShipmentQtyService | |||
| import com.ffii.fpsms.modules.productProcess.service.ProductProcessService | |||
| import com.ffii.fpsms.modules.productProcess.web.model.* | |||
| import org.springframework.data.domain.Page | |||
| @@ -16,7 +17,8 @@ import java.time.format.DateTimeFormatter | |||
| @RestController | |||
| @RequestMapping("/product-process") | |||
| class ProductProcessController( | |||
| private val productProcessService: ProductProcessService | |||
| private val productProcessService: ProductProcessService, | |||
| private val drinkShipmentQtyService: DrinkShipmentQtyService, | |||
| ) { | |||
| @GetMapping | |||
| @@ -333,4 +335,14 @@ class ProductProcessController( | |||
| } | |||
| return productProcessService.getDrinkProductionQty(parsedDate, viewMode) | |||
| } | |||
| @GetMapping("/Demo/DrinkShipmentQty") | |||
| fun getDrinkShipmentQty( | |||
| @RequestParam(required = false) date: String?, | |||
| ): List<DrinkShipmentQtyResponse> { | |||
| val parsedDate = date?.takeIf { it.isNotBlank() }?.let { | |||
| LocalDate.parse(it, DateTimeFormatter.ISO_DATE) | |||
| } | |||
| return drinkShipmentQtyService.getDrinkShipmentQty(parsedDate) | |||
| } | |||
| } | |||
| @@ -218,7 +218,7 @@ data class JobOrderProductProcessPageResponse( | |||
| ) | |||
| /** | |||
| * Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass/autoPass, stock-in not completed/rejected; | |||
| * Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass, stock-in not completed/rejected; | |||
| * job order has at least one process dated today or yesterday. [qc] = before received; [putAway] = received / partially_completed. | |||
| */ | |||
| data class JobOrderFgAlertRowResponse( | |||
| @@ -400,4 +400,25 @@ data class DrinkProductionQtyResponse( | |||
| val totalReqQty: BigDecimal, | |||
| val totalQty: BigDecimal, | |||
| val jobOrders: List<DrinkProductionQtyJobOrderDetail> = emptyList(), | |||
| ) | |||
| data class DrinkShipmentQtyDeliveryDetail( | |||
| val deliveryOrderId: Long, | |||
| val deliveryOrderCode: String?, | |||
| @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") | |||
| val deliveryDate: LocalDate?, | |||
| val shopCode: String?, | |||
| val shopName: String?, | |||
| val deliveryOrderStatus: String?, | |||
| val orderQty: BigDecimal, | |||
| val shippedQty: BigDecimal, | |||
| ) | |||
| data class DrinkShipmentQtyResponse( | |||
| val itemCode: String?, | |||
| val itemName: String?, | |||
| val uom: String?, | |||
| val totalOrderQty: BigDecimal, | |||
| val totalShippedQty: BigDecimal, | |||
| val deliveries: List<DrinkShipmentQtyDeliveryDetail> = emptyList(), | |||
| ) | |||