| @@ -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 stockInLineRepository: StockInLineRepository, | ||||
| private val qcResultRepository: QcResultRepository, | 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> { | open fun findAll(pageable: Pageable): Page<ProductProcess> { | ||||
| println(" Service: Finding all ProductProcess with page: ${pageable.pageNumber}, size: ${pageable.pageSize}") | 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 { | open fun updateProductProcessLineStatus(productProcessLineId: Long, status: String): MessageResponse { | ||||
| println(" Service: Updating ProductProcessLine Status: $productProcessLineId") | println(" Service: Updating ProductProcessLine Status: $productProcessLineId") | ||||
| val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) | val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) | ||||
| @@ -1259,12 +1254,6 @@ open class ProductProcessService( | |||||
| // productProcessLine.endTime = LocalDateTime.now() | // productProcessLine.endTime = LocalDateTime.now() | ||||
| productProcessLineRepository.save(productProcessLine) | productProcessLineRepository.save(productProcessLine) | ||||
| println(" Service: ProductProcessLine Status Updated: ${productProcessLine.status}") | 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) | syncProductProcessStatusFromLines(productProcessLine.productProcess?.id ?: 0L) | ||||
| return MessageResponse( | return MessageResponse( | ||||
| @@ -1319,7 +1308,7 @@ open class ProductProcessService( | |||||
| val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | ||||
| println(" Service: ProductProcessLines: $productProcessLines") | println(" Service: ProductProcessLines: $productProcessLines") | ||||
| if (productProcessLines.all { isLineDone(it.status) }) { | |||||
| if (productProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { | |||||
| productProcess.status = ProductProcessStatus.COMPLETED | productProcess.status = ProductProcessStatus.COMPLETED | ||||
| if (productProcess.endTime == null) { | if (productProcess.endTime == null) { | ||||
| productProcess.endTime = LocalDateTime.now() | 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。 | * 注意: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 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 qcKeys = candidateAggregates.mapNotNull { agg -> | ||||
| val jobOrderId = agg.jobOrderId | val jobOrderId = agg.jobOrderId | ||||
| val stockInLine = stockInLineByJobOrderId[jobOrderId] | val stockInLine = stockInLineByJobOrderId[jobOrderId] | ||||
| @@ -1684,12 +1676,20 @@ open class ProductProcessService( | |||||
| val lineAggregate = lineStatusByJobOrderId[jobOrderId] | val lineAggregate = lineStatusByJobOrderId[jobOrderId] | ||||
| val totalLines = lineAggregate?.totalLines ?: 0L | 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 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( | JobOrderQcKey( | ||||
| jobOrderId = jobOrderId, | jobOrderId = jobOrderId, | ||||
| @@ -1877,7 +1877,10 @@ open class ProductProcessService( | |||||
| val joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList() | val joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList() | ||||
| val productProcessLines = linesByProcessId[productProcess.id ?: 0L].orEmpty() | 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 bomIsDrink = productProcess.bom?.isDrink | ||||
| val matchStatus = if (joPickOrdersList.isNotEmpty() && | 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). | * only job orders that have a product process dated **today or yesterday** (server local date). | ||||
| */ | */ | ||||
| open fun findJobOrderFgQcAndPutAwayAlertsForTodayYesterday(): JobOrderFgAlertsResponse { | open fun findJobOrderFgQcAndPutAwayAlertsForTodayYesterday(): JobOrderFgAlertsResponse { | ||||
| @@ -2005,7 +2008,7 @@ open class ProductProcessService( | |||||
| val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() } | val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() } | ||||
| val allLinesDone = jobOrderLines.isNotEmpty() && | val allLinesDone = jobOrderLines.isNotEmpty() && | ||||
| jobOrderLines.all { isLineDone(it.status) } | |||||
| jobOrderLines.all { it.status == "Completed" || it.status == "Pass" } | |||||
| if (!allLinesDone) continue | if (!allLinesDone) continue | ||||
| val maxDate = processes.mapNotNull { it.date }.maxOrNull() | val maxDate = processes.mapNotNull { it.date }.maxOrNull() | ||||
| @@ -2107,12 +2110,6 @@ open class ProductProcessService( | |||||
| status?.trim()?.lowercase()?.replace(" ", "") ?: "" | status?.trim()?.lowercase()?.replace(" ", "") ?: "" | ||||
| private fun isLineDone(status: String?): Boolean { | 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) | val n = normalizeLineStatus(status) | ||||
| return n == "completed" || n == "pass" | return n == "completed" || n == "pass" | ||||
| } | } | ||||
| @@ -2122,54 +2119,9 @@ open class ProductProcessService( | |||||
| return n == "inprogress" || n == "paused" | 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. | * 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 | * - Any line started but not all done -> in_progress | ||||
| * - Does not override STOPPED or CANCELLED on the parent. | * - Does not override STOPPED or CANCELLED on the parent. | ||||
| */ | */ | ||||
| @@ -2249,8 +2201,8 @@ open class ProductProcessService( | |||||
| // 获取所有 product process lines | // 获取所有 product process lines | ||||
| val allproductProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | 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 和状态 | // 更新 product process 的 endTime 和状态 | ||||
| updateProductProcessEndTime(productProcessId) | updateProductProcessEndTime(productProcessId) | ||||
| updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED) | updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED) | ||||
| @@ -2679,6 +2631,9 @@ open class ProductProcessService( | |||||
| } | } | ||||
| // ===== Drink Production Qty Dashboard ===== | // ===== 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 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */ | ||||
| open fun getDrinkProductionQty( | open fun getDrinkProductionQty( | ||||
| @@ -2765,6 +2720,7 @@ open class ProductProcessService( | |||||
| } | } | ||||
| val drinkOrders = candidateJobOrders | val drinkOrders = candidateJobOrders | ||||
| .filter { it.isHidden != true } | |||||
| .filter { it.bom?.isDrink == true } | .filter { it.bom?.isDrink == true } | ||||
| .filter { jo -> | .filter { jo -> | ||||
| isPlannedView || jo.status != JobOrderStatus.PLANNING | isPlannedView || jo.status != JobOrderStatus.PLANNING | ||||
| @@ -3091,7 +3047,7 @@ open class ProductProcessService( | |||||
| } | } | ||||
| val currentProcesses = operatorLines | 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 -> | .map { line -> | ||||
| val productProcess = line.productProcess | val productProcess = line.productProcess | ||||
| val jobOrder = productProcess.jobOrder | 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.ProductProcess | ||||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessLine | import com.ffii.fpsms.modules.productProcess.entity.ProductProcessLine | ||||
| import com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus | 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.service.ProductProcessService | ||||
| import com.ffii.fpsms.modules.productProcess.web.model.* | import com.ffii.fpsms.modules.productProcess.web.model.* | ||||
| import org.springframework.data.domain.Page | import org.springframework.data.domain.Page | ||||
| @@ -16,7 +17,8 @@ import java.time.format.DateTimeFormatter | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/product-process") | @RequestMapping("/product-process") | ||||
| class ProductProcessController( | class ProductProcessController( | ||||
| private val productProcessService: ProductProcessService | |||||
| private val productProcessService: ProductProcessService, | |||||
| private val drinkShipmentQtyService: DrinkShipmentQtyService, | |||||
| ) { | ) { | ||||
| @GetMapping | @GetMapping | ||||
| @@ -333,4 +335,14 @@ class ProductProcessController( | |||||
| } | } | ||||
| return productProcessService.getDrinkProductionQty(parsedDate, viewMode) | 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. | * job order has at least one process dated today or yesterday. [qc] = before received; [putAway] = received / partially_completed. | ||||
| */ | */ | ||||
| data class JobOrderFgAlertRowResponse( | data class JobOrderFgAlertRowResponse( | ||||
| @@ -400,4 +400,25 @@ data class DrinkProductionQtyResponse( | |||||
| val totalReqQty: BigDecimal, | val totalReqQty: BigDecimal, | ||||
| val totalQty: BigDecimal, | val totalQty: BigDecimal, | ||||
| val jobOrders: List<DrinkProductionQtyJobOrderDetail> = emptyList(), | 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(), | |||||
| ) | ) | ||||