From 781caff39fee100b215bd66bf5212d413a7e3df5 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 10 Aug 2026 23:29:43 +0800 Subject: [PATCH 01/13] =?UTF-8?q?1.=20=E5=93=A1=E5=B7=A5=E7=99=BC=E8=B2=A8?= =?UTF-8?q?=E7=B8=BE=E6=95=88=E5=9C=96=E8=A1=A8=202.=20=20=E6=96=B0?= =?UTF-8?q?=E5=A0=B1=E8=A1=A8=EF=BC=9A=E9=80=81=E8=B2=A8=E8=A8=82=E5=96=AE?= =?UTF-8?q?=E8=88=87=E5=80=89=E5=AD=98=E5=96=AE=E4=BD=8D=E4=B8=8D=E7=AC=A6?= =?UTF-8?q?=203=20=E5=BA=97=E9=8B=AA=E8=A3=9C=E8=B2=A8=E5=A0=B1=E8=A1=A8?= =?UTF-8?q?=20=E5=8A=A0=E3=80=8C=E5=8E=9F=E5=96=AE=E6=8F=90=E6=96=99?= =?UTF-8?q?=E4=BA=BA=E3=80=8D=E3=80=8C=E5=AF=A6=E9=9A=9B=E8=A3=9C=E8=B2=A8?= =?UTF-8?q?=E6=8F=90=E6=96=99=E4=BA=BA=E3=80=8D=EF=BC=9BExcel=20=E6=AC=84?= =?UTF-8?q?=E4=BD=8D=E6=94=B9=E7=B4=94=E4=B8=AD=E6=96=87=E8=A1=A8=E9=A0=AD?= =?UTF-8?q?=204=20=20=E5=8C=85=E8=A3=9D=20autoPass=205.=20JO=20=E6=8F=90?= =?UTF-8?q?=E6=96=99=20re-suggest=20JO=20=E9=A1=9E=E5=9E=8B=20storeId=20?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=20null=EF=BC=88=E9=81=BF=E5=85=8D=E8=A2=AB?= =?UTF-8?q?=E9=A0=90=E8=A8=AD=E6=88=90=202/F=20=E6=BF=BE=E6=8E=89=203F?= =?UTF-8?q?=EF=BC=89=20=20=E5=89=8D=E7=AB=AF=E5=B0=8D=E9=BD=8A=20DO?= =?UTF-8?q?=EF=BC=9A=E7=94=A8=E5=BE=8C=E7=AB=AF=20stockout=20qty=E3=80=81?= =?UTF-8?q?=E5=90=8C=20POL=20=E4=BE=9D=20status=20=E6=8E=92=E5=BA=8F=206.?= =?UTF-8?q?=20=E4=BD=BF=E7=94=A8=E8=80=85=E5=BB=BA=E7=AB=8B=EF=BC=8F?= =?UTF-8?q?=E9=A9=97=E8=AD=89=207.=20=20BOM=20=E8=A8=AD=E5=82=99=208?= =?UTF-8?q?=E3=80=8C=E4=B8=8D=E5=90=88=E7=94=A8=EF=BC=8F=E4=B8=8D=E9=81=A9?= =?UTF-8?q?=E7=94=A8=E3=80=8D=E7=B5=B1=E4=B8=80=E4=B8=8D=E6=8E=9B=20equipm?= =?UTF-8?q?ent=20data=209=20hilde=20some=20useless=20console=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/chart/service/ChartService.kt | 79 ++++++++--- .../modules/chart/web/ChartController.kt | 8 +- .../ffii/fpsms/modules/common/ErrorCodes.java | 2 + .../service/DoWorkbenchMainService.kt | 13 +- .../service/JoWorkbenchPickConstants.kt | 1 + .../modules/master/service/BomService.kt | 12 +- .../entity/ProductProcessLineRepository.kt | 3 +- .../service/ProductProcessService.kt | 62 ++++----- .../web/ProductProcessController.kt | 2 +- .../web/model/SaveProductProcessRequest.kt | 2 +- .../DoInventoryUomMismatchReportService.kt | 128 ++++++++++++++++++ .../ShopOrderReplenishmentReportService.kt | 32 ++++- .../DoInventoryUomMismatchReportController.kt | 116 ++++++++++++++++ .../modules/user/entity/UserRepository.java | 4 + .../modules/user/service/UserService.java | 23 ++++ 15 files changed, 413 insertions(+), 74 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt diff --git a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt index 3f1c503..faaf4f0 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt @@ -717,9 +717,13 @@ open class ChartService( } /** - * Staff delivery performance: daily pick ticket count and total time per staff. + * FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 + * Staff delivery performance: daily pick ticket count, duration, item kinds, and actual picked qty. * Uses delivery_order_pick_order (handler = handledBy); time = sum of * (ticketCompleteDateTime - ticketReleaseTime) per completed ticket. + * itemKindCount = sum of per-ticket COUNT(DISTINCT pol.itemId); + * itemQtyPicked = sum of stock_out_line.qty via pick_order_line (actual picked, not pol.qty). + * Scoped CTE first (date/store/staff) then lineAgg — avoids full-history pick/sol scan. * staffNos: when non-empty, filter to these staff by user.staffNo (multi-select). * storeIdNull: when true, only rows with dop.storeId IS NULL (takes precedence over storeId). * storeId: when non-blank and storeIdNull is not true, filter dop.storeId equality (trimmed). @@ -742,12 +746,13 @@ open class ChartService( args["endExclusive"] = endDate.plusDays(1).atStartOfDay() "AND dop.ticketCompleteDateTime < :endExclusive" } else "" - val staffSql = if (!staffNos.isNullOrEmpty()) { - val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } - if (nos.isEmpty()) "" else { - args["staffNos"] = nos - "AND u.staffNo IN (:staffNos)" - } + val staffNosFiltered = staffNos?.map { it.trim() }?.filter { it.isNotBlank() }.orEmpty() + val staffSql = if (staffNosFiltered.isNotEmpty()) { + args["staffNos"] = staffNosFiltered + "AND u_scope.staffNo IN (:staffNos)" + } else "" + val scopeUserJoin = if (staffSql.isNotEmpty()) { + "LEFT JOIN user u_scope ON dop.handledBy = u_scope.id AND u_scope.deleted = 0" } else "" val storeSql = when { storeIdNull == true -> "AND dop.storeId IS NULL" @@ -758,31 +763,61 @@ open class ChartService( else -> "" } val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank() - val fromClause = if (useStoreFilter) { + val dopFromClause = if (useStoreFilter) { "FROM delivery_order_pick_order dop" } else { "FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)" } val sql = """ + WITH dop_scoped AS ( + SELECT + dop.id, + dop.ticketCompleteDateTime, + dop.ticketReleaseTime, + dop.handledBy, + dop.handlerName + $dopFromClause + $scopeUserJoin + WHERE dop.deleted = 0 + AND dop.ticketStatus = 'completed' + AND dop.ticketCompleteDateTime IS NOT NULL + $startSql $endSql $storeSql $staffSql + ), + lineAgg AS ( + SELECT + po.deliveryOrderPickOrderId AS dopId, + COUNT(DISTINCT pol.itemId) AS itemKindCount, + COALESCE(SUM(sol.qty), 0) AS itemQtyPicked + FROM dop_scoped d + INNER JOIN pick_order po + ON po.deliveryOrderPickOrderId = d.id + AND po.deleted = 0 + INNER JOIN pick_order_line pol + ON pol.poId = po.id + AND pol.deleted = 0 + LEFT JOIN stock_out_line sol FORCE INDEX (idx_sol_polid_deleted_status_qty) + ON sol.pickOrderLineId = pol.id + AND sol.deleted = 0 + GROUP BY po.deliveryOrderPickOrderId + ) SELECT - DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d') AS date, - COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), dop.handlerName, 'Unknown') AS staffName, - COUNT(dop.id) AS orderCount, + DATE_FORMAT(d.ticketCompleteDateTime, '%Y-%m-%d') AS date, + COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), d.handlerName, 'Unknown') AS staffName, + COUNT(d.id) AS orderCount, COALESCE(SUM( CASE - WHEN dop.ticketReleaseTime IS NOT NULL AND dop.ticketCompleteDateTime IS NOT NULL - THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dop.ticketReleaseTime, dop.ticketCompleteDateTime)) + WHEN d.ticketReleaseTime IS NOT NULL AND d.ticketCompleteDateTime IS NOT NULL + THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, d.ticketReleaseTime, d.ticketCompleteDateTime)) ELSE 0 END - ), 0) AS totalMinutes - $fromClause - LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0 - WHERE dop.deleted = 0 - AND dop.ticketStatus = 'completed' - AND dop.ticketCompleteDateTime IS NOT NULL - $startSql $endSql $staffSql $storeSql - GROUP BY DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d'), - dop.handledBy, u.name, dop.handlerName + ), 0) AS totalMinutes, + COALESCE(SUM(la.itemKindCount), 0) AS itemKindCount, + COALESCE(SUM(la.itemQtyPicked), 0) AS itemQtyPicked + FROM dop_scoped d + LEFT JOIN user u ON d.handledBy = u.id AND u.deleted = 0 + LEFT JOIN lineAgg la ON la.dopId = d.id + GROUP BY DATE_FORMAT(d.ticketCompleteDateTime, '%Y-%m-%d'), + d.handledBy, u.name, d.handlerName ORDER BY date, orderCount DESC """.trimIndent() return jdbcDao.queryForList(sql, args) diff --git a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt index 7d568ec..f520e77 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt @@ -194,9 +194,13 @@ class ChartController( chartService.getStaffDeliveryPerformanceHandlers() /** + * FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 * GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002&storeId=2/F&storeIdNull=true - * Returns [{ date, staffName, orderCount, totalMinutes }]. Data from delivery_order_pick_order - * (handledBy), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime). + * Returns [{ date, staffName, orderCount, totalMinutes, itemKindCount, itemQtyPicked }]. + * Data from delivery_order_pick_order (handledBy); orderCount = completed pick tickets; + * totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime); + * itemKindCount = sum of per-ticket COUNT(DISTINCT pick_order_line.itemId); + * itemQtyPicked = sum of stock_out_line.qty via pick_order_line. * Optional storeId filters delivery_order_pick_order.storeId; storeIdNull=true means IS NULL (overrides storeId). */ @GetMapping("/staff-delivery-performance") diff --git a/src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java b/src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java index 7da8d24..612aa4e 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java +++ b/src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java @@ -10,6 +10,8 @@ public class ErrorCodes { public static final String SEND_EMAIL_ERROR = "SEND_EMAIL_ERROR"; public static final String USERNAME_NOT_AVAILABLE = "USERNAME_NOT_AVAILABLE"; + public static final String NAME_NOT_AVAILABLE = "NAME_NOT_AVAILABLE"; + public static final String STAFF_NO_NOT_AVAILABLE = "STAFF_NO_NOT_AVAILABLE"; public static final String INIT_EXCEL_ERROR = "INIT_EXCEL_ERROR"; diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index 5c2bc1e..ae9d47c 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -267,7 +267,7 @@ open class DoWorkbenchMainService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.1 | 2026-08-10 * Workbench scan-pick (DO FG): * 1) Post outbound on scanned inventory lot line first; on failure return a clear message. * 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty], @@ -2386,10 +2386,15 @@ return MessageResponse( var postMs = 0L try { if (pickOrderId != null) { - val suggestionStoreId = resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) + // JO assign primes with storeId=null; re-suggest must match or 3F lots are + // filtered out after resolveWorkbenchSuggestionStoreId defaults to "2/F". + val resolvedPoType = pickOrderRepository.findById(pickOrderId).orElse(null)?.type + val suggestionStoreId = + if (resolvedPoType == PickOrderType.JOB_ORDER) null + else resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes( pickOrderId = pickOrderId, - poType = null, + poType = resolvedPoType, userId = userId, requestExcludeWarehouseCodes = effectiveExcludeWarehouseCodes, ) @@ -2620,7 +2625,7 @@ return MessageResponse( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.1 | 2026-08-10 * DO user-audit: Just Complete is reportable only when the item still has pickable stock * at complete time (AVAILABLE, not expired, in−out > 0). * diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt index 76480c9..727c54a 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt @@ -5,6 +5,7 @@ package com.ffii.fpsms.modules.jobOrder.service * * [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService]) * and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]). + * JO re-suggest keeps [storeId] null (same as assign); it does not use DO floor store resolution. */ object JoWorkbenchPickConstants { val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set = setOf( diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index 41ef63f..c0da6bd 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt @@ -1430,7 +1430,8 @@ open class BomService( } 3 -> { val equipmentName = tempCell.stringCellValue.trim() - if (equipmentName != "不適用") { + // 不合用 / 不適用:不掛 equipment(與格式檢查同等處理) + if (!isNotApplicableEquipment(equipmentName)) { val equipment = bomGetOrCreateEquipment(equipmentName) // println("equipment created") bomProcessRequest.equipment = equipment @@ -1977,10 +1978,17 @@ open class BomService( issueLogFileId = issueLogId ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 63 | v1.0.0 | 2026-08-10 */ + /** BOM 工序「使用設備」為不合用/不適用時,不掛 equipment FK */ + private fun isNotApplicableEquipment(value: String): Boolean { + val trimmed = value.trim() + return trimmed == "不合用" || trimmed == "不適用" + } + private fun isValidEquipmentType(value: String): Boolean { val trimmed = value.trim() if (trimmed.isEmpty()) return false - if (trimmed == "不合用" || trimmed == "不適用") return true + if (isNotApplicableEquipment(trimmed)) return true if (trimmed.contains(",")) return false // 新增:不允許逗號 val regex = Regex("^[^-/]+-[^-/]+$") // 例:工具類-切絲機 return regex.matches(trimmed) diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt index d121b4b..db23083 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt @@ -29,12 +29,13 @@ interface ProductProcessLineRepository : JpaRepository """) fun findByProductProcess_IdInWithOperatorAndEquipment(@Param("ids") ids: List): List + /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 */ @Query( """ SELECT p.jobOrder.id AS jobOrderId, COUNT(l.id) AS totalLines, - SUM(CASE WHEN l.status IN ('Completed', 'Pass') THEN 1 ELSE 0 END) AS doneLines + SUM(CASE WHEN l.status IN ('Completed', 'Pass', 'autoPass') THEN 1 ELSE 0 END) AS doneLines FROM ProductProcessLine l JOIN l.productProcess p WHERE l.deleted = false 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 51f0ac8..72e7aca 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 @@ -1250,7 +1250,7 @@ open class ProductProcessService( ) } - /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */ + /** 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) @@ -1260,8 +1260,8 @@ open class ProductProcessService( productProcessLineRepository.save(productProcessLine) println(" Service: ProductProcessLine Status Updated: ${productProcessLine.status}") - // One packaging Complete/Pass → auto-Pass remaining packaging lines on the same JO. - if (isLineDone(status) && isPackagingLine(productProcessLine)) { + // One packaging Complete/Pass → autoPass remaining packaging lines on the same JO. + if (isManualLineDone(status) && isPackagingLine(productProcessLine)) { autoPassSiblingPackagingLines(productProcessLineId) } @@ -1319,7 +1319,7 @@ open class ProductProcessService( val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) println(" Service: ProductProcessLines: $productProcessLines") - if (productProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { + if (productProcessLines.all { isLineDone(it.status) }) { productProcess.status = ProductProcessStatus.COMPLETED if (productProcess.endTime == null) { productProcess.endTime = LocalDateTime.now() @@ -1511,9 +1511,9 @@ open class ProductProcessService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.2 | 2026-08-09 - * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 - * (包裝 Complete/Pass 時會自動 Pass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。) + * 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,因此不再需要「任一包裝即可」特例。) * * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 */ @@ -1663,10 +1663,6 @@ 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] @@ -1688,15 +1684,10 @@ open class ProductProcessService( val lineAggregate = lineStatusByJobOrderId[jobOrderId] val totalLines = lineAggregate?.totalLines ?: 0L - val done = { s: String? -> - val x = s?.trim()?.lowercase() - x == "completed" || x == "pass" - } - - - // After packaging Complete/Pass auto-passes sibling 包裝 lines, qcReady is simply all lines done. + + // After packaging Complete/Pass autoPasses sibling 包裝 lines, qcReady is simply all lines done. val jobLines = linesByJobOrder[jobOrderId].orEmpty() - val allLinesDone = jobLines.isNotEmpty() && jobLines.all { done(it.status) } + val allLinesDone = jobLines.isNotEmpty() && jobLines.all { isLineDone(it.status) } val ready = includedInList && stockInLine != null && allLinesDone @@ -1886,10 +1877,7 @@ open class ProductProcessService( val joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList() val productProcessLines = linesByProcessId[productProcess.id ?: 0L].orEmpty() - val finishedCount = productProcessLines.count { - val s = it.status?.trim()?.lowercase() - s == "completed" || s == "pass" - } + val finishedCount = productProcessLines.count { isLineDone(it.status) } val bomIsDrink = productProcess.bom?.isDrink val matchStatus = if (joPickOrdersList.isNotEmpty() && @@ -1963,7 +1951,7 @@ open class ProductProcessService( } /** - * FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass, stock-in exists and not completed/rejected; + * FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass/autoPass, 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 { @@ -2017,7 +2005,7 @@ open class ProductProcessService( val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() } val allLinesDone = jobOrderLines.isNotEmpty() && - jobOrderLines.all { it.status == "Completed" || it.status == "Pass" } + jobOrderLines.all { isLineDone(it.status) } if (!allLinesDone) continue val maxDate = processes.mapNotNull { it.date }.maxOrNull() @@ -2119,6 +2107,12 @@ 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" } @@ -2128,14 +2122,14 @@ open class ProductProcessService( return n == "inprogress" || n == "paused" } - /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */ + /** 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.0 | 2026-08-09 - * When one 包裝 line is Completed/Pass, auto-Pass other unfinished 包裝 lines on the same job order, - * then sync each affected ProductProcess.status (Pass counts as done → COMPLETED). + * 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 @@ -2163,7 +2157,7 @@ open class ProductProcessService( line.startTime = now } line.endTime = now - line.status = "Pass" + line.status = "autoPass" productProcessLineRepository.save(line) line.productProcess?.id?.let { affectedProcessIds.add(it) } } @@ -2175,7 +2169,7 @@ open class ProductProcessService( /** * Align parent [ProductProcess.status] with all line states. - * - All Completed/Pass -> completed (via [ifAllLinesCompletedOrPassed]) + * - All Completed/Pass/autoPass -> completed (via [ifAllLinesCompletedOrPassed]) * - Any line started but not all done -> in_progress * - Does not override STOPPED or CANCELLED on the parent. */ @@ -2255,8 +2249,8 @@ open class ProductProcessService( // 获取所有 product process lines val allproductProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) - // 检查是否所有 lines 都是 "Completed" 或 "Pass" - if (allproductProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { + // 检查是否所有 lines 都是 Completed / Pass / autoPass + if (allproductProcessLines.all { isLineDone(it.status) }) { // 更新 product process 的 endTime 和状态 updateProductProcessEndTime(productProcessId) updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED) @@ -3097,7 +3091,7 @@ open class ProductProcessService( } val currentProcesses = operatorLines - .filter { it.endTime == null || (it.status != null && it.status != "Completed" && it.status != "Pass") } + .filter { it.endTime == null || (it.status != null && !isLineDone(it.status)) } .map { line -> val productProcess = line.productProcess val jobOrder = productProcess.jobOrder 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 c081000..748c661 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 @@ -200,7 +200,7 @@ class ProductProcessController( return productProcessService.getAllJoborderProductProcessInfo(bomType) } - /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 */ @GetMapping("/Demo/Process/search") fun demoprocesssearch( @RequestParam(required = false) date: String?, 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 68a8a82..ab9e59e 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 @@ -218,7 +218,7 @@ data class JobOrderProductProcessPageResponse( ) /** - * Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass, stock-in not completed/rejected; + * Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass/autoPass, 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( diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt new file mode 100644 index 0000000..a5fcde2 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt @@ -0,0 +1,128 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.format.DateTimeParseException + +@Service +class DoInventoryUomMismatchReportService( + private val jdbcDao: JdbcDao, + private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, +) { + /** + * FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.0 | 2026-08-10 + * Pending DO lines by estimatedArrivalDate whose current inventory lot UOM + * does not match the DO line UOM. Excel-only report. + * Supplier scope follows DoSearch floor lists (2F / 4F / All = both). + */ + fun searchDoInventoryUomMismatch( + deliveryDate: String?, + storeId: String? = null, + ): List> { + val raw = deliveryDate?.trim()?.replace("/", "-").orEmpty() + if (raw.isBlank()) { + throw IllegalArgumentException("deliveryDate is required") + } + + val date = try { + LocalDate.parse(raw) + } catch (_: DateTimeParseException) { + throw IllegalArgumentException("deliveryDate must be yyyy-MM-dd") + } + + val today = LocalDate.now() + if (date.isBefore(today)) { + throw IllegalArgumentException("deliveryDate must not be before today") + } + + val floor = storeId?.trim().orEmpty().let { f -> + when { + f.isEmpty() || f.equals("All", ignoreCase = true) -> "ALL" + else -> f + } + } + val allowedSupplierCodes = doFloorSupplierSettingsService.allowedSupplierCodesForFloor(floor) + if (allowedSupplierCodes.isEmpty()) { + return emptyList() + } + + val args = mutableMapOf( + "deliveryDate" to date.toString(), + "allowedSupplierCodes" to allowedSupplierCodes, + ) + + // Drive from pending DO by estimatedArrivalDate (not orderDate / pick requiredDeliveryDate). + // STRAIGHT_JOIN keeps DO CTE as driver (avoids full inventory_lot scan). + // Supplier filter matches DoSearch: d.supplier.code IN floor settings (2F/4F/All). + val sql = """ + WITH do_lines AS ( + SELECT DISTINCT + do.code AS doCode, + DATE(do.estimatedArrivalDate) AS deliveryDate, + s.code AS supplierCode, + s.name AS supplierName, + dol.id AS doLineId, + dol.itemId AS itemId, + dol.itemNo AS itemNo, + dol.qty AS doQty, + dol.uomId AS doUomId, + uc_do.udfudesc AS doUom + FROM delivery_order do + INNER JOIN shop s + ON s.id = do.supplierId + AND s.deleted = 0 + AND s.code IN (:allowedSupplierCodes) + INNER JOIN delivery_order_line dol + ON dol.deliveryOrderId = do.id + AND dol.deleted = 0 + LEFT JOIN uom_conversion uc_do + ON uc_do.id = dol.uomId + AND uc_do.deleted = 0 + WHERE do.deleted = 0 + AND do.status = 'pending' + AND do.supplierId IS NOT NULL + AND do.estimatedArrivalDate IS NOT NULL + AND DATE(do.estimatedArrivalDate) = :deliveryDate + ) + SELECT + DATE_FORMAT(d.deliveryDate, '%Y-%m-%d') AS deliveryDate, + d.doCode AS doCode, + d.supplierCode AS supplierCode, + d.supplierName AS supplierName, + d.itemNo AS itemCode, + it.name AS itemName, + d.doQty AS doQty, + d.doUom AS doUom, + d.doUomId AS doUomId, + il.lotNo AS mismatchLotNo, + (IFNULL(ill.inQty, 0) - IFNULL(ill.outQty, 0)) AS mismatchLotQty, + uc_ill.udfudesc AS mismatchLotUom, + iu.uomId AS inventoryUomId + FROM do_lines d + STRAIGHT_JOIN inventory_lot il + ON il.itemId = d.itemId + AND il.deleted = 0 + STRAIGHT_JOIN inventory_lot_line ill + ON ill.inventoryLotId = il.id + AND ill.deleted = 0 + AND (IFNULL(ill.inQty, 0) - IFNULL(ill.outQty, 0)) > 0 + STRAIGHT_JOIN item_uom iu + ON iu.id = ill.stockItemUomId + AND iu.deleted = 0 + LEFT JOIN uom_conversion uc_ill + ON uc_ill.id = iu.uomId + AND uc_ill.deleted = 0 + LEFT JOIN items it + ON it.id = d.itemId + AND it.deleted = 0 + WHERE d.doUomId IS NULL + OR iu.uomId IS NULL + OR d.doUomId <> iu.uomId + ORDER BY d.doCode, d.itemNo, il.lotNo + """.trimIndent() + + return jdbcDao.queryForList(sql, args) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt index 7319118..a66e7f5 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt @@ -15,6 +15,7 @@ import java.math.BigDecimal class ShopOrderReplenishmentReportService( private val jdbcDao: JdbcDao, ) { + /** FP-MTMS Version Checklist | Functions Ref. No. 49 | v1.0.1 | 2026-08-10 */ fun searchShopOrderReplenishmentReport( reorderDateStart: String?, reorderDateEnd: String?, @@ -87,6 +88,7 @@ class ShopOrderReplenishmentReportService( DATE_FORMAT(dr.created, '%Y-%m-%d') AS reorderDate, dr.reason AS reason, DATE_FORMAT(dop.requiredDeliveryDate, '%Y-%m-%d') AS deliveredDate, + TRIM(IFNULL(dop.handlerName, '')) AS actualDeliveredHandler, dr.sourceDoId AS sourceDoId, dr.itemId AS itemId, dr.pickOrderLineId AS pickOrderLineId @@ -130,7 +132,7 @@ class ShopOrderReplenishmentReportService( val itemIds = baseRows.mapNotNull { longVal(it["itemId"]) }.distinct() val actualDeliveredByPolId = loadActualDeliveredQtyByPickOrderLineId(pickOrderLineIds) - val firstOrderPickBySourceKey = loadFirstOrderActualPickQty(sourceDoIds, itemIds, pickOrderLineIds) + val firstOrderPickBySourceKey = loadFirstOrderPickInfo(sourceDoIds, itemIds, pickOrderLineIds) val rows = baseRows.map { row -> val polId = longVal(row["pickOrderLineId"]) @@ -138,6 +140,7 @@ class ShopOrderReplenishmentReportService( val itemId = longVal(row["itemId"]) val sourceKey = if (sourceDoId != null && itemId != null) sourceDoId to itemId else null + val firstOrder = sourceKey?.let { firstOrderPickBySourceKey[it] } linkedMapOf( "shopNo" to row["shopNo"], @@ -147,11 +150,13 @@ class ShopOrderReplenishmentReportService( "itemNo" to row["itemNo"], "itemName" to row["itemName"], "firstOrderQty" to row["firstOrderQty"], - "firstOrderActualPickQty" to (sourceKey?.let { firstOrderPickBySourceKey[it] } ?: BigDecimal.ZERO), + "firstOrderActualPickQty" to (firstOrder?.qty ?: BigDecimal.ZERO), + "firstOrderPickerHandler" to (firstOrder?.handler ?: ""), "reorderQty" to row["reorderQty"], "reorderDate" to row["reorderDate"], "reason" to row["reason"], "actualDeliveredQty" to (polId?.let { actualDeliveredByPolId[it] } ?: BigDecimal.ZERO), + "actualDeliveredHandler" to (row["actualDeliveredHandler"] ?: ""), "deliveredDate" to row["deliveredDate"], ) } @@ -185,14 +190,15 @@ class ShopOrderReplenishmentReportService( } /** - * Sum stock_out_line.qty for source DO pick order lines of the same item, + * First-order pick qty + handler for source DO + item, * excluding any POL that is itself a replenishment line (incl. current report POLs). + * Same ticket has at most one handler; MAX() is for GROUP BY only. */ - private fun loadFirstOrderActualPickQty( + private fun loadFirstOrderPickInfo( sourceDoIds: List, itemIds: List, excludePickOrderLineIds: List, - ): Map, BigDecimal> { + ): Map, FirstOrderPickInfo> { if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap() val args = mutableMapOf( @@ -210,7 +216,8 @@ class ShopOrderReplenishmentReportService( SELECT po.doId AS sourceDoId, pol.itemId AS itemId, - SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty + SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty, + MAX(TRIM(IFNULL(dop.handlerName, ''))) AS firstOrderPickerHandler FROM pick_order po INNER JOIN pick_order_line pol ON pol.poId = po.id @@ -218,6 +225,9 @@ class ShopOrderReplenishmentReportService( LEFT JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN delivery_order_pick_order dop + ON dop.id = po.deliveryOrderPickOrderId + AND IFNULL(dop.deleted, 0) = 0 WHERE IFNULL(po.deleted, 0) = 0 AND po.doId IN (:sourceDoIds) AND pol.itemId IN (:itemIds) @@ -236,10 +246,18 @@ class ShopOrderReplenishmentReportService( return rows.mapNotNull { row -> val sourceDoId = longVal(row["sourceDoId"]) ?: return@mapNotNull null val itemId = longVal(row["itemId"]) ?: return@mapNotNull null - (sourceDoId to itemId) to decimalVal(row["firstOrderActualPickQty"]) + (sourceDoId to itemId) to FirstOrderPickInfo( + qty = decimalVal(row["firstOrderActualPickQty"]), + handler = row["firstOrderPickerHandler"]?.toString()?.trim().orEmpty(), + ) }.toMap() } + private data class FirstOrderPickInfo( + val qty: BigDecimal, + val handler: String, + ) + private fun normalizeDate(raw: String): String = raw.trim().replace("/", "-") private fun dateStartClause( diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt new file mode 100644 index 0000000..c84b90e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt @@ -0,0 +1,116 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.DoInventoryUomMismatchReportService +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.io.ByteArrayOutputStream + +@RestController +@RequestMapping("/report") +class DoInventoryUomMismatchReportController( + private val doInventoryUomMismatchReportService: DoInventoryUomMismatchReportService, +) { + /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.0 | 2026-08-10 */ + @GetMapping("/print-do-inventory-uom-mismatch-excel") + fun exportExcel( + @RequestParam(required = false) deliveryDate: String?, + @RequestParam(required = false) storeId: String?, + ): ResponseEntity { + val rows = try { + doInventoryUomMismatchReportService.searchDoInventoryUomMismatch(deliveryDate, storeId) + } catch (ex: IllegalArgumentException) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build() + } + + val bytes = buildExcel(rows) + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + // ASCII only — Tomcat rejects non-Latin-1 in Content-Disposition + setContentDispositionFormData("attachment", "DoInventoryUomMismatchReport.xlsx") + } + return ResponseEntity(bytes, headers, HttpStatus.OK) + } + + private fun buildExcel(rows: List>): ByteArray { + val workbook = XSSFWorkbook() + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName("DO_UOM_mismatch")) + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + val font = workbook.createFont().apply { bold = true } + setFont(font) + } + val textStyle = workbook.createCellStyle().apply { + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val columns = listOf( + "deliveryDate" to "預計到貨日期", + "doCode" to "送貨單號", + "supplierCode" to "供應商編號", + "supplierName" to "供應商名稱", + "itemCode" to "貨品編號", + "itemName" to "貨品名稱", + "doQty" to "送貨單數量", + "doUom" to "送貨單單位", + "mismatchLotNo" to "不符批號", + "mismatchLotQty" to "不符批數量", + "mismatchLotUom" to "不符批單位", + ) + + var r = 0 + val headerRow = sheet.createRow(r++) + columns.forEachIndexed { i, (_, label) -> + headerRow.createCell(i).apply { + setCellValue(label) + cellStyle = headerStyle + } + } + + for (row in rows) { + val excelRow = sheet.createRow(r++) + columns.forEachIndexed { i, (key, _) -> + val v = row[key] + val cell = excelRow.createCell(i) + cell.cellStyle = textStyle + when (v) { + null -> cell.setCellValue("") + is Number -> cell.setCellValue(v.toDouble()) + else -> cell.setCellValue(v.toString()) + } + } + } + + val widths = intArrayOf(14, 16, 12, 18, 14, 28, 12, 14, 18, 12, 14) + widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + + val out = ByteArrayOutputStream() + workbook.write(out) + workbook.close() + return out.toByteArray() + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java b/src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java index 51d7b02..d5c0554 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java +++ b/src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java @@ -13,6 +13,8 @@ import com.ffii.fpsms.modules.user.entity.projections.UserCombo; public interface UserRepository extends AbstractRepository { List findByName(@Param("name") String name); + + List findByNameAndDeletedFalse(String name); Optional findByUsernameAndDeletedFalse(String username); @@ -20,6 +22,8 @@ public interface UserRepository extends AbstractRepository { Optional findByStaffNo(@Param("staffNo") String staffNo); + Optional findByStaffNoAndDeletedFalse(String staffNo); + @Modifying @Query(value = """ INSERT INTO user_authority (userID, authId) diff --git a/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java b/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java index 9c57e79..9832457 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java +++ b/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java @@ -92,6 +92,22 @@ public class UserService extends AbstractBaseEntityService search(SearchUserReq req) { StringBuilder sql = new StringBuilder("SELECT" @@ -199,11 +215,18 @@ public class UserService extends AbstractBaseEntityService Date: Tue, 11 Aug 2026 22:21:33 +0800 Subject: [PATCH 02/13] =?UTF-8?q?=E5=BA=AB=E5=AD=98=E5=93=81=E8=B3=AA?= =?UTF-8?q?=E6=AA=A2=E6=B8=AC=E5=A0=B1=E5=91=8A=20add=20qc=20item=20select?= =?UTF-8?q?=20control,=20search=20date=20change=20from=20stock=20in=20line?= =?UTF-8?q?=20date=20to=20qc=20reuslt=20create=20date=20=E5=BA=AB=E5=AD=98?= =?UTF-8?q?=E6=98=8E=E7=B4=B0=E5=A0=B1=E5=91=8A=20improve=20speed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 送貨訂單與倉存單位不符 excel 預計到貨→預計送貨 --- .../report/service/ItemQcFailReportService.kt | 8 +- .../service/StockLedgerReportService.kt | 376 +++++++++--------- .../DoInventoryUomMismatchReportController.kt | 4 +- .../report/web/ItemQcFailReportController.kt | 16 +- .../report/web/StockLedgerReportController.kt | 373 +++++++++-------- .../resources/jasper/StockLedgarReport.jrxml | 2 +- 6 files changed, 403 insertions(+), 376 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt index 94a9038..fc2d1b6 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt @@ -7,6 +7,7 @@ import org.springframework.stereotype.Service open class ItemQcFailReportService( private val jdbcDao: JdbcDao, ) { + /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ fun searchItemQcFailReport( stockCategory: String?, itemCode: String?, @@ -33,16 +34,19 @@ open class ItemQcFailReportService( val qcItemScopeSql = buildQcItemScopeClause(measurable, other) val measuredValueSql = buildMeasuredValueClause(measurable, scope) + // Date filter = QC result created time (true QC finish), not receiptDate / put-away. + // JO EPQC: receiptDate is often SIL create day; put-away ≈ QC via auto put-away. + // PO IQC: put-away can be days after QC — must not use ill.created for this filter. val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { val formattedDate = lastInDateStart.replace("/", "-") args["lastInDateStart"] = formattedDate - "AND DATE(sil.receiptDate) >= DATE(:lastInDateStart)" + "AND DATE(qr.created) >= DATE(:lastInDateStart)" } else "" val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) { val formattedDate = lastInDateEnd.replace("/", "-") args["lastInDateEnd"] = formattedDate - "AND DATE(sil.receiptDate) <= DATE(:lastInDateEnd)" + "AND DATE(qr.created) <= DATE(:lastInDateEnd)" } else "" val sql = """ diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 4ab4978..55c546b 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -4,17 +4,25 @@ import com.ffii.core.support.JdbcDao import org.springframework.stereotype.Service import java.time.LocalDate import java.time.format.DateTimeFormatter + @Service open class StockLedgerReportService( private val jdbcDao: JdbcDao, ) { + private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") + /** + * FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 * Stock Ledger 報表查詢 * * - stockSubCategory = items.type * - trnDate = stock_ledger.date * - trnRefNo = stock_ledger.type + * - cumBalance = stock_ledger.balance(異動後纍計存量) + * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) + * + * 只查 [start, end] 期間列,不掃起日以前全歷史。 */ fun searchStockLedgerReport( stockCategory: String?, @@ -23,18 +31,15 @@ open class StockLedgerReportService( reportPeriodStart: String?, reportPeriodEnd: String?, ): List> { - + val args = mutableMapOf() - - // 1) 先決定 reportPeriodEnd:如果有填 end,就用使用者的;否則用今天 + val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") - ?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) - - // 2) 如果有填 start,就用使用者的;否則從 DB 查最早一筆日期 + ?: LocalDate.now().format(dateFmt)) + val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { reportPeriodStart.replace("/", "-") } else { - // 用簡單 SQL 查全表最早一筆日期(可加上類似 stockCategory/itemCode 過濾) val minDateSql = """ SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate FROM stock_ledger sl @@ -42,188 +47,39 @@ open class StockLedgerReportService( AND sl.itemCode IS NOT NULL AND sl.itemCode <> '' """.trimIndent() - + val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap()).firstOrNull() (minDateRow?.get("firstDate") as? String) - ?: reportPeriodEnd // 如果表是空的,就退回用今天 + ?: reportPeriodEnd } - - // 3) 把 from/to 塞到 args,供後面 SQL 使用 + + val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) args["reportPeriodStart"] = reportPeriodStart - args["reportPeriodEnd"] = reportPeriodEnd - - // 4) 之後再用你原來的 stockCategorySql / itemCodeSql / storeLocationSql + args["reportPeriodEndExclusive"] = endExclusive + val stockCategorySql = buildMultiValueExactClause( stockCategory, "it.type", "stockCategory", args ) - + val itemCodeSql = buildMultiValueLikeClause( itemCode, "sl.itemCode", "itemCode", args ) - + + // 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數 val storeLocationSql = if (!storeLocation.isNullOrBlank()) { args["storeLocation"] = "%$storeLocation%" - "AND (wh_in.code LIKE :storeLocation OR wh_out.code LIKE :storeLocation)" + "AND lot.storeLocation LIKE :storeLocation" } else { "" } - - val reportPeriodEndSql = "AND DATE(sl.date) <= :reportPeriodEnd" - val sql = """ -WITH base AS ( - SELECT - sl.id AS slId, - DATE(sl.date) AS trnDateRaw, - DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, - CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' - AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' - ELSE COALESCE(sl.type, '') - END AS trnRefNoRaw, - - sl.itemId AS itemId, - sl.itemCode AS itemCode, - - COALESCE(sl.inQty, 0) AS inQty, - COALESCE(sl.outQty, 0) AS outQty, - (COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS delta, - - it.type AS stockSubCategory, - it.code AS itemNo, - it.name AS itemName, - uc.udfudesc AS unitOfMeasure, - - lot.lotNo AS lotNo, - COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, - lot.storeLocation AS storeLocation, - - COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, - COALESCE(TRIM(jo.code), '') AS jobOrderNo, - - '' AS openingBalance, - '' AS cumStockIn, - '' AS cumStockOut, - '' AS currentBalance, - '' AS lastInDate, - '' AS lastOutDate, - '' AS reOrderLevel, - '' AS reOrderQty - FROM stock_ledger sl - LEFT JOIN stock_in_line sil - ON sl.stockInLineId = sil.id - AND sil.deleted = 0 - LEFT JOIN inventory_lot il_in - ON sil.inventoryLotId = il_in.id - AND il_in.deleted = 0 - - LEFT JOIN stock_out_line sol - ON sl.stockOutLineId = sol.id - AND sol.deleted = 0 - LEFT JOIN inventory_lot_line ill_out - ON sol.inventoryLotLineId = ill_out.id - AND ill_out.deleted = 0 - LEFT JOIN inventory_lot il_out - ON ill_out.inventoryLotId = il_out.id - AND il_out.deleted = 0 - - LEFT JOIN ( - SELECT - il.id AS lotId, - il.lotNo AS lotNo, - il.expiryDate AS expiryDate, - MAX(wh.code) AS storeLocation - FROM inventory_lot il - LEFT JOIN inventory_lot_line ill - ON ill.inventoryLotId = il.id - AND ill.deleted = 0 - LEFT JOIN warehouse wh - ON ill.warehouseId = wh.id - AND wh.deleted = 0 - GROUP BY - il.id, il.lotNo, il.expiryDate - ) lot - ON lot.lotId = COALESCE(il_in.id, il_out.id) - - LEFT JOIN items it - ON sl.itemId = it.id - AND it.deleted = 0 - LEFT JOIN item_uom iu - ON it.id = iu.itemId - AND iu.stockUnit = 1 - AND iu.deleted = 0 - LEFT JOIN uom_conversion uc - ON iu.uomId = uc.id - - -- 這兩個 alias 是為了配合你上面 storeLocationSql 的 wh_in / wh_out - LEFT JOIN inventory_lot_line ill_in - ON il_in.id = ill_in.inventoryLotId - AND ill_in.deleted = 0 - LEFT JOIN warehouse wh_in - ON ill_in.warehouseId = wh_in.id - AND wh_in.deleted = 0 - LEFT JOIN warehouse wh_out - ON ill_out.warehouseId = wh_out.id - AND wh_out.deleted = 0 - LEFT JOIN stock_out so - ON sol.stockOutId = so.id - AND so.deleted = 0 - LEFT JOIN pick_order_line pol - ON sol.pickOrderLineId = pol.id - AND pol.deleted = 0 - LEFT JOIN pick_order po_out - ON pol.poId = po_out.id - AND po_out.deleted = 0 - LEFT JOIN job_order jo_po - ON po_out.joId = jo_po.id - AND jo_po.deleted = 0 - LEFT JOIN delivery_order do - ON po_out.doId = do.id - AND do.deleted = 0 - LEFT JOIN stock_in si - ON sil.stockInId = si.id - AND si.deleted = 0 - LEFT JOIN job_order jo - ON sil.jobOrderId = jo.id - AND jo.deleted = 0 - LEFT JOIN purchase_order po - ON sil.purchaseOrderId = po.id - AND po.deleted = 0 - WHERE - sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - AND DATE(sl.date) <= :reportPeriodEnd - $stockCategorySql - $itemCodeSql - $storeLocationSql - AND lot.lotId IS NOT NULL -), -opening AS ( - SELECT - itemCode, - COALESCE(SUM(delta), 0) AS openingBeforeStart - FROM base - WHERE trnDateRaw < :reportPeriodStart - GROUP BY itemCode -), -period AS ( - SELECT - b.*, - COALESCE(o.openingBeforeStart, 0) AS openingBeforeStart - FROM base b - LEFT JOIN opening o - ON o.itemCode = b.itemCode - WHERE b.trnDateRaw BETWEEN :reportPeriodStart AND :reportPeriodEnd -) SELECT stockSubCategory, itemNo, @@ -231,7 +87,7 @@ SELECT unitOfMeasure, lotNo, expiryDate, - trnDate, + trnDate, CASE trnRefNoRaw WHEN 'OPEN' THEN '開倉' WHEN 'NOR' THEN '出入倉' @@ -255,39 +111,171 @@ SELECT reOrderLevel, reOrderQty, - -- jrxml 需要 String;負數括號顯示,無小數 - CASE WHEN COALESCE(inQty, 0) < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(COALESCE(inQty, 0), 0) END AS stockIn, - CASE WHEN COALESCE(outQty, 0) < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(COALESCE(outQty, 0), 0) END AS stockOut, - - -- 累計存量(跨 lot:只用 itemCode 分區) - CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)) < 0 - THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)), 0), ')') - ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId), 0) END AS cumBalance, - - -- 累計期初存量 = 本行累計 - 本行異動 - CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta) < 0 - THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta), 0), ')') - ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta, 0) END AS cumOpeningBal, - - -- footer totals(同樣輸出 String) - CASE WHEN COALESCE(openingBeforeStart, 0) < 0 THEN CONCAT('(', FORMAT(-openingBeforeStart, 0), ')') ELSE FORMAT(COALESCE(openingBeforeStart, 0), 0) END AS totalCumOpeningBal, - CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockIn, - CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockOut, - CASE WHEN (openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)) < 0 - THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)), 0), ')') - ELSE FORMAT(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode), 0) END AS totalCumBalance - -FROM period + CASE WHEN inQty < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(inQty, 0) END AS stockIn, + CASE WHEN outQty < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(outQty, 0) END AS stockOut, + + CASE WHEN cumOpeningBalRaw < 0 + THEN CONCAT('(', FORMAT(-cumOpeningBalRaw, 0), ')') + ELSE FORMAT(cumOpeningBalRaw, 0) END AS cumOpeningBal, + CASE WHEN bal < 0 + THEN CONCAT('(', FORMAT(-bal, 0), ')') + ELSE FORMAT(bal, 0) END AS cumBalance, + + CASE WHEN totalCumOpeningBalRaw < 0 + THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')') + ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal, + CASE WHEN totalStockInRaw < 0 + THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')') + ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn, + CASE WHEN totalStockOutRaw < 0 + THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')') + ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut, + CASE WHEN totalCumBalanceRaw < 0 + THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')') + ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance + +FROM ( + SELECT + x.*, + FIRST_VALUE(cumOpeningBalRaw) OVER ( + PARTITION BY itemCode ORDER BY trnDateRaw, slId + ) AS totalCumOpeningBalRaw, + SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw, + SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw, + FIRST_VALUE(bal) OVER ( + PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC + ) AS totalCumBalanceRaw + FROM ( + SELECT + sl.id AS slId, + DATE(sl.date) AS trnDateRaw, + DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, + CASE + WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' + WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' + AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' + ELSE COALESCE(sl.type, '') + END AS trnRefNoRaw, + + sl.itemCode AS itemCode, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty, + COALESCE(sl.balance, 0) AS bal, + COALESCE(sl.balance, 0) + - COALESCE(sl.inQty, 0) + + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, + + it.type AS stockSubCategory, + it.code AS itemNo, + it.name AS itemName, + uc.udfudesc AS unitOfMeasure, + + lot.lotNo AS lotNo, + COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + lot.storeLocation AS storeLocation, + + COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, + COALESCE(TRIM(jo.code), '') AS jobOrderNo, + + '' AS openingBalance, + '' AS cumStockIn, + '' AS cumStockOut, + '' AS currentBalance, + '' AS lastInDate, + '' AS lastOutDate, + '' AS reOrderLevel, + '' AS reOrderQty + FROM stock_ledger sl + LEFT JOIN stock_in_line sil + ON sl.stockInLineId = sil.id + AND sil.deleted = 0 + LEFT JOIN inventory_lot il_in + ON sil.inventoryLotId = il_in.id + AND il_in.deleted = 0 + + LEFT JOIN stock_out_line sol + ON sl.stockOutLineId = sol.id + AND sol.deleted = 0 + LEFT JOIN inventory_lot_line ill_out + ON sol.inventoryLotLineId = ill_out.id + AND ill_out.deleted = 0 + LEFT JOIN inventory_lot il_out + ON ill_out.inventoryLotId = il_out.id + AND il_out.deleted = 0 + + LEFT JOIN ( + SELECT + il.id AS lotId, + il.lotNo AS lotNo, + il.expiryDate AS expiryDate, + MAX(wh.code) AS storeLocation + FROM inventory_lot il + LEFT JOIN inventory_lot_line ill + ON ill.inventoryLotId = il.id + AND ill.deleted = 0 + LEFT JOIN warehouse wh + ON ill.warehouseId = wh.id + AND wh.deleted = 0 + WHERE il.deleted = 0 + GROUP BY + il.id, il.lotNo, il.expiryDate + ) lot + ON lot.lotId = COALESCE(il_in.id, il_out.id) + + LEFT JOIN items it + ON sl.itemId = it.id + AND it.deleted = 0 + LEFT JOIN item_uom iu + ON it.id = iu.itemId + AND iu.stockUnit = 1 + AND iu.deleted = 0 + LEFT JOIN uom_conversion uc + ON iu.uomId = uc.id + + LEFT JOIN stock_out so + ON sol.stockOutId = so.id + AND so.deleted = 0 + LEFT JOIN pick_order_line pol + ON sol.pickOrderLineId = pol.id + AND pol.deleted = 0 + LEFT JOIN pick_order po_out + ON pol.poId = po_out.id + AND po_out.deleted = 0 + LEFT JOIN job_order jo_po + ON po_out.joId = jo_po.id + AND jo_po.deleted = 0 + LEFT JOIN delivery_order do + ON po_out.doId = do.id + AND do.deleted = 0 + LEFT JOIN stock_in si + ON sil.stockInId = si.id + AND si.deleted = 0 + LEFT JOIN job_order jo + ON sil.jobOrderId = jo.id + AND jo.deleted = 0 + LEFT JOIN purchase_order po + ON sil.purchaseOrderId = po.id + AND po.deleted = 0 + WHERE + sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + AND sl.date >= :reportPeriodStart + AND sl.date < :reportPeriodEndExclusive + $stockCategorySql + $itemCodeSql + $storeLocationSql + AND lot.lotId IS NOT NULL + ) x +) y ORDER BY itemNo, trnDateRaw, slId, lotNo """.trimIndent() - - val result = jdbcDao.queryForList(sql, args) - return result + return jdbcDao.queryForList(sql, args) } /** LIKE 多值工具方法 */ @@ -327,4 +315,4 @@ ORDER BY } return "AND (${conditions.joinToString(" OR ")})" } -} \ No newline at end of file +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt index c84b90e..a646fae 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt @@ -23,7 +23,7 @@ import java.io.ByteArrayOutputStream class DoInventoryUomMismatchReportController( private val doInventoryUomMismatchReportService: DoInventoryUomMismatchReportService, ) { - /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.0 | 2026-08-10 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ @GetMapping("/print-do-inventory-uom-mismatch-excel") fun exportExcel( @RequestParam(required = false) deliveryDate: String?, @@ -69,7 +69,7 @@ class DoInventoryUomMismatchReportController( } val columns = listOf( - "deliveryDate" to "預計到貨日期", + "deliveryDate" to "預計送貨日期", "doCode" to "送貨單號", "supplierCode" to "供應商編號", "supplierName" to "供應商名稱", diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt index 8565087..73902a8 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt @@ -21,6 +21,7 @@ class ItemQcFailReportController( private val itemQcFailReportService: ItemQcFailReportService, ) { + /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ @GetMapping("/print-item-qc-fail") fun generateItemQcFailReport( @RequestParam(required = false) stockCategory: String?, @@ -29,7 +30,7 @@ class ItemQcFailReportController( @RequestParam(required = false) lastInDateEnd: String?, @RequestParam(required = false) qcType: String?, @RequestParam(required = false, defaultValue = "true") includeMeasurable: String?, - @RequestParam(required = false, defaultValue = "false") includeOther: String?, + @RequestParam(required = false, defaultValue = "true") includeOther: String?, @RequestParam(required = false, defaultValue = "all") measurableScope: String?, ): ResponseEntity { val dbData = itemQcFailReportService.searchItemQcFailReport( @@ -70,6 +71,7 @@ class ItemQcFailReportController( return ResponseEntity(pdfBytes, headers, HttpStatus.OK) } + /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ @GetMapping("/print-item-qc-fail-excel") fun exportItemQcFailReportExcel( @RequestParam(required = false) stockCategory: String?, @@ -78,7 +80,7 @@ class ItemQcFailReportController( @RequestParam(required = false) lastInDateEnd: String?, @RequestParam(required = false) qcType: String?, @RequestParam(required = false, defaultValue = "true") includeMeasurable: String?, - @RequestParam(required = false, defaultValue = "false") includeOther: String?, + @RequestParam(required = false, defaultValue = "true") includeOther: String?, @RequestParam(required = false, defaultValue = "all") measurableScope: String?, ): ResponseEntity { val dbData = itemQcFailReportService.searchItemQcFailReport( @@ -297,7 +299,7 @@ class ItemQcFailReportController( "不合格數量", "實測值", "備註", - "訂單/工單" + "訂單/工單", ) run { @@ -321,12 +323,16 @@ class ItemQcFailReportController( fun writeNumber(col: Int, value: Any?) { val raw = value?.toString()?.trim() ?: "" - val cleaned = raw.removeSuffix(".") + // SQL FORMAT() may emit thousand separators (e.g. "1,021") — strip before parse. + val cleaned = raw + .replace(",", "") + .replace(" ", "") + .removeSuffix(".") val bd = cleaned.toBigDecimalOrNull() val cell = row.createCell(col) if (bd == null) { - cell.setCellValue(cleaned) + cell.setCellValue(raw) cell.cellStyle = textStyle } else { val stripped = bd.stripTrailingZeros() diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt index e7baa83..5345032 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt @@ -14,10 +14,10 @@ import org.apache.poi.ss.usermodel.HorizontalAlignment import org.apache.poi.ss.usermodel.IndexedColors import org.apache.poi.ss.usermodel.Row import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.usermodel.Workbook import org.apache.poi.ss.util.CellRangeAddress import org.apache.poi.ss.util.WorkbookUtil -import org.apache.poi.xssf.usermodel.XSSFCellStyle -import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.apache.poi.xssf.streaming.SXSSFWorkbook import java.io.ByteArrayOutputStream @RestController @@ -27,66 +27,66 @@ class StockLedgerReportController( private val stockLedgerReportService: StockLedgerReportService, ) { private data class ExcelStyles( - val title: XSSFCellStyle, - val subtitle: XSSFCellStyle, - val header: XSSFCellStyle, - val text: XSSFCellStyle, - val center: XSSFCellStyle, - val int: XSSFCellStyle, - val dash: XSSFCellStyle, - val sumQty: XSSFCellStyle, - val sumLabel: XSSFCellStyle, - val sumEmpty: XSSFCellStyle, - val sumHidden: XSSFCellStyle, + val title: CellStyle, + val subtitle: CellStyle, + val header: CellStyle, + val text: CellStyle, + val center: CellStyle, + val int: CellStyle, + val dash: CellStyle, + val sumQty: CellStyle, + val sumLabel: CellStyle, + val sumEmpty: CellStyle, + val sumHidden: CellStyle, ) + /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ @GetMapping("/print-stock-ledger") -fun generateStockLedgerReport( - @RequestParam(required = false) stockCategory: String?, - @RequestParam(required = false) itemCode: String?, - @RequestParam(required = false) storeLocation: String?, - // URL 參數名仍然是 lastInDateStart / lastInDateEnd - @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, - @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, -): ResponseEntity { - val parameters = mutableMapOf() - - parameters["stockCategory"] = stockCategory ?: "All" - parameters["stockSubCategory"] = stockCategory ?: "All" - parameters["itemNo"] = itemCode ?: "All" - parameters["year"] = LocalDate.now().year.toString() - parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) - parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + fun generateStockLedgerReport( + @RequestParam(required = false) stockCategory: String?, + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) storeLocation: String?, + @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, + @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, + ): ResponseEntity { + val parameters = mutableMapOf() - parameters["storeLocation"] = storeLocation ?: "" - parameters["balanceFilterStart"] = "" - parameters["balanceFilterEnd"] = "" - parameters["reportPeriodStart"] = reportPeriodStart ?: "" - parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" + parameters["stockCategory"] = stockCategory ?: "All" + parameters["stockSubCategory"] = stockCategory ?: "All" + parameters["itemNo"] = itemCode ?: "All" + parameters["year"] = LocalDate.now().year.toString() + parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + parameters["storeLocation"] = storeLocation ?: "" + parameters["balanceFilterStart"] = "" + parameters["balanceFilterEnd"] = "" + parameters["reportPeriodStart"] = reportPeriodStart ?: "" + parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" - val dbData = stockLedgerReportService.searchStockLedgerReport( - stockCategory = stockCategory, - itemCode = itemCode, - storeLocation = storeLocation, - reportPeriodStart = reportPeriodStart, - reportPeriodEnd = reportPeriodEnd, - ) + val dbData = stockLedgerReportService.searchStockLedgerReport( + stockCategory = stockCategory, + itemCode = itemCode, + storeLocation = storeLocation, + reportPeriodStart = reportPeriodStart, + reportPeriodEnd = reportPeriodEnd, + ) - val pdfBytes = reportService.createPdfResponse( - "/jasper/StockLedgarReport.jrxml", - parameters, - dbData - ) + val pdfBytes = reportService.createPdfResponse( + "/jasper/StockLedgarReport.jrxml", + parameters, + dbData, + ) - val headers = HttpHeaders().apply { - contentType = MediaType.APPLICATION_PDF - setContentDispositionFormData("attachment", "StockLedgerReport.pdf") - set("filename", "StockLedgerReport.pdf") + val headers = HttpHeaders().apply { + contentType = MediaType.APPLICATION_PDF + setContentDispositionFormData("attachment", "StockLedgerReport.pdf") + set("filename", "StockLedgerReport.pdf") + } + return ResponseEntity(pdfBytes, headers, HttpStatus.OK) } - return ResponseEntity(pdfBytes, headers, HttpStatus.OK) -} + /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ @GetMapping("/print-stock-ledger-excel") fun exportStockLedgerReportExcel( @RequestParam(required = false) stockCategory: String?, @@ -119,8 +119,8 @@ fun generateStockLedgerReport( return ResponseEntity(excelBytes, headers, HttpStatus.OK) } - private fun createStyles(workbook: XSSFWorkbook): ExcelStyles { - val titleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + private fun createStyles(workbook: Workbook): ExcelStyles { + val titleStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.CENTER verticalAlignment = VerticalAlignment.CENTER val font = workbook.createFont().apply { @@ -129,7 +129,7 @@ fun generateStockLedgerReport( } setFont(font) } - val subtitleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val subtitleStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.LEFT verticalAlignment = VerticalAlignment.CENTER val font = workbook.createFont().apply { @@ -138,7 +138,7 @@ fun generateStockLedgerReport( } setFont(font) } - val headerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val headerStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.CENTER verticalAlignment = VerticalAlignment.CENTER fillForegroundColor = IndexedColors.GREY_25_PERCENT.index @@ -150,7 +150,7 @@ fun generateStockLedgerReport( val font = workbook.createFont().apply { bold = true } setFont(font) } - val textStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val textStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.LEFT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THIN @@ -158,7 +158,7 @@ fun generateStockLedgerReport( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN } - val centerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val centerStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.CENTER verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THIN @@ -166,7 +166,7 @@ fun generateStockLedgerReport( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN } - val intStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val intStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THIN @@ -176,7 +176,7 @@ fun generateStockLedgerReport( val df: DataFormat = workbook.createDataFormat() dataFormat = df.getFormat("#,##0") } - val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { + val dashStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THIN @@ -184,7 +184,7 @@ fun generateStockLedgerReport( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN } - val sumQty = (workbook.createCellStyle() as XSSFCellStyle).apply { + val sumQty = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER val df: DataFormat = workbook.createDataFormat() @@ -196,7 +196,7 @@ fun generateStockLedgerReport( val font = workbook.createFont().apply { bold = true } setFont(font) } - val sumLabel = (workbook.createCellStyle() as XSSFCellStyle).apply { + val sumLabel = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THICK @@ -206,7 +206,7 @@ fun generateStockLedgerReport( val font = workbook.createFont().apply { bold = true } setFont(font) } - val sumEmpty = (workbook.createCellStyle() as XSSFCellStyle).apply { + val sumEmpty = workbook.createCellStyle().apply { alignment = HorizontalAlignment.LEFT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THICK @@ -214,7 +214,7 @@ fun generateStockLedgerReport( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN } - val sumHidden = (workbook.createCellStyle() as XSSFCellStyle).apply { + val sumHidden = workbook.createCellStyle().apply { alignment = HorizontalAlignment.LEFT verticalAlignment = VerticalAlignment.CENTER borderTop = BorderStyle.THICK @@ -239,7 +239,7 @@ fun generateStockLedgerReport( ) } - private fun setTextCell(row: Row, col: Int, value: Any?, style: XSSFCellStyle) { + private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { row.createCell(col).apply { setCellValue(value?.toString() ?: "") cellStyle = style @@ -259,8 +259,8 @@ fun generateStockLedgerReport( row: Row, col: Int, value: Any?, - intStyle: XSSFCellStyle, - dashStyle: XSSFCellStyle, + intStyle: CellStyle, + dashStyle: CellStyle, ) { val cell = row.createCell(col) val parsed = parseSignedNumber(value) @@ -279,131 +279,160 @@ fun generateStockLedgerReport( } } + /** + * SXSSF keeps only a sliding window of rows in memory to avoid OOM on large exports. + */ private fun createStockLedgerExcel( dbData: List>, reportPeriodStart: String, reportPeriodEnd: String, ): ByteArray { - val workbook = XSSFWorkbook() - val styles = createStyles(workbook) - val reportTitle = "庫存明細報告" - val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) - - val headers = listOf( - "貨品編號", "貨品名稱", "單位", - "出入賬日期", "類型", "批號", "到期日", - "纍計期初存量", "入庫", "出庫", "纍計存量", - "參考編號", "存貨位置", - ) - val totalColumns = headers.size - var rowIndex = 0 + val workbook = SXSSFWorkbook(100) + workbook.setCompressTempFiles(true) + try { + val styles = createStyles(workbook) + val reportTitle = "庫存明細報告" + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) - val titleRow = sheet.createRow(rowIndex++) - titleRow.createCell(0).apply { - setCellValue(reportTitle) - cellStyle = styles.title - } - sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) - - val reportDateTime = - LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + - "(" + - LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + - ")" - val subtitleRow = sheet.createRow(rowIndex++) - subtitleRow.createCell(0).apply { - setCellValue("報告日期:$reportDateTime") - cellStyle = styles.subtitle - } - sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4)) - subtitleRow.createCell(5).apply { - setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}") - cellStyle = styles.subtitle - } - sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1)) - sheet.createRow(rowIndex++) + val headers = listOf( + "貨品編號", "貨品名稱", "單位", + "出入賬日期", "類型", "批號", "到期日", + "纍計期初存量", "入庫", "出庫", "纍計存量", + "參考編號", "存貨位置", + ) + val totalColumns = headers.size + var rowIndex = 0 - val headerRowIndex = rowIndex - val headerRow = sheet.createRow(rowIndex++) - headers.forEachIndexed { i, h -> - headerRow.createCell(i).apply { - setCellValue(h) - cellStyle = styles.header + val titleRow = sheet.createRow(rowIndex++) + titleRow.createCell(0).apply { + setCellValue(reportTitle) + cellStyle = styles.title } - } + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) - fun addItemSummaryRow(itemNo: String, itemName: String, uom: String, totalIn: Any?, totalOut: Any?, totalBal: Any?) { - val r = sheet.createRow(rowIndex++) - r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden } - r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden } - r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden } - for (c in 3 until totalColumns) { - r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty } + val reportDateTime = + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + "(" + + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + + ")" + val subtitleRow = sheet.createRow(rowIndex++) + subtitleRow.createCell(0).apply { + setCellValue("報告日期:$reportDateTime") + cellStyle = styles.subtitle } - // totals should align with numeric columns (shift right by 1) - r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel } - setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash) - setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash) - setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash) - } - - if (dbData.isEmpty()) { - val r = sheet.createRow(rowIndex++) - for (c in 0 until totalColumns) { - r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4)) + subtitleRow.createCell(5).apply { + setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}") + cellStyle = styles.subtitle } - } else { - var currentItemNo: String? = null - var currentItemName = "" - var currentUom = "" - var lastTotals: Triple = Triple(null, null, null) + sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1)) + sheet.createRow(rowIndex++) - dbData.forEach { m -> - val itemNo = m["itemNo"]?.toString().orEmpty() - val itemName = m["itemName"]?.toString().orEmpty() - val uom = m["unitOfMeasure"]?.toString().orEmpty() + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headers.forEachIndexed { i, h -> + headerRow.createCell(i).apply { + setCellValue(h) + cellStyle = styles.header + } + } - if (currentItemNo != null && itemNo != currentItemNo) { - addItemSummaryRow(currentItemNo!!, currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third) - sheet.createRow(rowIndex++) + fun addItemSummaryRow( + itemNo: String, + itemName: String, + uom: String, + totalIn: Any?, + totalOut: Any?, + totalBal: Any?, + ) { + val r = sheet.createRow(rowIndex++) + r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden } + r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden } + r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden } + for (c in 3 until totalColumns) { + r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty } } + r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel } + setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash) + setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash) + setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash) + } + if (dbData.isEmpty()) { val r = sheet.createRow(rowIndex++) - setTextCell(r, 0, itemNo, styles.text) - setTextCell(r, 1, itemName, styles.text) - setTextCell(r, 2, uom, styles.center) - setTextCell(r, 3, m["trnDate"], styles.center) - val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t -> - if (t.equals("Expiry", ignoreCase = true)) "過期" else t + for (c in 0 until totalColumns) { + r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } } - setTextCell(r, 4, typeText, styles.center) - setTextCell(r, 5, m["lotNo"], styles.text) - setTextCell(r, 6, m["expiryDate"], styles.center) - setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash) - setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash) - setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash) - setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash) - setTextCell(r, 11, m["orderRefNo"], styles.text) - setTextCell(r, 12, m["storeLocation"], styles.center) + } else { + var currentItemNo: String? = null + var currentItemName = "" + var currentUom = "" + var lastTotals: Triple = Triple(null, null, null) + + dbData.forEach { m -> + val itemNo = m["itemNo"]?.toString().orEmpty() + val itemName = m["itemName"]?.toString().orEmpty() + val uom = m["unitOfMeasure"]?.toString().orEmpty() - currentItemNo = itemNo - currentItemName = itemName - currentUom = uom - lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"]) + if (currentItemNo != null && itemNo != currentItemNo) { + addItemSummaryRow( + currentItemNo!!, + currentItemName, + currentUom, + lastTotals.first, + lastTotals.second, + lastTotals.third, + ) + sheet.createRow(rowIndex++) + } + + val r = sheet.createRow(rowIndex++) + setTextCell(r, 0, itemNo, styles.text) + setTextCell(r, 1, itemName, styles.text) + setTextCell(r, 2, uom, styles.center) + setTextCell(r, 3, m["trnDate"], styles.center) + val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t -> + if (t.equals("Expiry", ignoreCase = true)) "過期" else t + } + setTextCell(r, 4, typeText, styles.center) + setTextCell(r, 5, m["lotNo"], styles.text) + setTextCell(r, 6, m["expiryDate"], styles.center) + setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash) + setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash) + setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash) + setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash) + setTextCell(r, 11, m["orderRefNo"], styles.text) + setTextCell(r, 12, m["storeLocation"], styles.center) + + currentItemNo = itemNo + currentItemName = itemName + currentUom = uom + lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"]) + } + + addItemSummaryRow( + currentItemNo ?: "", + currentItemName, + currentUom, + lastTotals.first, + lastTotals.second, + lastTotals.third, + ) } - addItemSummaryRow(currentItemNo ?: "", currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third) - } + val lastRowIndex = rowIndex - 1 + if (lastRowIndex >= headerRowIndex) { + sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) + } + val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12) + widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } - val lastRowIndex = rowIndex - 1 - if (lastRowIndex >= headerRowIndex) { - sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) + val out = ByteArrayOutputStream() + workbook.write(out) + return out.toByteArray() + } finally { + workbook.dispose() + workbook.close() } - val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12) - widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } - - val out = ByteArrayOutputStream() - workbook.use { it.write(out) } - return out.toByteArray() } -} \ No newline at end of file +} diff --git a/src/main/resources/jasper/StockLedgarReport.jrxml b/src/main/resources/jasper/StockLedgarReport.jrxml index 5bf97d7..a245642 100644 --- a/src/main/resources/jasper/StockLedgarReport.jrxml +++ b/src/main/resources/jasper/StockLedgarReport.jrxml @@ -79,7 +79,7 @@ - + From 0492a4b5f00cd7d8fc23cb547b987eb66625990b Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 13 Aug 2026 22:42:47 +0800 Subject: [PATCH 03/13] fix consumable suggestion, and scan handle and M18snc allow m18 acces or admin user to use --- .../fpsms/config/security/SecurityConfig.java | 10 +++++++++ .../service/DoWorkbenchMainService.kt | 21 ++++++++++++------- .../web/models/WorkbenchScanPickRequest.kt | 2 +- .../ConsumableWorkbenchPickConstants.kt | 9 +++++--- .../01_add_m18_sync_authority.sql | 8 +++++++ 5 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260813_m18_sync_authority/01_add_m18_sync_authority.sql diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index e59628a..7dfc830 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -75,6 +75,7 @@ public class SecurityConfig { } /** + * FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 * (stockAdjustment/submit → INVENTORY_ADJUST only) @@ -128,6 +129,15 @@ public class SecurityConfig { /* 工序「已完成」(Just Pass):僅 ADMIN */ .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/pass/**") .hasAuthority("ADMIN") + /* M18 手動同步頁:ADMIN 或 M18_SYNC。po-by-code 另允許 PURCHASE(採購單搜尋自動同步)。 */ + .requestMatchers(HttpMethod.GET, "/m18/test/po-by-code") + .hasAnyAuthority("ADMIN", "M18_SYNC", "PURCHASE") + .requestMatchers(HttpMethod.GET, "/m18/test/do-by-code") + .hasAnyAuthority("ADMIN", "M18_SYNC") + .requestMatchers(HttpMethod.GET, "/m18/test/do-by-code-extra") + .hasAnyAuthority("ADMIN", "M18_SYNC") + .requestMatchers(HttpMethod.GET, "/m18/test/product-by-code") + .hasAnyAuthority("ADMIN", "M18_SYNC") .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index ae9d47c..e1563c5 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -244,9 +244,10 @@ open class DoWorkbenchMainService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 * Warehouse exclude list for workbench **re-suggest** after scan-pick (shortfall / lot split). * - JO: same list as assign ([JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES]). - * - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else `null`. + * - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else empty (no limit). * - DO / other: pass through request (`null` → service default excludes). */ private fun workbenchResuggestExcludeWarehouseCodes( @@ -267,7 +268,7 @@ open class DoWorkbenchMainService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.1 | 2026-08-10 + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 * Workbench scan-pick (DO FG): * 1) Post outbound on scanned inventory lot line first; on failure return a clear message. * 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty], @@ -2364,6 +2365,7 @@ return MessageResponse( } } + /** FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 */ private fun runWorkbenchPickDeferredFollowUps( solId: Long, polId: Long, @@ -2386,12 +2388,17 @@ return MessageResponse( var postMs = 0L try { if (pickOrderId != null) { - // JO assign primes with storeId=null; re-suggest must match or 3F lots are - // filtered out after resolveWorkbenchSuggestionStoreId defaults to "2/F". + // JO / Consumable prime with storeId=null; re-suggest must match or non-2F lots + // are filtered out after resolveWorkbenchSuggestionStoreId defaults to "2/F". val resolvedPoType = pickOrderRepository.findById(pickOrderId).orElse(null)?.type val suggestionStoreId = - if (resolvedPoType == PickOrderType.JOB_ORDER) null - else resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) + if (resolvedPoType == PickOrderType.JOB_ORDER || + resolvedPoType == PickOrderType.Consumable + ) { + null + } else { + resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) + } val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes( pickOrderId = pickOrderId, poType = resolvedPoType, @@ -2625,7 +2632,7 @@ return MessageResponse( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.1 | 2026-08-10 + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 * DO user-audit: Just Complete is reportable only when the item still has pickable stock * at complete time (AVAILABLE, not expired, in−out > 0). * diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt index a3e0474..983b7d5 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt @@ -3,7 +3,7 @@ package com.ffii.fpsms.modules.deliveryOrder.web.models import java.math.BigDecimal /** - * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 * Workbench v1: scan lot and post pick immediately (no separate submit step). * [qty] optional: when null, posts up to remaining quantity for this stock-out line chunk; when set, may exceed that * chunk and is capped only by available quantity on the scanned inventory lot line (overscan / UI edit). diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt index 21c29c1..caaadd8 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt @@ -6,13 +6,16 @@ import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchPickConstants * Temporary consumable workbench exclude list until per-user DB config (Scheme A) lands. * * - [HARDCODED_EXCLUDE_USER_ID] → same warehouses as JO ([JoWorkbenchPickConstants]). - * - All other users → `null` → DO 2F default excludes in [SuggestedPickLotWorkbenchService]. + * - All other users → empty list (no warehouse limit). Do not return null: + * [SuggestedPickLotWorkbenchService] treats null as DO default 2F excludes. + * Consumable re-suggest keeps storeId null (same as first prime / JO); it does not use DO floor store resolution. */ object ConsumableWorkbenchPickConstants { const val HARDCODED_EXCLUDE_USER_ID: Long = 246L - fun resolveExcludeWarehouseCodes(userId: Long): List? { - if (userId != HARDCODED_EXCLUDE_USER_ID) return null + /** FP-MTMS Version Checklist | Functions Ref. No. 66 | v1.0.0 | 2026-08-13 */ + fun resolveExcludeWarehouseCodes(userId: Long): List { + if (userId != HARDCODED_EXCLUDE_USER_ID) return emptyList() return JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES.toList() } } diff --git a/src/main/resources/db/changelog/changes/20260813_m18_sync_authority/01_add_m18_sync_authority.sql b/src/main/resources/db/changelog/changes/20260813_m18_sync_authority/01_add_m18_sync_authority.sql new file mode 100644 index 0000000..e330e48 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260813_m18_sync_authority/01_add_m18_sync_authority.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql + +--changeset fpsms:add_m18_sync_authority +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'M18_SYNC' +--comment: Manual M18 sync page (/m18Syn): ADMIN or M18_SYNC +INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`) +VALUES ('M18_SYNC', 'M18同步', 'M18', 'Allow manual M18 sync by document or item code'); From 42ac8cf6f8a0fe20cf427ef7874cd20508b16770 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Fri, 21 Aug 2026 21:43:58 +0800 Subject: [PATCH 04/13] added for expiry date for bag printing and handle the changed id for item m18Id --- build.gradle | 4 + docs/deploy/20260819_po-m18id-remap.md | 85 + python/Bag4.py | 3074 +++++++++++++++++ python/Bag4.spec | 38 + python/__pycache__/Bag4.cpython-314.pyc | Bin 0 -> 164826 bytes python/installAndExe.txt | 28 +- ...erate_item_default_shelf_life_liquibase.py | 186 + .../fpsms/m18/service/M18MasterDataService.kt | 44 +- .../jobOrder/entity/OnPackTemplateFile.kt | 41 + .../entity/OnPackTemplateFileRepository.kt | 29 + .../service/LaserBag2AutoSendService.kt | 1 + .../service/OnPackImageTemplateCodec.kt | 45 + .../jobOrder/service/OnPackJuiceExpiryXml.kt | 152 + .../jobOrder/service/OnPackLemonExpiryXml.kt | 61 + .../service/OnPackTemplateFileService.kt | 364 ++ .../modules/jobOrder/service/OnPackXml.kt | 13 + .../service/PlasticBagPrinterService.kt | 368 +- .../jobOrder/web/OnPackTemplateController.kt | 55 + .../web/PlasticBagPrinterController.kt | 92 +- .../web/model/LaserBag2SendRequest.kt | 8 +- .../web/model/OnPackTemplateModels.kt | 29 + .../jobOrder/web/model/PlasticPrintRequest.kt | 2 + .../master/entity/ItemDefaultShelfLife.kt | 43 + .../entity/ItemDefaultShelfLifeRepository.kt | 12 + .../service/ItemDefaultShelfLifeService.kt | 83 + .../master/service/ItemM18IdRemapSupport.kt | 33 + .../modules/master/service/ItemsService.kt | 73 +- .../java/com/ffii/fpsms/py/PyController.kt | 18 +- .../com/ffii/fpsms/py/PyJobOrderListItem.kt | 7 + .../com/ffii/fpsms/py/PyJobOrderListMapper.kt | 26 + .../01_create_item_default_shelf_life.sql | 21 + .../02_seed_item_default_shelf_life.sql | 17 + ..._item_default_shelf_life_from_joexpiry.sql | 181 + ...4_item_default_shelf_life_minus18_flag.sql | 8 + ...d_item_default_shelf_life_minus18_flag.sql | 220 ++ .../01_create_onpack_template_file.sql | 21 + .../resources/onpack2030_exp/pp1181.image | Bin 0 -> 2996 bytes .../jobOrder/service/LaserBag2PayloadTest.kt | 38 + .../service/OnPackJuiceExpiryXmlTest.kt | 109 + .../service/OnPackLemonExpiryXmlTest.kt | 40 + .../service/OnPackTemplateFileServiceTest.kt | 87 + .../service/OnPackTemplateLoadQaTest.kt | 206 ++ .../ItemDefaultShelfLifeServiceTest.kt | 45 + .../service/ItemM18IdRemapSupportTest.kt | 94 + .../ffii/fpsms/py/PyJobOrderListMapperTest.kt | 46 +- 45 files changed, 6027 insertions(+), 120 deletions(-) create mode 100644 docs/deploy/20260819_po-m18id-remap.md create mode 100644 python/Bag4.py create mode 100644 python/Bag4.spec create mode 100644 python/__pycache__/Bag4.cpython-314.pyc create mode 100644 scripts/generate_item_default_shelf_life_liquibase.py create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFile.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFileRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackImageTemplateCodec.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXml.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXml.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackXml.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLife.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupport.kt create mode 100644 src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/01_create_item_default_shelf_life.sql create mode 100644 src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/02_seed_item_default_shelf_life.sql create mode 100644 src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/03_seed_item_default_shelf_life_from_joexpiry.sql create mode 100644 src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/04_item_default_shelf_life_minus18_flag.sql create mode 100644 src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/05_seed_item_default_shelf_life_minus18_flag.sql create mode 100644 src/main/resources/db/changelog/changes/20260820_onpack_template_file/01_create_onpack_template_file.sql create mode 100644 src/main/resources/onpack2030_exp/pp1181.image create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXmlTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXmlTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileServiceTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateLoadQaTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupportTest.kt diff --git a/build.gradle b/build.gradle index 7907d7f..cb4ad20 100644 --- a/build.gradle +++ b/build.gradle @@ -93,6 +93,10 @@ kotlin { jvmToolchain(17) } +test { + useJUnitPlatform() +} + bootRun { // Use db-local profile by default so datasource is loaded (application-db-local.yml). // Override with: ./gradlew bootRun --args='--spring.profiles.active=prod' diff --git a/docs/deploy/20260819_po-m18id-remap.md b/docs/deploy/20260819_po-m18id-remap.md new file mode 100644 index 0000000..4bca16f --- /dev/null +++ b/docs/deploy/20260819_po-m18id-remap.md @@ -0,0 +1,85 @@ +# Deploy note — PO line m18Id rematch (recode) +Date: 2026-08-19 +Branch / build: uncommitted (QA before prod) +Author: agent + QA + +## Summary +- PO/DO sync failed when M18 recoded a product (`proId` changed, item **code** unchanged), e.g. `PFP002PO26080303` lines `20022` / `20023` / `20024`. +- Sync now **links** the new M18 id onto the existing local item (same **code + type**) and **does not** rebuild/delete `item_uom`. + +## Scope +- Backend: `ItemsService.saveItem`, `M18MasterDataService.saveProduct` / `saveProducts`, remap helper +- Frontend: none +- DB / Liquibase: none +- Config / ops: none (use existing `GET /m18/test/po-by-code`) + +## Commits +- (not committed yet) + +## Safety rules (what this deploy will / will not do) + +| Situation | Behaviour | +|-----------|-----------| +| Local item already has that `m18Id` | Unchanged: normal product/PO update | +| Same **code + type**, new unused `m18Id` | Update `items.m18Id` only. **No** UOM delete/rebuild, **no** QC/name wipe | +| New `m18Id` already on **another** item | Refuse (keep old mapping) | +| Same code, **different** type | Will **not** auto-link (same as before for type mismatch) | +| Brand-new M18 product, no local code | Creates item + UOMs as before | +| Scheduled product sync of already-mapped items | Unchanged full product/UOM sync | +| Local PO status not `PENDING` | PO resync is **skipped** (existing rule) | + +## Pre-check SQL (prod or staging, before resync) + +```sql +-- 1) Failed PO lines for this PO +SELECT d.id, d.m18Id AS m18_line_id, d.status, d.dataLog, d.createDate +FROM m18_data_log d +WHERE d.refType = 'Purchase Order Line' + AND d.status = 'FAIL' + AND d.dataLog LIKE '%PFP002PO26080303%' +ORDER BY d.id DESC +LIMIT 20; + +-- 2) Local PO must be PENDING +SELECT id, code, status, m18Id +FROM purchase_order +WHERE code = 'PFP002PO26080303' AND deleted = 0; + +-- 3) After you know the 3 M18 product codes, confirm one local row each +SELECT id, code, name, type, m18Id +FROM items +WHERE deleted = 0 AND code IN ('CODE1', 'CODE2', 'CODE3'); +``` + +Confirm each of the three codes has **exactly one** local row, and its current `m18Id` is **not** already `20022`/`20023`/`20024` on a *different* item: + +```sql +SELECT id, code, m18Id FROM items +WHERE deleted = 0 AND m18Id IN (20022, 20023, 20024); +``` + +Expected before fix: **no rows** (or only unrelated items — if any, **do not** auto-resync; fix mapping manually). + +## Test plan + +| # | Steps (who / where / data) | Expected result | +|---|----|-----| +| 1 | **Staging first.** Snapshot `items.m18Id` + `item_uom` counts for the 3 item codes. | Baseline recorded | +| 2 | Confirm M18 products `20022`/`20023`/`20024` still have the **same codes** as local items. | Codes match; types still map to the same FPSMS `type` | +| 3 | Confirm local PO `PFP002PO26080303` status is **PENDING**. | If not pending, stop — resync will skip the whole PO | +| 4 | Call `GET /m18/test/po-by-code?code=PFP002PO26080303` (auth as ops). | HTTP 200; `totalFail` for those 3 lines is 0 (or success list includes the PO) | +| 5 | SQL: `items.m18Id` for the 3 codes is now 20022/20023/20024. `item_uom` **row count and ids unchanged**. QC category / shelf life unchanged. | Remap-only; no UOM wipe | +| 6 | UI: PO workbench / PO detail for `PFP002PO26080303` shows **6 lines** (previously 3 missing). | Failed lines created | +| 7 | **Regression:** pick a normal mapped item, run scheduled or `GET /m18/product/{existingM18Id}`. | Item still updates as before; UOMs still sync | +| 8 | Confirm no new `PO_LINE FAIL` email for this PO after resync. | Alert not re-sent for these 3 proIds | + +## Out of scope / not tested +- Recoded products whose **item code also changed** (will not auto-link) +- Non-`PENDING` PO overwrite (by design skipped) +- DO with the same recode (same `resolveLocalItemId` path; spot-check only if a DO uses these 3 items) +- Live M18 UOM id change on the recoded product (PO qty conversion still uses existing purchase UOM) + +## Rollback +- Revert backend deploy (no Liquibase, no frontend). +- If only `items.m18Id` was updated, restore previous `m18Id` from the pre-check snapshot. PO lines already inserted stay; delete/fix only if QA rejects the PO. +- Do **not** run a full product resync on the new proIds expecting to undo UOMs — this path does not rewrite UOMs. diff --git a/python/Bag4.py b/python/Bag4.py new file mode 100644 index 0000000..b5f924f --- /dev/null +++ b/python/Bag4.py @@ -0,0 +1,3074 @@ +#!/usr/bin/env python3 +""" +Bag4 v4.0 – FPSMS job orders by plan date, with DataFlex expiry under Lot. + +Uses the public API GET /py/job-orders and POST /py/job-order-print-submit (no login required). +Same as Bag3, plus expiry (today + defaultShelfLifeDays) on DataFlex ZPL and as the 4th laser TCP param. + +Bag3 remains the line without expiry on DataFlex; do not share settings files. + +Run: python Bag4.py +""" + +import errno +import json +import os +import select +import socket +import sys +import tempfile +import threading +import time +import tkinter as tk +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from tkinter import messagebox, ttk +from typing import Callable, Optional, Tuple + +import requests + +# UI "列印機" check uses short TCP probes; DataFlex may refuse connections briefly during E1005 recovery. +_DATAFLEX_RECOVERY_GRACE_UNTIL: float = 0.0 + + +def touch_dataflex_recovery_grace(seconds: float = 22.0) -> None: + """While host reset runs, avoid flashing printer status to red.""" + global _DATAFLEX_RECOVERY_GRACE_UNTIL + u = time.time() + max(0.0, seconds) + if u > _DATAFLEX_RECOVERY_GRACE_UNTIL: + _DATAFLEX_RECOVERY_GRACE_UNTIL = u + + +try: + import serial +except ImportError: + serial = None # type: ignore + +try: + import win32print # type: ignore[import] + import win32ui # type: ignore[import] + import win32con # type: ignore[import] + import win32gui # type: ignore[import] +except ImportError: + win32print = None # type: ignore[assignment] + win32ui = None # type: ignore[assignment] + win32con = None # type: ignore[assignment] + win32gui = None # type: ignore[assignment] + +try: + from PIL import Image, ImageDraw, ImageFont, ImageOps + try: + from PIL import ImageWin # type: ignore + except Exception: + ImageWin = None # type: ignore[assignment] + import qrcode + _HAS_PIL_QR = True +except ImportError: + Image = None # type: ignore[assignment] + ImageDraw = None # type: ignore[assignment] + ImageFont = None # type: ignore[assignment] + ImageOps = None # type: ignore[assignment] + ImageWin = None # type: ignore[assignment] + qrcode = None # type: ignore[assignment] + _HAS_PIL_QR = False + +APP_VERSION = "4.0" + +DEFAULT_BASE_URL = os.environ.get("FPSMS_BASE_URL", "http://localhost:8090/api") +# When run as PyInstaller exe, save settings next to the exe; otherwise next to script +if getattr(sys, "frozen", False): + _SETTINGS_DIR = os.path.dirname(sys.executable) +else: + _SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__)) +# Bag4 has its own settings file so it doesn't share with Bag1/Bag2/Bag3. +SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag4_settings.json") +LASER_COUNTER_FILE = os.path.join(_SETTINGS_DIR, "bag4_last_batch_count.txt") + +DEFAULT_SETTINGS = { + "api_ip": "localhost", + "api_port": "8090", + "dabag_ip": "", + "dabag_port": "3008", + "laser_ip": "192.168.17.10", + "laser_port": "45678", + # For 標簽機 on Windows, this is the Windows printer name, e.g. "TSC TTP-246M Pro" + "label_com": "TSC TTP-246M Pro", +} + + +def load_settings() -> dict: + """Load settings from JSON file; return defaults if missing or invalid.""" + try: + if os.path.isfile(SETTINGS_FILE): + with open(SETTINGS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return {**DEFAULT_SETTINGS, **data} + except Exception: + pass + return dict(DEFAULT_SETTINGS) + + +def save_settings(settings: dict) -> None: + """Save settings to JSON file.""" + with open(SETTINGS_FILE, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=2, ensure_ascii=False) + + +def build_base_url(api_ip: str, api_port: str) -> str: + ip = (api_ip or "localhost").strip() + port = (api_port or "8090").strip() + return f"http://{ip}:{port}/api" + + +def try_printer_connection(printer_name: str, sett: dict) -> bool: + """Try to connect to the selected printer (TCP IP:port or COM). Returns True if OK.""" + if printer_name == "打袋機 DataFlex": + ip = (sett.get("dabag_ip") or "").strip() + port_str = (sett.get("dabag_port") or "3008").strip() + if not ip: + return False + try: + port = int(port_str) + except ValueError: + return False + # Retry once: firmware often busy for ~1s after E1005 / blank label. + timeout = max(PRINTER_SOCKET_TIMEOUT, 6.0) + for attempt in range(2): + try: + s = socket.create_connection((ip, port), timeout=timeout) + s.close() + return True + except (socket.error, OSError): + if attempt == 0: + time.sleep(0.35) + continue + return False + if printer_name == "激光機": + ip = (sett.get("laser_ip") or "").strip() + port_str = (sett.get("laser_port") or "45678").strip() + if not ip: + return False + try: + port = int(port_str) + s = socket.create_connection((ip, port), timeout=PRINTER_SOCKET_TIMEOUT) + s.close() + return True + except (socket.error, ValueError, OSError): + return False + if printer_name == "標簽機": + target = (sett.get("label_com") or "").strip() + if not target: + return False + # On Windows, allow using a Windows printer name (e.g. "TSC TTP-246M Pro") + # as an alternative to a COM port. If it doesn't look like a COM port, + # try opening it via the Windows print spooler. + if os.name == "nt" and not target.upper().startswith("COM"): + if win32print is None: + return False + try: + handle = win32print.OpenPrinter(target) + win32print.ClosePrinter(handle) + return True + except Exception: + return False + # Fallback: treat as serial COM port (original behaviour) + if serial is None: + return False + try: + ser = serial.Serial(target, timeout=1) + ser.close() + return True + except (serial.SerialException, OSError): + return False + return False + +# Larger font for aged users (point size) +FONT_SIZE = 16 +FONT_SIZE_BUTTONS = 15 +# Printer selector: field + dropdown (use tk.OptionMenu so menu font is respected on Windows) +FONT_SIZE_COMBO = 18 +FONT_SIZE_QTY = 12 # smaller for 需求數量 under batch no. +FONT_SIZE_META = 11 # single-line 需求/已印 (compact list) +# Less vertical padding so ~30 rows fit more comfortably +LIST_ROW_PADY = 2 +LIST_ROW_IPADY = 5 +FONT_SIZE_ITEM = 20 # item code and item name (larger for readability) +FONT_FAMILY = "Microsoft JhengHei UI" # Traditional Chinese; fallback to TkDefaultFont +FONT_SIZE_ITEM_CODE = 20 # item code (larger for readability) +FONT_SIZE_ITEM_NAME = 26 # item name (bigger than item code) +# Column widths: fixed frame widths so 品號/品名 columns line up across rows +LEFT_COL_WIDTH_PX = 300 # 工單 + 需求/已印 block +ITEM_CODE_WRAP = 140 # Label wraplength (px) +# Narrower than wrap+padding so short codes sit closer to 品名 (still aligned across rows) +CODE_COL_WIDTH_PX = ITEM_CODE_WRAP + 6 +ITEM_NAME_WRAP = 640 # item name wraps in remaining space + +# Light blue theme (softer than pure grey) +BG_TOP = "#E8F4FC" +BG_LIST = "#D4E8F7" +BG_ROOT = "#E1F0FF" +BG_ROW = "#C5E1F5" +BG_ROW_SELECTED = "#6BB5FF" # highlighted when selected (for printing) +# Connection status bar +BG_STATUS_ERROR = "#FFCCCB" # red background when disconnected +FG_STATUS_ERROR = "#B22222" # red text +BG_STATUS_OK = "#90EE90" # light green when connected +FG_STATUS_OK = "#006400" # green text +RETRY_MS = 30 * 1000 # 30 seconds reconnect +# POST /py/job-order-print-submit: retries when server is briefly unavailable +PRINT_SUBMIT_MAX_ATTEMPTS = 5 +PRINT_SUBMIT_RETRY_DELAY_SEC = 1.5 +PRINTER_CHECK_MS = 60 * 1000 # 1 minute when printer OK +PRINTER_RETRY_MS = 30 * 1000 # 30 seconds when printer failed +PRINTER_SOCKET_TIMEOUT = 3 +DATAFLEX_SEND_TIMEOUT = 10 # seconds when sending ZPL to DataFlex + + +def _dataflex_float_env(name: str, default: float) -> float: + raw = (os.environ.get(name) or "").strip() + if not raw: + return default + try: + return float(raw) + except ValueError: + return default + + +def _dataflex_bool_env(name: str, default: bool) -> bool: + raw = (os.environ.get(name) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _dataflex_int_env(name: str, default: int) -> int: + raw = (os.environ.get(name) or "").strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + return default + + +# Job list auto-refresh interval (ms). 0 = off (list rebuild can hide DataFlex「停止列印」). +# Re-enable: FPSMS_JOB_LIST_REFRESH_MS=60000 +JOB_LIST_AUTO_REFRESH_MS = max(0, _dataflex_int_env("FPSMS_JOB_LIST_REFRESH_MS", 0)) +# Defer full list rebuild while printing; retry after this interval until idle. FPSMS_JOB_LIST_DEFER_WHILE_PRINTING_MS +JOB_LIST_DEFER_WHILE_PRINTING_MS = max( + 500, + _dataflex_int_env("FPSMS_JOB_LIST_DEFER_WHILE_PRINTING_MS", 1500), +) + + +# Gap between bag labels (after each job has fully left the client). Tune if bags are blank/skipped. +# Override: FPSMS_DATAFLEX_INTER_LABEL_DELAY_SEC (e.g. 3.5 if ~3–5% blanks per 100). +DATAFLEX_INTER_LABEL_DELAY_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_INTER_LABEL_DELAY_SEC", 0.3 +) +# Brief pause after each ZPL send so firmware can commit before we FIN the socket (reduces dropped/blank jobs). +# Override: FPSMS_DATAFLEX_POST_LABEL_SETTLE_SEC +DATAFLEX_POST_LABEL_SETTLE_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_POST_LABEL_SETTLE_SEC", 0.08 +) +# Before each print job: light reset only (~JA + ~RO). Must finish before first ^XA or first label can be lost. +# Override: FPSMS_DATAFLEX_POST_PREPRINT_DELAY_SEC +DATAFLEX_PREPRINT_BYTES = b"~JA\r\n~RO1\r\n~RO2\r\n" +DATAFLEX_POST_PREPRINT_DELAY_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_POST_PREPRINT_DELAY_SEC", 0.55 +) +# Whether each new print job starts with full reset (~JR) so DataFlex batch counter returns to 0. +# Set FPSMS_DATAFLEX_FULL_RESET_EACH_JOB=0 to keep only light preprint reset. +DATAFLEX_FULL_RESET_EACH_JOB = _dataflex_bool_env( + "FPSMS_DATAFLEX_FULL_RESET_EACH_JOB", False +) +# Extra-safe mode: run light preprint reset (~JA/~RO) before EVERY bag. +# Slower, but reduces E1005 on unstable firmware. +DATAFLEX_PREPRINT_EACH_LABEL = _dataflex_bool_env( + "FPSMS_DATAFLEX_PREPRINT_EACH_LABEL", False +) +DATAFLEX_VERIFY_STATUS_AFTER_SEND = _dataflex_bool_env( + "FPSMS_DATAFLEX_VERIFY_STATUS_AFTER_SEND", False +) +# Hard-disable automatic reset/counter commands during printing. +# When False, normal print path sends ZPL only (no ~JA/~RO/~JR). +DATAFLEX_AUTO_RESET_ENABLED = _dataflex_bool_env( + "FPSMS_DATAFLEX_AUTO_RESET_ENABLED", False +) +# After a failed TCP send, always run host recovery + retry (recommended when E1005 stops the run). +DATAFLEX_RECOVER_ON_SEND_ERROR = _dataflex_bool_env( + "FPSMS_DATAFLEX_RECOVER_ON_SEND_ERROR", True +) +DATAFLEX_STATUS_QUERY_TIMEOUT_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_STATUS_QUERY_TIMEOUT_SEC", 0.8 +) +DATAFLEX_RECOVERY_MAX_ATTEMPTS = max( + 1, + _dataflex_int_env("FPSMS_DATAFLEX_RECOVERY_MAX_ATTEMPTS", 2), +) +DATAFLEX_RECOVERY_WAIT_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_RECOVERY_WAIT_SEC", 0.8 +) +# Prevent cumulative thermal/mechanical fault in long runs (E1000 after ~40 bags on some units): +# pause briefly every N bags. +DATAFLEX_COOLDOWN_EVERY_LABELS = max( + 0, + _dataflex_int_env("FPSMS_DATAFLEX_COOLDOWN_EVERY_LABELS", 8), +) +DATAFLEX_COOLDOWN_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_COOLDOWN_SEC", 3.5 +) +# Extra long pause every M bags (head cool-down). 0 = off. FPSMS_DATAFLEX_THERMAL_REST_EVERY_LABELS +DATAFLEX_THERMAL_REST_EVERY_LABELS = max( + 0, + _dataflex_int_env("FPSMS_DATAFLEX_THERMAL_REST_EVERY_LABELS", 20), +) +DATAFLEX_THERMAL_REST_SEC = _dataflex_float_env( + "FPSMS_DATAFLEX_THERMAL_REST_SEC", 5.0 +) +# Light ~HS check every N bags. Default off — periodic checks + recovery caused long stalls with E1005 on some units. +DATAFLEX_VERIFY_EVERY_LABELS = max( + 0, + _dataflex_int_env("FPSMS_DATAFLEX_VERIFY_EVERY_LABELS", 0), +) +# Status bar progress while printing (main thread). 0 = off. +DATAFLEX_UI_PROGRESS_EVERY = max( + 0, + _dataflex_int_env("FPSMS_DATAFLEX_UI_PROGRESS_EVERY", 5), +) +# One TCP send: single ZPL with ^PQn (n identical bags). Some DataFlex units may fault (E1005); default off. +DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( + "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False +) +# Link-OS SGD: raw-ZPL job shows this host id instead of a generic name (e.g. "ZPL. EMULATION"). +# Same id when the same job order is printed again. Disable: FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD=0 +DATAFLEX_HOST_IDENTIFICATION_SGD = _dataflex_bool_env( + "FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD", True +) +# Bag ZPL size (dots). ^PW700 matched little content (mostly vertical ^A@R), so previews showed a wide strip with empty right margin. +DATAFLEX_LABEL_PW = max( + 280, + _dataflex_int_env("FPSMS_DATAFLEX_LABEL_PW", 400), +) +DATAFLEX_LABEL_LL = max( + 200, + _dataflex_int_env("FPSMS_DATAFLEX_LABEL_LL", 500), +) +# Some Zebra/DataFlex units RST the socket on host half-close; Windows surfaces WinError 10054. +# Set FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR=1 to omit shutdown(SHUT_WR) and only close() (often avoids RST). +DATAFLEX_SKIP_SHUTDOWN_WR = _dataflex_bool_env( + "FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR", False +) +# Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware +DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 +# Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) +DATAFLEX_RESET_BYTES = b"~RO1\r\n~RO2\r\n" +# Full host recovery: ~JA clear buffers, ~RO counters, ~JR soft reset (clears latched errors without power cycle) +DATAFLEX_FULL_RECOVERY_BYTES = b"~JA\r\n~RO1\r\n~RO2\r\n~JR\r\n" + + +def _dataflex_full_recovery_payload() -> bytes: + """~JA+~RO+~JR for manual「打袋重設」; set env FPSMS_DATAFLEX_NO_JR=1 to skip ~JR.""" + if os.environ.get("FPSMS_DATAFLEX_NO_JR", "").strip().lower() in ("1", "true", "yes"): + return b"~JA\r\n" + DATAFLEX_RESET_BYTES + return DATAFLEX_FULL_RECOVERY_BYTES + + +def _zpl_escape(s: str) -> str: + """Escape text for ZPL ^FD...^FS (backslash and caret).""" + return s.replace("\\", "\\\\").replace("^", "\\^") + + +def _dataflex_host_identification_sgd_prefix(job_order_id: Optional[int]) -> str: + """ + Optional ASCII prefix before ^XA: set zpl.host_identification so the printer lists the job + under the job order id instead of a generic raw-ZPL label. + """ + if not DATAFLEX_HOST_IDENTIFICATION_SGD or job_order_id is None: + return "" + try: + jid = str(int(job_order_id)) + except (TypeError, ValueError): + return "" + if not jid.isdigit(): + return "" + return f'! U1 setvar "zpl.host_identification" "{jid}"\r\n' + + +def _dataflex_zpl_bytes(zpl: str) -> bytes: + """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" + s = (zpl or "").rstrip("\r\n") + return (s + "\r\n").encode("utf-8") + + +def _dataflex_is_benign_tcp_reset(err: BaseException) -> bool: + """True when peer closed with RST/FIN in a way that is normal for raw printer TCP (Windows 10054).""" + if isinstance(err, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)): + return True + if isinstance(err, OSError): + if getattr(err, "winerror", None) == 10054: # WSAECONNRESET + return True + if err.errno in ( + errno.ECONNRESET, + errno.EPIPE, + errno.ECONNABORTED, + ): + return True + return False + + +def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None: + """Half-close write side; ignore printer RST (common after ZPL on port 9100-style links).""" + if DATAFLEX_SKIP_SHUTDOWN_WR: + return + try: + sock.shutdown(socket.SHUT_WR) + except OSError as e: + if _dataflex_is_benign_tcp_reset(e): + return + raise + + +def format_expiry_print_label(d: date) -> str: + """Printed bag wording, e.g. Expiry Date 20260821.""" + return f"Expiry Date {d.strftime('%Y%m%d')}" + + +def job_expiry_zpl_text(jo: dict) -> Optional[str]: + """Expiry yyyyMMdd from print-time shelf life days, else API expiryDate.""" + days = jo.get("defaultShelfLifeDays") + if isinstance(days, bool): + days = None + if isinstance(days, float) and days.is_integer(): + days = int(days) + if isinstance(days, int) and days > 0: + return format_expiry_print_label(date.today() + timedelta(days=days)) + iso = jo.get("expiryDate") + if iso: + s = str(iso).strip()[:10] + try: + y, m, day = (int(p) for p in s.split("-")) + return format_expiry_print_label(date(y, m, day)) + except ValueError: + return None + return None + + +def job_expiry_laser_param(jo: dict) -> str: + """Same expiry as DataFlex ZPL, sanitized for `;`-separated laser TCP.""" + return (job_expiry_zpl_text(jo) or "").replace(";", ",") + + +def generate_zpl_dataflex( + batch_no: str, + item_code: str, + item_name: str, + item_id: Optional[int] = None, + stock_in_line_id: Optional[int] = None, + lot_no: Optional[str] = None, + job_order_id: Optional[int] = None, + expiry_text: Optional[str] = None, + font_regular: str = "E:STXihei.ttf", + font_bold: str = "E:STXihei.ttf", +) -> str: + """ + Row 1 (from zero): QR code, then item name (rotated 90°). + Row 2: smaller Lot, then expiry at the next X, then item code. + Text is ^A@R (90° CW); the next line under Lot is +X, not +Y. + Label and QR use lotNo from API when present, else batch_no (Bxxxxx). + Light preprint (~JA/~RO) is sent before labels; full ~JR recovery is only for「打袋重設」. + """ + desc = _zpl_escape((item_name or "—").strip()) + code = _zpl_escape((item_code or "—").strip()) + label_line = (lot_no or batch_no or "").strip() + label_esc = _zpl_escape(label_line) + exp_raw = (expiry_text or "").strip() + exp_esc = _zpl_escape(exp_raw) if exp_raw else "" + # QR payload: prefer JSON {"itemId":..., "stockInLineId":...} when both present; else fall back to lot/batch text + if item_id is not None and stock_in_line_id is not None: + qr_payload = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}) + else: + qr_payload = label_line if label_line else batch_no.strip() + qr_value = _zpl_escape(qr_payload) + # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex + # firmware when many labels are sent on one TCP session without a per-job quantity. + host_id = _dataflex_host_identification_sgd_prefix(job_order_id) + if exp_esc: + row2 = f"""^FO0,200 +^A@R,40,40,{font_regular}^FD{label_esc}^FS +^FO42,200 +^A@R,40,40,{font_regular}^FD{exp_esc}^FS +^FO88,200 +^A@R,88,88,{font_bold}^FD{code}^FS""" + else: + row2 = f"""^FO0,200 +^A@R,72,72,{font_regular}^FD{label_esc}^FS +^FO55,200 +^A@R,88,88,{font_bold}^FD{code}^FS""" + return host_id + f"""^XA +^PQ1,0,1,N +^CI28 +^PW{DATAFLEX_LABEL_PW} +^LL{DATAFLEX_LABEL_LL} +^PO N +^FO10,20 +^BQN,2,4^FDQA,{qr_value}^FS +^FO170,20 +^A@R,72,72,{font_regular}^FD{desc}^FS +{row2} +^XZ""" + + +def dataflex_zpl_set_print_quantity(zpl: str, copies: int) -> str: + """ + Replace the fixed ^PQ1 line from generate_zpl_dataflex() with ^PQn so one ZPL job prints + n identical bags over one TCP connection. + """ + if copies < 1: + copies = 1 + old = "^PQ1,0,1,N" + if old not in zpl: + raise RuntimeError( + "DataFlex ZPL 缺少預期的 ^PQ1 列(無法改為單次連線多張)。" + ) + return zpl.replace(old, f"^PQ{copies},0,1,N", 1) + + +def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) -> None: + """ + Fast prep before printing: ~JA + ~RO (no ~JR). Clears buffer and zeros batch counters so the first + bag starts quickly. Use before fixed-qty batch and continuous mode. + """ + if not force and not DATAFLEX_AUTO_RESET_ENABLED: + return + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + sock.settimeout(DATAFLEX_SEND_TIMEOUT) + try: + sock.connect((ip, port)) + sock.sendall(DATAFLEX_PREPRINT_BYTES) + time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) + _dataflex_shutdown_write_maybe(sock) + finally: + sock.close() + + +def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False) -> None: + """ + Full host recovery for「打袋重設」: ~JA, ~RO, and ~JR (soft reset) to clear latched E1005. + Slower than [send_dataflex_preprint_reset]; do not use on every row click. + """ + if not force and not DATAFLEX_AUTO_RESET_ENABLED: + return + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + sock.settimeout(DATAFLEX_SEND_TIMEOUT) + try: + sock.connect((ip, port)) + sock.sendall(_dataflex_full_recovery_payload()) + time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) + _dataflex_shutdown_write_maybe(sock) + finally: + sock.close() + + +def send_dataflex_start_job_reset(ip: str, port: int, *, force: bool = False) -> None: + """ + Start-of-job reset sequence. + + Full reset first (default) ensures printer-side batch quantity returns to 0 for each job; + then light preprint reset prepares the first bag send. + + Use force=True for the start of each print job and when selecting a job row so batch + counter resets even if FPSMS_DATAFLEX_AUTO_RESET_ENABLED=0 (that flag mainly gates + extra per-label / recovery traffic). + """ + if not force and not DATAFLEX_AUTO_RESET_ENABLED: + return + if DATAFLEX_FULL_RESET_EACH_JOB: + send_dataflex_job_counter_reset(ip, port, force=force) + send_dataflex_preprint_reset(ip, port, force=force) + + +def send_dataflex_reset_and_labels( + ip: str, + port: int, + zpl: str, + copies: int, + delay_sec: float, +) -> None: + """ + One TCP connection: light preprint (~JA + ~RO), short pause, then `copies` identical ZPL labels + with delay_sec between copies (not after the last). Avoids rapid connect/disconnect per bag. + """ + if copies < 1: + return + raw_zpl = _dataflex_zpl_bytes(zpl) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + sock.settimeout(DATAFLEX_SEND_TIMEOUT) + try: + sock.connect((ip, port)) + sock.sendall(DATAFLEX_PREPRINT_BYTES) + time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) + for i in range(copies): + sock.sendall(raw_zpl) + time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) + if i < copies - 1: + time.sleep(delay_sec) + _dataflex_shutdown_write_maybe(sock) + finally: + sock.close() + + +def generate_zpl_label_small( + batch_no: str, + item_code: str, + item_name: str, + item_id: Optional[int] = None, + stock_in_line_id: Optional[int] = None, + lot_no: Optional[str] = None, + font: str = "MingLiUHKSCS", +) -> str: + """ + ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot no (or batch) right. + QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else batch_no. + Unicode (^CI28); font set for Big-5 (e.g. MingLiUHKSCS). + """ + desc = _zpl_escape((item_name or "—").strip()) + code = _zpl_escape((item_code or "—").strip()) + label_line2 = (lot_no or batch_no or "—").strip() + label_line2_esc = _zpl_escape(label_line2) + if item_id is not None and stock_in_line_id is not None: + qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})) + else: + qr_data = f"QA,{batch_no}" + return f"""^XA +^CI28 +^PW500 +^LL500 +^FO10,15 +^FB480,3,0,L,0 +^A@N,38,38,{font}^FD{desc}^FS +^FO10,110 +^BQN,2,6^FD{qr_data}^FS +^FO150,110 +^A@N,48,48,{font}^FD{code}^FS +^FO150,175 +^A@N,40,40,{font}^FD{label_line2_esc}^FS +^XZ""" + + +# Label image size (pixels) for 標簽機 image printing. +# Enlarged for readability (approx +90% scale). +LABEL_IMAGE_W = 720 +LABEL_IMAGE_H = 530 +LABEL_PADDING = 23 +LABEL_FONT_NAME_SIZE = 42 +LABEL_FONT_CODE_SIZE = 49 +LABEL_FONT_BATCH_SIZE = 34 +LABEL_QR_SIZE = 210 + + +def _get_chinese_font(size: int) -> Optional["ImageFont.FreeTypeFont"]: + """Return a Chinese-capable font for PIL, or None to use default.""" + if ImageFont is None: + return None + # Prefer real font files on Windows (font *names* may fail and silently fallback). + if os.name == "nt": + fonts_dir = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts") + for rel in ( + "msjh.ttc", # Microsoft JhengHei + "msjhl.ttc", # Microsoft JhengHei Light + "msjhbd.ttc", # Microsoft JhengHei Bold + "mingliu.ttc", + "mingliub.ttc", + "kaiu.ttf", + "msyh.ttc", # Microsoft YaHei + "msyhbd.ttc", + "simhei.ttf", + "simsun.ttc", + ): + p = os.path.join(fonts_dir, rel) + try: + if os.path.exists(p): + return ImageFont.truetype(p, size) + except (OSError, IOError): + continue + # Fallback: try common font names (may still work depending on Pillow build) + for name in ( + "Microsoft JhengHei UI", + "Microsoft JhengHei", + "MingLiU", + "MingLiU_HKSCS", + "Microsoft YaHei", + "SimHei", + "SimSun", + ): + try: + return ImageFont.truetype(name, size) + except (OSError, IOError): + continue + try: + return ImageFont.load_default() + except Exception: + return None + + +def render_label_to_image( + batch_no: str, + item_code: str, + item_name: str, + item_id: Optional[int] = None, + stock_in_line_id: Optional[int] = None, + lot_no: Optional[str] = None, +) -> "Image.Image": + """ + Render 標簽機 label as a PIL Image (white bg, black text + QR). + Use this image for printing so Chinese displays correctly; words are drawn bigger. + Requires Pillow and qrcode. Raises RuntimeError if not available. + """ + if not _HAS_PIL_QR or Image is None or qrcode is None: + raise RuntimeError("Pillow and qrcode are required for image labels. Run: pip install Pillow qrcode[pil]") + img = Image.new("RGB", (LABEL_IMAGE_W, LABEL_IMAGE_H), "white") + draw = ImageDraw.Draw(img) + # QR payload (same as ZPL) + if item_id is not None and stock_in_line_id is not None: + qr_data = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}) + else: + qr_data = f"QA,{batch_no}" + # Draw QR top-left area + qr = qrcode.QRCode(box_size=4, border=2) + qr.add_data(qr_data) + qr.make(fit=True) + qr_img = qr.make_image(fill_color="black", back_color="white") + _resample = getattr(Image, "Resampling", Image).NEAREST + qr_img = qr_img.resize((LABEL_QR_SIZE, LABEL_QR_SIZE), _resample) + img.paste(qr_img, (LABEL_PADDING, LABEL_PADDING)) + # Fonts (bigger for readability) + font_name = _get_chinese_font(LABEL_FONT_NAME_SIZE) + font_code = _get_chinese_font(LABEL_FONT_CODE_SIZE) + font_batch = _get_chinese_font(LABEL_FONT_BATCH_SIZE) + x_right = LABEL_PADDING + LABEL_QR_SIZE + LABEL_PADDING + y_line = LABEL_PADDING + # Line 1: item name (wrap within remaining width) + name_str = (item_name or "—").strip() + max_name_w = LABEL_IMAGE_W - x_right - LABEL_PADDING + if font_name: + # Wrap rule: after 7 "words" (excl. parentheses). ()() not counted; +=*/. and A–Z/a–z count as 0.5. + def _wrap_text(text: str, font, max_width: int) -> list: + ignore = set("()()") + half = set("+=*/.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + max_count = 6.5 + lines: list[str] = [] + current: list[str] = [] + count = 0.0 + + for ch in text: + if ch == "\n": + lines.append("".join(current).strip()) + current = [] + count = 0.0 + continue + + ch_count = 0.0 if ch in ignore else (0.5 if ch in half else 1.0) + if count + ch_count > max_count and current: + lines.append("".join(current).strip()) + current = [] + count = 0.0 + + current.append(ch) + count += ch_count + + if current: + lines.append("".join(current).strip()) + + # Max 2 rows for item name. If still long, keep everything in row 2. + if len(lines) > 2: + lines = [lines[0], "".join(lines[1:]).strip()] + + # Safety: if any line still exceeds pixel width, wrap by width as well. + if hasattr(draw, "textbbox"): + out: list[str] = [] + for ln in lines: + buf: list[str] = [] + for ch in ln: + buf.append(ch) + bbox = draw.textbbox((0, 0), "".join(buf), font=font) + if bbox[2] - bbox[0] > max_width and len(buf) > 1: + out.append("".join(buf[:-1]).strip()) + buf = [buf[-1]] + if buf: + out.append("".join(buf).strip()) + out = [x for x in out if x] + if len(out) > 2: + out = [out[0], "".join(out[1:]).strip()] + return out + + lines = [x for x in lines if x] + if len(lines) > 2: + lines = [lines[0], "".join(lines[1:]).strip()] + return lines + lines = _wrap_text(name_str, font_name, max_name_w) + for i, ln in enumerate(lines): + draw.text((x_right, y_line + i * (LABEL_FONT_NAME_SIZE + 4)), ln, font=font_name, fill="black") + y_line += len(lines) * (LABEL_FONT_NAME_SIZE + 4) + 8 + else: + draw.text((x_right, y_line), name_str[:30], fill="black") + y_line += LABEL_FONT_NAME_SIZE + 12 + # Item code (bigger) + code_str = (item_code or "—").strip() + if font_code: + draw.text((x_right, y_line), code_str, font=font_code, fill="black") + else: + draw.text((x_right, y_line), code_str, fill="black") + y_line += LABEL_FONT_CODE_SIZE + 6 + # Batch/lot line + batch_str = (lot_no or batch_no or "—").strip() + if font_batch: + draw.text((x_right, y_line), batch_str, font=font_batch, fill="black") + else: + draw.text((x_right, y_line), batch_str, fill="black") + return img + + +def _image_to_zpl_gfa(pil_image: "Image.Image") -> str: + """ + Convert a PIL image into ZPL ^GFA (ASCII hex) so we can print Chinese reliably + on ZPL printers (USB/Windows printer or COM) without relying on GDI drivers. + """ + if Image is None or ImageOps is None: + raise RuntimeError("Pillow is required for image-to-ZPL conversion.") + # Convert to 1-bit monochrome bitmap. Invert so '1' bits represent black in ZPL. + img_bw = ImageOps.invert(pil_image.convert("L")).convert("1") + w, h = img_bw.size + bytes_per_row = (w + 7) // 8 + raw = img_bw.tobytes() + total = bytes_per_row * h + # Ensure length matches expected (Pillow should already pack per row). + if len(raw) != total: + raw = raw[:total].ljust(total, b"\x00") + hex_data = raw.hex().upper() + return f"""^XA +^PW{w} +^LL{h} +^FO0,0 +^GFA,{total},{total},{bytes_per_row},{hex_data} +^FS +^XZ""" + + +def zpl_apply_print_quantity(zpl: str, copies: int) -> str: + """ + Ask the printer to output `copies` identical labels from one ZPL format by inserting ^PQ after ^XA. + Results in a **single** spool job / one write — no N separate Windows jobs, no chained ^XA blocks + (which broke some TSC drivers with white-on-white ^GFA output). + """ + if copies <= 1: + return zpl + first_fmt = zpl.split("^XZ", 1)[0] if "^XZ" in zpl else zpl + if "^PQ" in first_fmt.upper(): + return zpl + lines = zpl.splitlines() + new_lines: list[str] = [] + inserted = False + for line in lines: + new_lines.append(line) + if not inserted and line.strip() == "^XA": + # ZPL II: q labels, 0 pause between, 1 replicate (non-serial), N = default options. + # Same graphic (^GFA) is repeated q times — e.g. 需求數量 150 → 150 identical labels, one spool job. + new_lines.append(f"^PQ{copies},0,1,N") + inserted = True + if not inserted: + raise RuntimeError( + "標籤 ZPL 無法插入 ^PQ(格式非預期)。請聯絡程式維護。" + ) + ending = "\n" if zpl.endswith("\n") else "" + return "\n".join(new_lines) + ending + + +def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> None: + """ + Send a PIL Image to 標簽機 via Windows GDI (so Chinese and graphics print correctly). + Only supported when target is a Windows printer name (not COM port). Requires pywin32. + """ + dest = (printer_name or "").strip() + if not dest: + raise ValueError("Label printer destination is empty.") + if os.name != "nt" or dest.upper().startswith("COM"): + raise RuntimeError("Image printing is only supported for a Windows printer name (e.g. TSC TTP-246M Pro).") + if win32print is None or win32ui is None or win32con is None or win32gui is None: + raise RuntimeError("pywin32 is required. Run: pip install pywin32") + dc = win32ui.CreateDC() + dc.CreatePrinterDC(dest) + dc.StartDoc("FPSMS Label") + dc.StartPage() + try: + bmp_w = pil_image.width + bmp_h = pil_image.height + # Scale-to-fit printable area (important for smaller physical labels). + try: + page_w = int(dc.GetDeviceCaps(win32con.HORZRES)) + page_h = int(dc.GetDeviceCaps(win32con.VERTRES)) + except Exception: + page_w, page_h = bmp_w, bmp_h + if page_w <= 0 or page_h <= 0: + page_w, page_h = bmp_w, bmp_h + scale = min(page_w / max(1, bmp_w), page_h / max(1, bmp_h)) + out_w = max(1, int(bmp_w * scale)) + out_h = max(1, int(bmp_h * scale)) + x0 = max(0, (page_w - out_w) // 2) + y0 = max(0, (page_h - out_h) // 2) + + # Most reliable: render via Pillow ImageWin directly to printer DC. + if ImageWin is not None: + dib = ImageWin.Dib(pil_image.convert("RGB")) + dib.draw(dc.GetHandleOutput(), (x0, y0, x0 + out_w, y0 + out_h)) + else: + # Fallback: Draw image to printer DC via temp BMP (GDI uses BMP) + with tempfile.NamedTemporaryFile(suffix=".bmp", delete=False) as f: + tmp_bmp = f.name + try: + pil_image.save(tmp_bmp, "BMP") + hbm = win32gui.LoadImage( + 0, tmp_bmp, win32con.IMAGE_BITMAP, 0, 0, + win32con.LR_LOADFROMFILE | win32con.LR_CREATEDIBSECTION, + ) + if hbm == 0: + raise RuntimeError("Failed to load label image as bitmap.") + try: + mem_dc = win32ui.CreateDCFromHandle(win32gui.CreateCompatibleDC(dc.GetSafeHdc())) + bmp = getattr(win32ui, "CreateBitmapFromHandle", lambda h: win32ui.PyCBitmap.FromHandle(h))(hbm) + mem_dc.SelectObject(bmp) + dc.StretchBlt((x0, y0), (out_w, out_h), mem_dc, (0, 0), (bmp_w, bmp_h), win32con.SRCCOPY) + finally: + win32gui.DeleteObject(hbm) + finally: + try: + os.unlink(tmp_bmp) + except OSError: + pass + finally: + dc.EndPage() + dc.EndDoc() + + +def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: + """Send ZPL label (^XA…^XZ) to DataFlex printer via TCP. Raises on connection/send error.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + sock.settimeout(DATAFLEX_SEND_TIMEOUT) + try: + sock.connect((ip, port)) + sock.sendall(_dataflex_zpl_bytes(zpl)) + time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) + _dataflex_shutdown_write_maybe(sock) + finally: + sock.close() + + +def query_dataflex_host_status(ip: str, port: int) -> str: + """ + Query DataFlex/Zebra host status (~HS). Returns decoded status text, or empty string + when device does not return host status. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + sock.settimeout(max(0.2, DATAFLEX_STATUS_QUERY_TIMEOUT_SEC)) + try: + sock.connect((ip, port)) + sock.sendall(b"~HS\r\n") + chunks: list[bytes] = [] + while True: + try: + data = sock.recv(4096) + except socket.timeout: + break + except OSError as ex: + if _dataflex_is_benign_tcp_reset(ex): + break + raise + if not data: + break + chunks.append(data) + if sum(len(c) for c in chunks) >= 16384: + break + return b"".join(chunks).decode("utf-8", errors="ignore") + finally: + sock.close() + + +def _dataflex_status_has_e1005(status_text: str) -> bool: + s = (status_text or "").lower() + return "e1005" in s or "1005" in s + + +def _dataflex_status_problem_code(status_text: str) -> Optional[str]: + """If host status (~HS) suggests a fault, return a short code like E1000; else None.""" + s = (status_text or "").lower() + for code in ("e1000", "e1005", "e1004", "e1003", "e1002", "e1001"): + if code in s: + return code.upper() + return None + + +def assert_dataflex_host_ok(ip: str, port: int) -> None: + """ + Query ~HS once. If printer reports a known fault token, stop the job early. + Empty/short replies are ignored (some firmware is quiet). + """ + st = query_dataflex_host_status(ip, port) + if not (st or "").strip(): + return + prob = _dataflex_status_problem_code(st) + if prob is not None: + raise RuntimeError( + f"打袋機狀態異常 {prob}(~HS)。請看機台畫面處理後再印。" + ) + + +def recover_dataflex_if_host_fault(ip: str, port: int) -> None: + """ + If ~HS reports E1000/E1005/etc., clear host state once and continue — do not abort the whole run. + Keeps work short so the print thread does not look "frozen". + """ + st = query_dataflex_host_status(ip, port) + if not (st or "").strip(): + return + if _dataflex_status_problem_code(st) is None: + return + touch_dataflex_recovery_grace(14.0) + send_dataflex_job_counter_reset(ip, port, force=True) + send_dataflex_preprint_reset(ip, port, force=True) + time.sleep(max(0.35, DATAFLEX_RECOVERY_WAIT_SEC)) + + +def send_dataflex_label_with_recovery(ip: str, port: int, zpl: str) -> None: + """ + Send one bag label with one automatic recovery attempt. + + If first send fails (including firmware-latched states such as E1005), + perform full recovery (~JA/~RO/~JR), then light preprint reset (~JA/~RO), + and retry once. + """ + last_err: Optional[Exception] = None + for attempt in range(DATAFLEX_RECOVERY_MAX_ATTEMPTS): + try: + if DATAFLEX_AUTO_RESET_ENABLED and DATAFLEX_PREPRINT_EACH_LABEL: + send_dataflex_preprint_reset(ip, port) + send_zpl_to_dataflex(ip, port, zpl) + if DATAFLEX_VERIFY_STATUS_AFTER_SEND: + status_text = query_dataflex_host_status(ip, port) + if _dataflex_status_has_e1005(status_text): + raise RuntimeError("DataFlex E1005 detected from host status.") + return + except (ConnectionRefusedError, socket.timeout, OSError, RuntimeError) as ex: + last_err = ex + if attempt >= DATAFLEX_RECOVERY_MAX_ATTEMPTS - 1: + break + if DATAFLEX_AUTO_RESET_ENABLED or DATAFLEX_RECOVER_ON_SEND_ERROR: + touch_dataflex_recovery_grace(14.0) + send_dataflex_job_counter_reset(ip, port, force=True) + send_dataflex_preprint_reset(ip, port, force=True) + time.sleep(max(0.35, DATAFLEX_RECOVERY_WAIT_SEC)) + + if last_err is not None: + raise last_err + raise RuntimeError("DataFlex label send failed.") + + +def send_zpl_to_label_printer(target: str, zpl: str) -> None: + """ + Send ZPL to 標簽機. + + On Windows, if target is not a COM port (e.g. "TSC TTP-246M Pro"), + send raw ZPL to the named Windows printer via the spooler. + Otherwise, treat target as a serial COM port (original behaviour). + """ + dest = (target or "").strip() + if not dest: + raise ValueError("Label printer destination is empty.") + + # Unicode (^CI28); send UTF-8 to 標簽機 + raw_bytes = zpl.encode("utf-8") + + # Windows printer name path (USB printer installed as normal printer) + if os.name == "nt" and not dest.upper().startswith("COM"): + if win32print is None: + raise RuntimeError("pywin32 not installed. Run: pip install pywin32") + handle = win32print.OpenPrinter(dest) + try: + job = win32print.StartDocPrinter(handle, 1, ("FPSMS Label", None, "RAW")) + win32print.StartPagePrinter(handle) + win32print.WritePrinter(handle, raw_bytes) + win32print.EndPagePrinter(handle) + win32print.EndDocPrinter(handle) + finally: + win32print.ClosePrinter(handle) + return + + # Fallback: serial COM port + if serial is None: + raise RuntimeError("pyserial not installed. Run: pip install pyserial") + ser = serial.Serial(dest, timeout=5) + try: + ser.write(raw_bytes) + finally: + ser.close() + + +def send_zpl_to_label_printer_batch(target: str, zpl: str, copies: int) -> None: + """ + Print multiple identical ZPL labels in **exactly one** Windows spool job (or one COM write). + + Uses ZPL ^PQ so the printer firmware repeats the format N times — never one job per label. + """ + if copies < 1: + return + zpl_out = zpl_apply_print_quantity(zpl, copies) + send_zpl_to_label_printer(target, zpl_out) + + +def load_laser_last_count() -> tuple[int, Optional[str]]: + """Load last batch count and date from laser counter file. Returns (count, date_str).""" + if not os.path.exists(LASER_COUNTER_FILE): + return 0, None + try: + with open(LASER_COUNTER_FILE, "r", encoding="utf-8") as f: + lines = f.read().strip().splitlines() + if len(lines) >= 2: + return int(lines[1].strip()), lines[0].strip() + except Exception: + pass + return 0, None + + +def save_laser_last_count(date_str: str, count: int) -> None: + """Save laser batch count and date to file.""" + try: + with open(LASER_COUNTER_FILE, "w", encoding="utf-8") as f: + f.write(f"{date_str}\n{count}") + except Exception: + pass + + +LASER_PUSH_INTERVAL = 2 # seconds between pushes (like sample script) +# Click row with 激光機 selected: send this many times, delay between sends (not after last). +LASER_ROW_SEND_COUNT = 3 +LASER_ROW_SEND_DELAY_SEC = 3 + + +def laser_push_loop( + ip: str, + port: int, + stop_event: threading.Event, + root: tk.Tk, + on_error: Callable[[str], None], +) -> None: + """ + Run in a background thread: persistent connection to EZCAD, push B{yymmdd}{count:03d};; + every LASER_PUSH_INTERVAL seconds. Resets count each new day. Uses counter file. + """ + conn = None + push_count, last_saved_date = load_laser_last_count() + while not stop_event.is_set(): + try: + if conn is None: + conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + conn.settimeout(0.4) + conn.connect((ip, port)) + now = datetime.now() + today_str = now.strftime("%y%m%d") + if last_saved_date != today_str: + push_count = 1 + last_saved_date = today_str + batch = f"B{today_str}{push_count:03d}" + reply = f"{batch};;" + conn.sendall(reply.encode("utf-8")) + save_laser_last_count(today_str, push_count) + rlist, _, _ = select.select([conn], [], [], 0.4) + if rlist: + data = conn.recv(4096) + if not data: + conn.close() + conn = None + push_count += 1 + for _ in range(int(LASER_PUSH_INTERVAL * 2)): + if stop_event.is_set(): + break + time.sleep(0.5) + except socket.timeout: + pass + except Exception as e: + if conn: + try: + conn.close() + except Exception: + pass + conn = None + try: + root.after(0, lambda msg=str(e): on_error(msg)) + except Exception: + pass + for _ in range(6): + if stop_event.is_set(): + break + time.sleep(0.5) + if conn: + try: + conn.close() + except Exception: + pass + + +def send_job_to_laser( + conn_ref: list, + ip: str, + port: int, + item_id: Optional[int], + stock_in_line_id: Optional[int], + item_code: str, + item_name: str, + expiry_text: Optional[str] = None, +) -> tuple[bool, str]: + """ + Send to laser using `;` separated params: + {"itemId": itemId, "stockInLineId": stockInLineId} ; itemCode ; itemName ; [Expiry Date yyyyMMdd] ;; + conn_ref: [socket or None] - reused across calls; closed only when switching printer. + When both item_id and stock_in_line_id present, sends JSON first param; else fallback: 0;item_code;item_name;; + Expiry is the 4th field when present (same wording as DataFlex). + Returns (success, message). + """ + code_str = (item_code or "").strip().replace(";", ",") + name_str = (item_name or "").strip().replace(";", ",") + exp_str = (expiry_text or "").strip().replace(";", ",") + + if item_id is not None and stock_in_line_id is not None: + # Use compact JSON so device-side parser doesn't get spaces. + json_part = json.dumps( + {"itemId": item_id, "stockInLineId": stock_in_line_id}, + separators=(",", ":"), + ) + head = json_part + else: + head = "0" + if exp_str: + reply = f"{head};{code_str};{name_str};{exp_str};;" + else: + reply = f"{head};{code_str};{name_str};;" + conn = conn_ref[0] + try: + if conn is None: + conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + conn.settimeout(3.0) + conn.connect((ip, port)) + conn_ref[0] = conn + conn.settimeout(3.0) + conn.sendall(reply.encode("utf-8")) + conn.settimeout(0.5) + try: + data = conn.recv(4096) + if data: + ack = data.decode("utf-8", errors="ignore").strip().lower() + if "receive" in ack and "invalid" not in ack: + return True, f"已送出激光機:{reply}(已確認)" + except socket.timeout: + pass + return True, f"已送出激光機:{reply}" + except (ConnectionRefusedError, socket.timeout, OSError) as e: + if conn_ref[0] is not None: + try: + conn_ref[0].close() + except Exception: + pass + conn_ref[0] = None + if isinstance(e, ConnectionRefusedError): + return False, f"無法連線至 {ip}:{port},請確認激光機已開機且 IP 正確。" + if isinstance(e, socket.timeout): + return False, f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。" + return False, f"激光機送出失敗:{e}" + + +def send_job_to_laser_with_retry( + conn_ref: list, + ip: str, + port: int, + item_id: Optional[int], + stock_in_line_id: Optional[int], + item_code: str, + item_name: str, + expiry_text: Optional[str] = None, +) -> tuple[bool, str]: + """Send job to laser; on failure, retry once. Returns (success, message).""" + ok, msg = send_job_to_laser( + conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name, expiry_text, + ) + if ok: + return True, msg + ok2, msg2 = send_job_to_laser( + conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name, expiry_text, + ) + return ok2, msg2 + + +def run_laser_row_send_thread( + root: tk.Tk, + laser_conn_ref: list, + laser_busy_ref: list, + ip: str, + port: int, + item_id: Optional[int], + stock_in_line_id: Optional[int], + item_code: str, + item_name: str, + set_status_message: Callable[[str, bool], None], + base_url: Optional[str] = None, + job_order_id: Optional[int] = None, + on_recorded: Optional[Callable[[], None]] = None, + expiry_text: Optional[str] = None, +) -> None: + """ + On row click with 激光機: send LASER_ROW_SEND_COUNT times with LASER_ROW_SEND_DELAY_SEC between sends. + UI updates on main thread; work runs in background so the window does not freeze. + After success, POST LASER qty to API when job_order_id and base_url are set. + """ + if laser_busy_ref[0]: + messagebox.showwarning("激光機", "請等待目前激光發送完成。") + return + laser_busy_ref[0] = True + + def worker() -> None: + try: + n = LASER_ROW_SEND_COUNT + for i in range(n): + ok, msg = send_job_to_laser_with_retry( + laser_conn_ref, + ip, + port, + item_id, + stock_in_line_id, + item_code, + item_name, + expiry_text, + ) + if not ok: + root.after( + 0, + lambda m=msg: messagebox.showwarning("激光機", m), + ) + return + if i < n - 1: + time.sleep(LASER_ROW_SEND_DELAY_SEC) + posted = False + if base_url and job_order_id is not None: + try: + submit_job_order_print_submit(base_url, int(job_order_id), n, "LASER") + posted = True + except requests.RequestException as ex: + root.after( + 0, + lambda err=str(ex): messagebox.showwarning( + "激光機", + f"已發送,但伺服器記錄失敗:{err}", + ), + ) + elif base_url: + root.after( + 0, + lambda: messagebox.showwarning( + "激光機", + "已發送,但無工單 id,無法寫入伺服器記錄。", + ), + ) + root.after( + 0, + lambda: set_status_message("已發送", is_error=False), + ) + if on_recorded is not None and posted: + root.after(0, on_recorded) + except Exception as e: + root.after( + 0, + lambda err=str(e): messagebox.showwarning("激光機", f"送出失敗:{err}"), + ) + finally: + laser_busy_ref[0] = False + + threading.Thread(target=worker, daemon=True).start() + + +def run_dataflex_fixed_qty_thread( + root: tk.Tk, + dataflex_lock: threading.Lock, + dataflex_busy_ref: list, + ip: str, + port: int, + n: int, + zpl: str, + label_text: str, + jo_id: Optional[int], + base_url: str, + set_status_message: Callable[[str, bool], None], + on_recorded: Callable[[], None], +) -> None: + """ + Send n DataFlex labels with delay between copies. Runs off the Tk main thread so the UI + stays responsive (printer dropdown, other controls) during printing. + """ + def worker() -> None: + with dataflex_lock: + if dataflex_busy_ref[0]: + root.after( + 0, + lambda: messagebox.showwarning( + "打袋機", + "請等待目前列印完成或先停止連續列印。", + ), + ) + return + dataflex_busy_ref[0] = True + printed = 0 + used_single_tcp = False + try: + send_dataflex_start_job_reset(ip, port, force=True) + if DATAFLEX_SINGLE_TCP_JOB and n >= 1: + # One TCP connection, one ZPL, ^PQn — printer firmware prints n identical bags. + used_single_tcp = True + zpl_one = dataflex_zpl_set_print_quantity(zpl, n) + root.after( + 0, + lambda tn=n: set_status_message( + f"打袋單次發送中… {tn} 張(^PQ{tn})", + is_error=False, + ), + ) + send_dataflex_label_with_recovery(ip, port, zpl_one) + if DATAFLEX_VERIFY_EVERY_LABELS > 0: + recover_dataflex_if_host_fault(ip, port) + printed = n + else: + # One TCP job per bag. Slower but avoids E1005 on some units when ^PQ is large. + for i in range(n): + send_dataflex_label_with_recovery(ip, port, zpl) + printed += 1 + if DATAFLEX_UI_PROGRESS_EVERY > 0 and ( + printed == 1 or printed % DATAFLEX_UI_PROGRESS_EVERY == 0 + ): + p, t = printed, n + root.after( + 0, + lambda p=p, t=t: set_status_message( + f"打袋列印中… {p}/{t}", + is_error=False, + ), + ) + if ( + DATAFLEX_VERIFY_EVERY_LABELS > 0 + and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0 + ): + recover_dataflex_if_host_fault(ip, port) + if ( + DATAFLEX_COOLDOWN_EVERY_LABELS > 0 + and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0 + and i < n - 1 + ): + time.sleep(max(0.0, DATAFLEX_COOLDOWN_SEC)) + if ( + DATAFLEX_THERMAL_REST_EVERY_LABELS > 0 + and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0 + and i < n - 1 + ): + time.sleep(max(0.0, DATAFLEX_THERMAL_REST_SEC)) + if i < n - 1: + time.sleep(DATAFLEX_INTER_LABEL_DELAY_SEC) + root.after( + 0, + lambda u=used_single_tcp: set_status_message( + ( + f"已送出列印(單次 TCP):批次 {label_text} x {n} 張" + if u + else f"已送出列印:批次 {label_text} x {n} 張" + ), + is_error=False, + ), + ) + if jo_id is not None: + try: + submit_job_order_print_submit(base_url, int(jo_id), n, "DATAFLEX") + root.after(0, on_recorded) + except requests.RequestException as ex: + root.after( + 0, + lambda err=str(ex): messagebox.showwarning( + "打袋機", + f"列印可能已完成,但伺服器記錄失敗(可再試):{err}", + ), + ) + else: + root.after( + 0, + lambda: messagebox.showwarning( + "打袋機", + f"已送出列印 {n} 張,但無工單 id,無法寫入伺服器記錄。", + ), + ) + except ConnectionRefusedError: + root.after( + 0, + lambda: set_status_message( + f"無法連線至 {ip}:{port},已送出 {printed}/{n} 張。", + is_error=True, + ), + ) + except socket.timeout: + root.after( + 0, + lambda: set_status_message( + f"連線逾時 ({ip}:{port}),已送出 {printed}/{n} 張。", + is_error=True, + ), + ) + except OSError as err: + root.after( + 0, + lambda e=err: set_status_message( + f"列印失敗:{e}(已送出 {printed}/{n} 張)", + is_error=True, + ), + ) + except RuntimeError as err: + root.after( + 0, + lambda e=err: set_status_message( + f"打袋機錯誤:{e}(已送出 {printed}/{n} 張)", + is_error=True, + ), + ) + except Exception as err: + root.after( + 0, + lambda e=err: set_status_message( + f"打袋機例外:{e}(已送出 {printed}/{n} 張)", + is_error=True, + ), + ) + finally: + with dataflex_lock: + dataflex_busy_ref[0] = False + + threading.Thread(target=worker, daemon=True).start() + + +def run_label_print_batch_thread( + root: tk.Tk, + label_lock: threading.Lock, + label_busy_ref: list, + com: str, + zpl_img: str, + n: int, + jo_id: Optional[int], + base_url: str, + set_status_message: Callable[[str, bool], None], + on_recorded: Callable[[], None], +) -> None: + """ + Send n label copies off the main thread so DataFlex / laser / UI stay usable in parallel. + """ + def worker() -> None: + with label_lock: + if label_busy_ref[0]: + root.after( + 0, + lambda: messagebox.showwarning( + "標籤機", + "請等待目前標籤列印完成。", + ), + ) + return + label_busy_ref[0] = True + try: + send_zpl_to_label_printer_batch(com, zpl_img, n) + root.after( + 0, + lambda: set_status_message(f"已送出列印:標籤 x {n} 張", is_error=False), + ) + if jo_id is not None: + try: + submit_job_order_print_submit(base_url, int(jo_id), n, "LABEL") + root.after(0, on_recorded) + root.after( + 0, + lambda: messagebox.showinfo( + "標籤機", + f"已送出列印:{n} 張標籤(已記錄)", + ), + ) + except requests.RequestException as ex: + root.after( + 0, + lambda err=str(ex): messagebox.showwarning( + "標籤機", + f"標籤已列印 {n} 張,但伺服器記錄失敗:{err}", + ), + ) + else: + root.after( + 0, + lambda: messagebox.showwarning( + "標籤機", + f"已送出列印:{n} 張標籤(無工單 id,無法寫入伺服器記錄)", + ), + ) + except Exception as err: + root.after( + 0, + lambda e=str(err): messagebox.showerror("標籤機", f"列印失敗:{e}"), + ) + finally: + with label_lock: + label_busy_ref[0] = False + + threading.Thread(target=worker, daemon=True).start() + + +def _printed_qty_int(raw) -> int: + """Parse API printed qty field (may be float JSON) to int.""" + try: + return int(float(raw)) if raw is not None else 0 + except (TypeError, ValueError): + return 0 + + +def _filter_job_orders_by_search(data: list, needle: str) -> list: + """Substring match on item code, job order code, item name, lot (case-insensitive).""" + n = needle.strip().lower() + if not n: + return data + out: list = [] + for jo in data: + parts = [ + str(jo.get("itemCode") or ""), + str(jo.get("code") or ""), + str(jo.get("itemName") or ""), + str(jo.get("lotNo") or ""), + ] + if any(n in p.lower() for p in parts): + out.append(jo) + return out + + +def format_qty(val) -> str: + """Format quantity: integer without .0, with thousand separator.""" + if val is None: + return "—" + try: + n = float(val) + if n == int(n): + return f"{int(n):,}" + return f"{n:,.2f}".rstrip("0").rstrip(".") + except (TypeError, ValueError): + return str(val) + + +def batch_no(year: int, job_order_id: int) -> str: + """Batch no.: B + 4-digit year + jobOrderId zero-padded to 6 digits.""" + return f"B{year}{job_order_id:06d}" + + +def get_font(size: int = FONT_SIZE, bold: bool = False) -> tuple: + try: + return (FONT_FAMILY, size, "bold" if bold else "normal") + except Exception: + return ("TkDefaultFont", size, "bold" if bold else "normal") + + +def fetch_job_orders(base_url: str, plan_start: date) -> list: + """Call GET /py/job-orders and return the JSON list.""" + url = f"{base_url.rstrip('/')}/py/job-orders" + params = {"planStart": plan_start.isoformat()} + resp = requests.get(url, params=params, timeout=30) + resp.raise_for_status() + return resp.json() + + +def submit_job_order_print_submit( + base_url: str, + job_order_id: int, + qty: int, + print_channel: str = "LABEL", +) -> None: + """ + Record printed quantity in the FPSMS database via PyController. + + POST ``/api/py/job-order-print-submit`` (path under base_url) — **public endpoint, no login** + or API key required. Each successful call appends one row to ``py_job_order_print_submit``; + totals per job order and channel are aggregated server-side. + + Raises ``requests.RequestException`` if all retry attempts fail. + """ + url = f"{base_url.rstrip('/')}/py/job-order-print-submit" + payload = { + "jobOrderId": int(job_order_id), + "qty": int(qty), + "printChannel": print_channel, + } + last_err: Optional[Exception] = None + for attempt in range(PRINT_SUBMIT_MAX_ATTEMPTS): + try: + resp = requests.post(url, json=payload, timeout=30) + resp.raise_for_status() + return + except requests.RequestException as ex: + last_err = ex + if attempt < PRINT_SUBMIT_MAX_ATTEMPTS - 1: + time.sleep(PRINT_SUBMIT_RETRY_DELAY_SEC) + if last_err is not None: + raise last_err + raise RuntimeError("submit_job_order_print_submit: unexpected empty error") + + +def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None: + """Set row and all nested Frame/Label children to selected or normal background.""" + bg = BG_ROW_SELECTED if selected else BG_ROW + + def _paint(w: tk.Misc) -> None: + if isinstance(w, (tk.Frame, tk.Label)): + w.configure(bg=bg) + for c in w.winfo_children(): + _paint(c) + + _paint(row_frame) + + +def on_job_order_click(jo: dict, batch: str) -> None: + """Show message and highlight row (keeps printing to selected printer).""" + item_code = jo.get("itemCode") or "—" + item_name = jo.get("itemName") or "—" + messagebox.showinfo( + "工單", + f'已點選:批次 {batch}\n品號 {item_code} {item_name}', + ) + + +def ask_label_count(parent: tk.Tk) -> Optional[int]: + """ + When printer is 標簽機, ask how many labels to print: + optional direct qty in text field (e.g. 150), +50/+10/+5/+1, 重置, then 確認送出. + That count becomes ZPL ^PQ in one job — 150 → 150 identical labels. + Returns count (>= 1), or None if cancelled. + """ + result: list[Optional[int]] = [None] + qty_var = tk.StringVar(value="0") + + win = tk.Toplevel(parent) + win.title("標簽印數") + win.geometry("580x280") + win.transient(parent) + win.grab_set() + win.configure(bg=BG_TOP) + ttk.Label(win, text="印多少個?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) + + entry_row = tk.Frame(win, bg=BG_TOP) + entry_row.pack(pady=8) + tk.Label(entry_row, text="需求數量:", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6)) + qty_entry = tk.Entry( + entry_row, + textvariable=qty_var, + width=12, + font=get_font(FONT_SIZE), + bg="white", + justify=tk.RIGHT, + ) + qty_entry.pack(side=tk.LEFT, padx=4) + + def current_qty() -> int: + s = (qty_var.get() or "").strip().replace(",", "") + if not s: + return 0 + try: + return max(0, int(s)) + except ValueError: + return 0 + + def reset_qty() -> None: + qty_var.set("0") + + ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8) + + def add(n: int) -> None: + qty_var.set(str(current_qty() + n)) + + def confirm() -> None: + q = current_qty() + if q < 1: + messagebox.showwarning("標簽機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win) + return + result[0] = q + win.destroy() + + btn_row1 = tk.Frame(win, bg=BG_TOP) + btn_row1.pack(pady=8) + for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]: + def make_add(v: int): + return lambda: add(v) + ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4) + + ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12) + qty_entry.bind("", lambda e: confirm()) + win.protocol("WM_DELETE_WINDOW", win.destroy) + win.wait_window() + return result[0] + +def ask_bag_count(parent: tk.Tk) -> Optional[Tuple[int, bool]]: + """ + When printer is 打袋機 DataFlex: qty with +按鈕 then 確認送出, or big bottom「C」for continuous. + Returns (count, continuous_print). If continuous_print is True, count is ignored (use 0). + None if cancelled. + """ + result: list[Optional[Tuple[int, bool]]] = [None] + qty_var = tk.StringVar(value="0") + + win = tk.Toplevel(parent) + win.title("打袋列印數量") + win.geometry("580x420") + win.transient(parent) + win.grab_set() + win.configure(bg=BG_TOP) + ttk.Label(win, text="列印多少個袋?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) + + entry_row = tk.Frame(win, bg=BG_TOP) + entry_row.pack(pady=8) + tk.Label(entry_row, text="需求數量:", font=get_font(FONT_SIZE), bg=BG_TOP).pack(side=tk.LEFT, padx=(0, 6)) + qty_entry = tk.Entry( + entry_row, + textvariable=qty_var, + width=12, + font=get_font(FONT_SIZE), + bg="white", + justify=tk.RIGHT, + ) + qty_entry.pack(side=tk.LEFT, padx=4) + + def current_qty() -> int: + s = (qty_var.get() or "").strip().replace(",", "") + if not s: + return 0 + try: + return max(0, int(s)) + except ValueError: + return 0 + + def reset_qty() -> None: + qty_var.set("0") + + ttk.Button(entry_row, text="重置", command=reset_qty, width=8).pack(side=tk.LEFT, padx=8) + + def add(n: int) -> None: + qty_var.set(str(current_qty() + n)) + + def confirm() -> None: + q = current_qty() + if q < 1: + messagebox.showwarning("打袋機", "請輸入需求數量或按 +50、+10、+5、+1。", parent=win) + return + result[0] = (q, False) + win.destroy() + + def start_continuous() -> None: + """Big C: continuous print until 停止; counter reset at job start.""" + result[0] = (0, True) + win.destroy() + + btn_row1 = tk.Frame(win, bg=BG_TOP) + btn_row1.pack(pady=8) + for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]: + def make_add(v: int): + return lambda: add(v) + ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4) + + ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12) + qty_entry.bind("", lambda e: confirm()) + + sep = ttk.Separator(win, orient=tk.HORIZONTAL) + sep.pack(fill=tk.X, padx=16, pady=(4, 8)) + + bottom = tk.Frame(win, bg=BG_TOP) + bottom.pack(fill=tk.X, padx=12, pady=(0, 12)) + tk.Label( + bottom, + text="連續出袋 · 每單開始重設計數 · 另開視窗按「停止列印」結束", + font=get_font(FONT_SIZE_META), + bg=BG_TOP, + fg="#333333", + wraplength=540, + justify=tk.CENTER, + ).pack(fill=tk.X, pady=(0, 6)) + + tk.Button( + bottom, + text="C(連續印)", + command=start_continuous, + font=(FONT_FAMILY, 38, "bold"), + bg="#2E7D32", + fg="white", + activebackground="#1B5E20", + activeforeground="white", + relief=tk.RAISED, + bd=4, + cursor="hand2", + padx=24, + pady=18, + ).pack(fill=tk.X) + win.protocol("WM_DELETE_WINDOW", win.destroy) + win.wait_window() + return result[0] + + +@dataclass(frozen=True) +class DataflexPrintSession: + """ + Snapshot taken when the user starts DataFlex print (especially C 連續印). + The worker must use only this object — not grid row index, scroll position, or selection. + """ + + job_order_id: Optional[int] + job_code: str + item_code: str + item_name: str + label_text: str + zpl: str + printer_ip: str + printer_port: int + batch_display: str + + +def build_dataflex_print_session( + jo: dict, + batch: str, + zpl: str, + label_text: str, + printer_ip: str, + printer_port: int, +) -> DataflexPrintSession: + jo_id = jo.get("id") + jo_code = (jo.get("code") or "").strip() + if not jo_code and jo_id is not None: + jo_code = f"#{jo_id}" + elif not jo_code: + jo_code = "—" + return DataflexPrintSession( + job_order_id=int(jo_id) if jo_id is not None else None, + job_code=jo_code, + item_code=(jo.get("itemCode") or "—").strip(), + item_name=(jo.get("itemName") or "—").strip(), + label_text=label_text, + zpl=zpl, + printer_ip=printer_ip, + printer_port=printer_port, + batch_display=(batch or "—").strip(), + ) + + +def run_dataflex_continuous_thread( + root: tk.Tk, + session: DataflexPrintSession, + stop_event: threading.Event, + stop_win: tk.Toplevel, + dataflex_lock: threading.Lock, + dataflex_busy_ref: list, + dataflex_stop_win_ref: list, + active_session_ref: list, + base_url: str, + set_status_message: Callable[[str, bool], None], + on_recorded: Callable[[], None], +) -> None: + """Send bags in a loop until stop_event; all payload comes from session (in-memory snapshot).""" + + def worker() -> None: + with dataflex_lock: + if dataflex_busy_ref[0]: + active_session_ref[0] = None + + def _abort_start() -> None: + messagebox.showwarning( + "打袋機", + "請等待目前列印完成或先停止連續列印。", + ) + dataflex_stop_win_ref[0] = None + try: + stop_win.destroy() + except tk.TclError: + pass + + root.after(0, _abort_start) + return + dataflex_busy_ref[0] = True + + ip = session.printer_ip + port = session.printer_port + zpl = session.zpl + label_text = session.label_text + printed = 0 + error_shown = False + try: + send_dataflex_start_job_reset(ip, port, force=True) + while not stop_event.is_set(): + send_dataflex_label_with_recovery(ip, port, zpl) + printed += 1 + if DATAFLEX_UI_PROGRESS_EVERY > 0 and ( + printed == 1 or printed % DATAFLEX_UI_PROGRESS_EVERY == 0 + ): + p = printed + root.after( + 0, + lambda p=p, jc=session.job_code: set_status_message( + f"連續打袋 · 工單 {jc}… 已印 {p} 張", + is_error=False, + ), + ) + if ( + DATAFLEX_VERIFY_EVERY_LABELS > 0 + and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0 + ): + recover_dataflex_if_host_fault(ip, port) + if ( + DATAFLEX_COOLDOWN_EVERY_LABELS > 0 + and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0 + ): + _sleep_interruptible(stop_event, max(0.0, DATAFLEX_COOLDOWN_SEC)) + if ( + DATAFLEX_THERMAL_REST_EVERY_LABELS > 0 + and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0 + ): + _sleep_interruptible(stop_event, max(0.0, DATAFLEX_THERMAL_REST_SEC)) + _sleep_interruptible(stop_event, DATAFLEX_INTER_LABEL_DELAY_SEC) + except ConnectionRefusedError: + error_shown = True + root.after( + 0, + lambda: set_status_message( + f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", + is_error=True, + ), + ) + except socket.timeout: + error_shown = True + root.after( + 0, + lambda: set_status_message( + f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", + is_error=True, + ), + ) + except OSError as err: + error_shown = True + root.after( + 0, + lambda e=err: set_status_message(f"列印失敗:{e}", is_error=True), + ) + except RuntimeError as err: + error_shown = True + root.after( + 0, + lambda e=err: set_status_message(f"打袋機錯誤:{e}", is_error=True), + ) + except Exception as err: + error_shown = True + root.after( + 0, + lambda e=err: set_status_message(f"打袋機例外:{e}", is_error=True), + ) + finally: + with dataflex_lock: + dataflex_busy_ref[0] = False + active_session_ref[0] = None + + def _done() -> None: + dataflex_stop_win_ref[0] = None + try: + if os.name == "nt": + stop_win.attributes("-topmost", False) + except tk.TclError: + pass + try: + stop_win.destroy() + except tk.TclError: + pass + jc = session.job_code + if printed > 0: + set_status_message( + f"連續列印結束:工單 {jc} · {label_text},已印 {printed} 張", + is_error=False, + ) + if session.job_order_id is not None: + try: + submit_job_order_print_submit( + base_url, + session.job_order_id, + printed, + "DATAFLEX", + ) + on_recorded() + except requests.RequestException as ex: + messagebox.showwarning( + "打袋機", + f"列印可能已完成,但伺服器記錄失敗(可再試):{ex}", + ) + elif not error_shown: + set_status_message("連續列印未印出或已取消", is_error=True) + + root.after(0, _done) + + threading.Thread(target=worker, daemon=True).start() + + +def _sleep_interruptible(stop_event: threading.Event, total_sec: float) -> None: + """Sleep up to total_sec but return early if stop_event is set.""" + end = time.perf_counter() + total_sec + while time.perf_counter() < end: + if stop_event.is_set(): + return + remaining = end - time.perf_counter() + if remaining <= 0: + break + time.sleep(min(0.05, remaining)) + + +def open_dataflex_stop_window( + parent: tk.Tk, + stop_event: threading.Event, + stop_win_ref: list, + session: DataflexPrintSession, +) -> tk.Toplevel: + """ + Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable). + + Stays above other dialogs (e.g. 標籤機 quantity) via periodic lift + optional topmost on Windows, + so switching printer and printing labels does not hide the stop control. Ref is cleared on destroy. + Job details come from the in-memory session snapshot, not the grid selection. + """ + win = tk.Toplevel(parent) + win.title("打袋機連續列印") + win.geometry("480x240") + # On Windows, transient(root) can hide this Toplevel when the menubutton / printer row + # updates (e.g. switching to 激光機); keep transient only on non-Windows. + if os.name != "nt": + win.transient(parent) + win.configure(bg=BG_TOP) + stop_win_ref[0] = win + if os.name == "nt": + try: + win.attributes("-topmost", True) + except tk.TclError: + pass + + tk.Label( + win, + text="連續列印進行中(內容以按下 C 時的工單為準,與列表捲動/日期無關)", + font=get_font(FONT_SIZE_META), + bg=BG_TOP, + wraplength=440, + justify=tk.CENTER, + ).pack(pady=(12, 6)) + detail = ( + f"工單:{session.job_code}\n" + f"品號:{session.item_code}\n" + f"品名:{session.item_name}\n" + f"批次/批號:{session.label_text}" + ) + tk.Label( + win, + text=detail, + font=get_font(FONT_SIZE), + bg=BG_TOP, + fg="#111111", + wraplength=440, + justify=tk.LEFT, + anchor=tk.W, + ).pack(padx=16, pady=(0, 8), fill=tk.X) + + def clear_topmost() -> None: + if os.name == "nt": + try: + win.attributes("-topmost", False) + except tk.TclError: + pass + + def stop() -> None: + stop_event.set() + stop_win_ref[0] = None + clear_topmost() + try: + win.destroy() + except tk.TclError: + pass + + def periodic_lift() -> None: + if stop_win_ref[0] is not win: + return + try: + if not win.winfo_exists(): + return + win.lift() + if os.name == "nt": + win.attributes("-topmost", True) + except tk.TclError: + return + parent.after(4000, periodic_lift) + + tk.Button( + win, + text="停止列印", + command=stop, + font=get_font(FONT_SIZE_BUTTONS), + bg=BG_STATUS_ERROR, + fg=FG_STATUS_ERROR, + padx=20, + pady=10, + ).pack(pady=12) + win.protocol("WM_DELETE_WINDOW", stop) + parent.after(500, periodic_lift) + return win + + +def main() -> None: + settings = load_settings() + base_url_ref = [build_base_url(settings["api_ip"], settings["api_port"])] + + root = tk.Tk() + root.title(f"FP-MTMS Bag4 v{APP_VERSION} 打袋機") + root.geometry("1120x960") + root.minsize(480, 360) + root.configure(bg=BG_ROOT) + + # Style: larger font for aged users; light blue theme + style = ttk.Style() + try: + style.configure(".", font=get_font(FONT_SIZE), background=BG_TOP) + style.configure("TButton", font=get_font(FONT_SIZE_BUTTONS), background=BG_TOP) + style.configure("TLabel", font=get_font(FONT_SIZE), background=BG_TOP) + style.configure("TEntry", font=get_font(FONT_SIZE)) + style.configure("TFrame", background=BG_TOP) + # TCombobox field (if other combos use ttk later) + style.configure("TCombobox", font=get_font(FONT_SIZE_COMBO)) + except tk.TclError: + pass + + # Status bar at top: connection state (no popup on error) + status_frame = tk.Frame(root, bg=BG_STATUS_ERROR, padx=12, pady=6) + status_frame.pack(fill=tk.X) + status_lbl = tk.Label( + status_frame, + text="連接不到服務器", + font=get_font(FONT_SIZE_BUTTONS), + bg=BG_STATUS_ERROR, + fg=FG_STATUS_ERROR, + anchor=tk.CENTER, + ) + status_lbl.pack(fill=tk.X) + + def set_status_ok(): + status_frame.configure(bg=BG_STATUS_OK) + status_lbl.configure(text="連接正常", bg=BG_STATUS_OK, fg=FG_STATUS_OK) + + def set_status_error(): + status_frame.configure(bg=BG_STATUS_ERROR) + status_lbl.configure(text="連接不到服務器", bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR) + + def set_status_message(msg: str, is_error: bool = False) -> None: + """Show a message on the status bar.""" + if is_error: + status_frame.configure(bg=BG_STATUS_ERROR) + status_lbl.configure(text=msg, bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR) + else: + status_frame.configure(bg=BG_STATUS_OK) + status_lbl.configure(text=msg, bg=BG_STATUS_OK, fg=FG_STATUS_OK) + + # Laser: keep connection open for repeated sends; close when switching away + laser_conn_ref: list = [None] + laser_send_busy_ref: list = [False] + # DataFlex: shared lock so fixed-qty and continuous jobs do not overlap (independent of laser/label) + dataflex_lock = threading.Lock() + dataflex_busy_ref: list = [False] + # Suppress transient DataFlex "disconnected" UI while we intentionally reset/print. + dataflex_status_grace_until_ref: list[float] = [0.0] + # 標籤機: own lock so label jobs do not overlap; does not block DataFlex or laser + label_lock = threading.Lock() + label_busy_ref: list = [False] + # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs + dataflex_stop_win_ref: list = [None] + # In-memory job snapshot for C 連續印 (not tied to grid row position after start) + active_dataflex_session_ref: list[Optional[DataflexPrintSession]] = [None] + + def lift_dataflex_stop_if_running() -> None: + """After closing another dialog (e.g. 標籤印數), bring the stop panel forward again.""" + w = dataflex_stop_win_ref[0] + if w is None: + return + try: + if w.winfo_exists(): + w.lift() + if os.name == "nt": + w.attributes("-topmost", True) + except tk.TclError: + pass + + def hold_dataflex_status_ok(seconds: float) -> None: + until = time.time() + max(0.0, seconds) + if until > dataflex_status_grace_until_ref[0]: + dataflex_status_grace_until_ref[0] = until + + # Top: left [前一天] [date] [後一天] | right [printer dropdown] + top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP) + top.pack(fill=tk.X) + + date_var = tk.StringVar(value=date.today().isoformat()) + printer_options = ["打袋機 DataFlex", "標簽機", "激光機"] + printer_var = tk.StringVar(value=printer_options[0]) + + def go_prev_day() -> None: + try: + d = date.fromisoformat(date_var.get().strip()) + date_var.set((d - timedelta(days=1)).isoformat()) + load_job_orders(from_user_date_change=True) + except ValueError: + date_var.set(date.today().isoformat()) + load_job_orders(from_user_date_change=True) + + def go_next_day() -> None: + try: + d = date.fromisoformat(date_var.get().strip()) + date_var.set((d + timedelta(days=1)).isoformat()) + load_job_orders(from_user_date_change=True) + except ValueError: + date_var.set(date.today().isoformat()) + load_job_orders(from_user_date_change=True) + + # 前一天 (previous day) with left arrow icon + btn_prev = ttk.Button(top, text="◀ 前一天", command=go_prev_day) + btn_prev.pack(side=tk.LEFT, padx=(0, 8)) + + # Date field (no "日期:" label); shorter width + date_entry = tk.Entry( + top, + textvariable=date_var, + font=get_font(FONT_SIZE), + width=10, + bg="white", + ) + date_entry.pack(side=tk.LEFT, padx=(0, 8), ipady=4) + + # 後一天 (next day) with right arrow icon + btn_next = ttk.Button(top, text="後一天 ▶", command=go_next_day) + btn_next.pack(side=tk.LEFT, padx=(0, 8)) + + # Top right: Setup button + printer selection + right_frame = tk.Frame(top, bg=BG_TOP) + right_frame.pack(side=tk.RIGHT) + ttk.Button(right_frame, text="設定", command=lambda: open_setup_window(root, settings, base_url_ref)).pack( + side=tk.LEFT, padx=(0, 12) + ) + + def on_dataflex_host_reset() -> None: + """Send ~JA/~RO/~JR to clear E1005 latch without turning the printer off.""" + ip = (settings.get("dabag_ip") or "").strip() + port_str = (settings.get("dabag_port") or "3008").strip() + if not ip: + messagebox.showwarning("打袋機", "請先在「設定」填寫打袋機 IP。") + return + try: + port = int(port_str) + except ValueError: + port = 3008 + hold_dataflex_status_ok(12.0) + + def worker() -> None: + try: + send_dataflex_job_counter_reset(ip, port, force=True) + root.after( + 0, + lambda: messagebox.showinfo( + "打袋機", + "已送出主機重設(緩衝清除/計數/軟重設)。\n" + "若畫面仍顯示 E1005,請再按一次或關機重開。", + ), + ) + except OSError as ex: + root.after( + 0, + lambda e=str(ex): messagebox.showerror( + "打袋機", + f"連線失敗,無法重設:{e}", + ), + ) + + threading.Thread(target=worker, daemon=True).start() + + ttk.Button(right_frame, text="打袋重設", command=on_dataflex_host_reset).pack( + side=tk.LEFT, padx=(0, 8) + ) + # 列印機 label: green when printer connected, red when not (checked periodically) + printer_status_lbl = tk.Label( + right_frame, + text="列印機:", + font=get_font(FONT_SIZE), + bg=BG_STATUS_ERROR, + fg="black", + padx=6, + pady=2, + ) + printer_status_lbl.pack(side=tk.LEFT, padx=(0, 4)) + # tk.OptionMenu (not ttk.Combobox): on Windows the ttk dropdown uses OS font and stays tiny; + # OptionMenu's menu supports font= for the open list. + printer_combo = tk.OptionMenu(right_frame, printer_var, *printer_options) + _combo_font = get_font(FONT_SIZE_COMBO) + printer_combo.configure( + font=_combo_font, + bg=BG_TOP, + fg="black", + activebackground=BG_TOP, + activeforeground="black", + width=14, + anchor="w", + highlightthickness=0, + bd=1, + relief=tk.GROOVE, + ) + printer_combo["menu"].configure(font=_combo_font, tearoff=0) + printer_combo.pack(side=tk.LEFT) + + printer_after_ref = [None] + + def set_printer_status_ok(): + printer_status_lbl.configure(bg=BG_STATUS_OK, fg=FG_STATUS_OK) + + def set_printer_status_error(): + printer_status_lbl.configure(bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR) + + def check_printer() -> None: + if printer_after_ref[0] is not None: + root.after_cancel(printer_after_ref[0]) + printer_after_ref[0] = None + if printer_var.get() == "打袋機 DataFlex": + if ( + dataflex_busy_ref[0] + or time.time() < dataflex_status_grace_until_ref[0] + or time.time() < _DATAFLEX_RECOVERY_GRACE_UNTIL + ): + set_printer_status_ok() + printer_after_ref[0] = root.after(5000, check_printer) + return + ok = try_printer_connection(printer_var.get(), settings) + if ok: + set_printer_status_ok() + printer_after_ref[0] = root.after(PRINTER_CHECK_MS, check_printer) + else: + set_printer_status_error() + printer_after_ref[0] = root.after(PRINTER_RETRY_MS, check_printer) + + def on_printer_selection_changed(*args) -> None: + check_printer() + if printer_var.get() != "激光機": + if laser_conn_ref[0] is not None: + try: + laser_conn_ref[0].close() + except Exception: + pass + laser_conn_ref[0] = None + # DataFlex continuous stop panel can drop behind after OptionMenu closes; re-lift for any choice. + root.after(100, lift_dataflex_stop_if_running) + + printer_var.trace_add("write", on_printer_selection_changed) + + def open_setup_window(parent_win: tk.Tk, sett: dict, base_url_ref_list: list) -> None: + """Modal setup: API IP/port, 打袋機/激光機 IP+port, 標簽機 COM port.""" + d = tk.Toplevel(parent_win) + d.title("設定") + d.geometry("440x520") + d.transient(parent_win) + d.grab_set() + d.configure(bg=BG_TOP) + f = tk.Frame(d, padx=16, pady=16, bg=BG_TOP) + f.pack(fill=tk.BOTH, expand=True) + grid_row = [0] # use list so inner function can update + + def _ensure_dot_in_entry(entry: tk.Entry) -> None: + """Allow typing dot (.) in Entry when IME or layout blocks it (e.g. 192.168.17.27).""" + def on_key(event): + if event.keysym in ("period", "decimal"): + pos = entry.index(tk.INSERT) + entry.insert(tk.INSERT, ".") + return "break" + entry.bind("", on_key) + + def add_section(label_text: str, key_ip: str | None, key_port: str | None, key_single: str | None): + out = [] + ttk.Label(f, text=label_text, font=get_font(FONT_SIZE_BUTTONS)).grid( + row=grid_row[0], column=0, columnspan=2, sticky=tk.W, pady=(8, 2) + ) + grid_row[0] += 1 + if key_single: + ttk.Label( + f, + text="列印機名稱 (Windows):", + ).grid( + row=grid_row[0], + column=0, + sticky=tk.W, + pady=2, + ) + var = tk.StringVar(value=sett.get(key_single, "")) + e = tk.Entry(f, textvariable=var, width=22, font=get_font(FONT_SIZE), bg="white") + e.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) + _ensure_dot_in_entry(e) + grid_row[0] += 1 + return [(key_single, var)] + if key_ip: + ttk.Label(f, text="IP:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2) + var_ip = tk.StringVar(value=sett.get(key_ip, "")) + e_ip = tk.Entry(f, textvariable=var_ip, width=22, font=get_font(FONT_SIZE), bg="white") + e_ip.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) + _ensure_dot_in_entry(e_ip) + grid_row[0] += 1 + out.append((key_ip, var_ip)) + if key_port: + ttk.Label(f, text="Port:").grid(row=grid_row[0], column=0, sticky=tk.W, pady=2) + var_port = tk.StringVar(value=sett.get(key_port, "")) + e_port = tk.Entry(f, textvariable=var_port, width=12, font=get_font(FONT_SIZE), bg="white") + e_port.grid(row=grid_row[0], column=1, sticky=tk.W, pady=2) + _ensure_dot_in_entry(e_port) + grid_row[0] += 1 + out.append((key_port, var_port)) + return out + + all_vars = [] + all_vars.extend(add_section("API 伺服器", "api_ip", "api_port", None)) + all_vars.extend(add_section("打袋機 DataFlex", "dabag_ip", "dabag_port", None)) + all_vars.extend(add_section("激光機", "laser_ip", "laser_port", None)) + all_vars.extend(add_section("標簽機 (USB)", None, None, "label_com")) + + def on_save(): + for key, var in all_vars: + sett[key] = var.get().strip() + save_settings(sett) + base_url_ref_list[0] = build_base_url(sett["api_ip"], sett["api_port"]) + d.destroy() + + btn_f = tk.Frame(d, bg=BG_TOP) + btn_f.pack(pady=12) + ttk.Button(btn_f, text="儲存", command=on_save).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_f, text="取消", command=d.destroy).pack(side=tk.LEFT, padx=4) + d.wait_window() + + job_orders_frame = tk.Frame(root, bg=BG_LIST) + job_orders_frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12) + + search_var = tk.StringVar() + search_frame = tk.Frame(job_orders_frame, bg=BG_LIST) + search_frame.pack(fill=tk.X, pady=(0, 6)) + tk.Label( + search_frame, + text="搜尋品號/工單/批號:", + font=get_font(FONT_SIZE_QTY), + bg=BG_LIST, + fg="black", + ).pack(side=tk.LEFT, padx=(0, 6)) + search_entry = tk.Entry( + search_frame, + textvariable=search_var, + width=32, + font=get_font(FONT_SIZE_QTY), + bg="white", + ) + search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8)) + + # Scrollable area for buttons + canvas = tk.Canvas(job_orders_frame, highlightthickness=0, bg=BG_LIST) + scrollbar = ttk.Scrollbar(job_orders_frame, orient=tk.VERTICAL, command=canvas.yview) + inner = tk.Frame(canvas, bg=BG_LIST) + + win_id = canvas.create_window((0, 0), window=inner, anchor=tk.NW) + canvas.configure(yscrollcommand=scrollbar.set) + + def _on_inner_configure(event): + canvas.configure(scrollregion=canvas.bbox("all")) + + def _on_canvas_configure(event): + canvas.itemconfig(win_id, width=event.width) + + inner.bind("", _on_inner_configure) + canvas.bind("", _on_canvas_configure) + + # Mouse wheel: default Tk scroll speed (one unit per notch) + def _on_mousewheel(event): + if getattr(event, "delta", None) is not None: + canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + elif event.num == 5: + canvas.yview_scroll(1, "units") + elif event.num == 4: + canvas.yview_scroll(-1, "units") + + canvas.bind("", _on_mousewheel) + inner.bind("", _on_mousewheel) + canvas.bind("", _on_mousewheel) + canvas.bind("", _on_mousewheel) + inner.bind("", _on_mousewheel) + inner.bind("", _on_mousewheel) + + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Track which row is highlighted (selected for printing) and which job id + selected_row_holder = [None] # [tk.Frame | None] + selected_jo_id_ref = [None] # [int | None] job order id for selection preservation + last_data_ref = [None] # [list | None] last successful fetch for current date + last_plan_start_ref = [date.today()] # plan date for the current list (search filter uses same) + after_id_ref = [None] # [str | None] root.after id to cancel retry/refresh + + def _data_equal(a: Optional[list], b: Optional[list]) -> bool: + if a is None or b is None: + return a is b + if len(a) != len(b): + return False + for x, y in zip(a, b): + if x.get("id") != y.get("id"): + return False + for k in ("bagPrintedQty", "labelPrintedQty", "laserPrintedQty"): + if x.get(k) != y.get(k): + return False + return True + + def _build_list_from_data(data: list, plan_start: date, preserve_selection: bool) -> None: + selected_row_holder[0] = None + year = plan_start.year + selected_id = selected_jo_id_ref[0] if preserve_selection else None + found_row = None + for jo in data: + jo_id = jo.get("id") + raw_batch = batch_no(year, jo_id) if jo_id is not None else "—" + lot_no_val = jo.get("lotNo") + batch = (lot_no_val or "—").strip() if lot_no_val else "—" + jo_no_display = (jo.get("code") or "").strip() + if not jo_no_display and jo_id is not None: + jo_no_display = raw_batch + elif not jo_no_display: + jo_no_display = "—" + # Line 1: job order no.; line 2: 需求 + 已印(袋/標/激)on one row for compact scrolling + head_line = f"工單:{jo_no_display}" + item_code = jo.get("itemCode") or "—" + item_name = jo.get("itemName") or "—" + req_qty = jo.get("reqQty") + qty_str = format_qty(req_qty) + bag_pq = _printed_qty_int(jo.get("bagPrintedQty")) + label_pq = _printed_qty_int(jo.get("labelPrintedQty")) + laser_pq = _printed_qty_int(jo.get("laserPrintedQty")) + meta_line = ( + f"需求:{qty_str} " + f"已印 袋{bag_pq:,} 標{label_pq:,} 激{laser_pq:,}" + ) + # Columns: fixed-width left | fixed-width 品號 | 品名 (expand) + row = tk.Frame( + inner, + bg=BG_ROW, + relief=tk.RAISED, + bd=2, + cursor="hand2", + padx=10, + pady=LIST_ROW_IPADY, + ) + row.pack(fill=tk.X, pady=LIST_ROW_PADY) + + left = tk.Frame(row, bg=BG_ROW, width=LEFT_COL_WIDTH_PX) + left.pack_propagate(False) + left.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y) + batch_lbl = tk.Label( + left, + text=head_line, + font=get_font(FONT_SIZE_BUTTONS), + bg=BG_ROW, + fg="black", + ) + batch_lbl.pack(anchor=tk.W) + meta_lbl = tk.Label( + left, + text=meta_line, + font=get_font(FONT_SIZE_META), + bg=BG_ROW, + fg="#222222", + anchor=tk.W, + justify=tk.LEFT, + wraplength=LEFT_COL_WIDTH_PX - 8, + ) + meta_lbl.pack(anchor=tk.W) + + code_col = tk.Frame(row, bg=BG_ROW, width=CODE_COL_WIDTH_PX) + code_col.pack_propagate(False) + code_col.pack(side=tk.LEFT, anchor=tk.NW, fill=tk.Y, padx=(6, 2)) + code_lbl = tk.Label( + code_col, + text=item_code, + font=get_font(FONT_SIZE_ITEM_CODE), + bg=BG_ROW, + fg="black", + wraplength=ITEM_CODE_WRAP, + justify=tk.LEFT, + anchor=tk.NW, + ) + code_lbl.pack(anchor=tk.NW) + + name_col = tk.Frame(row, bg=BG_ROW) + name_col.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW) + name_lbl = tk.Label( + name_col, + text=item_name or "—", + font=get_font(FONT_SIZE_ITEM_NAME), + bg=BG_ROW, + fg="black", + wraplength=ITEM_NAME_WRAP, + justify=tk.LEFT, + anchor=tk.NW, + ) + name_lbl.pack(anchor=tk.NW) + + def _on_click(e, j=jo, b=batch, r=row): + if ( + printer_var.get() == "打袋機 DataFlex" + and dataflex_busy_ref[0] + and active_dataflex_session_ref[0] is not None + ): + s = active_dataflex_session_ref[0] + messagebox.showwarning( + "打袋機", + f"連續列印進行中,請先按「停止列印」。\n\n" + f"工單:{s.job_code}\n" + f"品號:{s.item_code}\n" + f"品名:{s.item_name}", + ) + return + if selected_row_holder[0] is not None: + set_row_highlight(selected_row_holder[0], False) + set_row_highlight(r, True) + selected_row_holder[0] = r + selected_jo_id_ref[0] = j.get("id") + if printer_var.get() == "打袋機 DataFlex": + ip = (settings.get("dabag_ip") or "").strip() + port_str = (settings.get("dabag_port") or "3008").strip() + try: + port = int(port_str) + except ValueError: + port = 3008 + if not ip: + messagebox.showerror("打袋機", "請在設定中填寫打袋機 DataFlex 的 IP。") + else: + hold_dataflex_status_ok(12.0) + + def _after_row_select_reset() -> None: + bag_ans = ask_bag_count(root) + if bag_ans is not None: + n, continuous = bag_ans + hold_dataflex_status_ok(12.0) + item_code = j.get("itemCode") or "—" + item_name = j.get("itemName") or "—" + item_id = j.get("itemId") + stock_in_line_id = j.get("stockInLineId") + lot_no = j.get("lotNo") + zpl = generate_zpl_dataflex( + b, + item_code, + item_name, + item_id=item_id, + stock_in_line_id=stock_in_line_id, + lot_no=lot_no, + job_order_id=j.get("id"), + expiry_text=job_expiry_zpl_text(j), + ) + label_text = (lot_no or b).strip() + if continuous: + if dataflex_busy_ref[0]: + messagebox.showwarning( + "打袋機", + "請等待目前列印完成或先停止連續列印。", + ) + return + session = build_dataflex_print_session( + j, + b, + zpl, + label_text, + ip, + port, + ) + active_dataflex_session_ref[0] = session + stop_ev = threading.Event() + stop_win = open_dataflex_stop_window( + root, + stop_ev, + dataflex_stop_win_ref, + session, + ) + run_dataflex_continuous_thread( + root=root, + session=session, + stop_event=stop_ev, + stop_win=stop_win, + dataflex_lock=dataflex_lock, + dataflex_busy_ref=dataflex_busy_ref, + dataflex_stop_win_ref=dataflex_stop_win_ref, + active_session_ref=active_dataflex_session_ref, + base_url=base_url_ref[0], + set_status_message=set_status_message, + on_recorded=lambda: load_job_orders( + from_user_date_change=False + ), + ) + else: + run_dataflex_fixed_qty_thread( + root=root, + dataflex_lock=dataflex_lock, + dataflex_busy_ref=dataflex_busy_ref, + ip=ip, + port=port, + n=n, + zpl=zpl, + label_text=label_text, + jo_id=j.get("id"), + base_url=base_url_ref[0], + set_status_message=set_status_message, + on_recorded=lambda: load_job_orders( + from_user_date_change=False + ), + ) + + def _row_select_reset_worker() -> None: + try: + send_dataflex_start_job_reset(ip, port, force=True) + except OSError as ex: + root.after( + 0, + lambda e=str(ex): messagebox.showwarning( + "打袋機", + f"點選工單時重設批次計數失敗(仍可比對數量):{e}", + ), + ) + root.after(0, _after_row_select_reset) + + threading.Thread(target=_row_select_reset_worker, daemon=True).start() + elif printer_var.get() == "標簽機": + com = (settings.get("label_com") or "").strip() + if not com: + messagebox.showerror("標簽機", "請在設定中填寫標簽機名稱 (例如:TSC TTP-246M Pro)。") + else: + count = ask_label_count(root) + lift_dataflex_stop_if_running() + if count is not None: + item_code = j.get("itemCode") or "—" + item_name = j.get("itemName") or "—" + item_id = j.get("itemId") + stock_in_line_id = j.get("stockInLineId") + lot_no = j.get("lotNo") + n = count + try: + # Always render to image (Chinese OK), then send as ZPL graphic (^GFA). + # This is more reliable than Windows GDI and works for both Windows printer name and COM. + if not _HAS_PIL_QR: + raise RuntimeError("請先安裝 Pillow + qrcode(pip install Pillow qrcode[pil])。") + label_img = render_label_to_image( + b, item_code, item_name, + item_id=item_id, stock_in_line_id=stock_in_line_id, + lot_no=lot_no, + ) + zpl_img = _image_to_zpl_gfa(label_img) + run_label_print_batch_thread( + root=root, + label_lock=label_lock, + label_busy_ref=label_busy_ref, + com=com, + zpl_img=zpl_img, + n=n, + jo_id=j.get("id"), + base_url=base_url_ref[0], + set_status_message=set_status_message, + on_recorded=lambda: load_job_orders( + from_user_date_change=False + ), + ) + except Exception as err: + messagebox.showerror("標簽機", f"列印失敗:{err}") + elif printer_var.get() == "激光機": + ip = (settings.get("laser_ip") or "").strip() + port_str = (settings.get("laser_port") or "45678").strip() + try: + port = int(port_str) + except ValueError: + port = 45678 + if not ip: + set_status_message("請在設定中填寫激光機的 IP。", is_error=True) + else: + item_id = j.get("itemId") + stock_in_line_id = j.get("stockInLineId") + item_code_val = j.get("itemCode") or "" + item_name_val = j.get("itemName") or "" + run_laser_row_send_thread( + root=root, + laser_conn_ref=laser_conn_ref, + laser_busy_ref=laser_send_busy_ref, + ip=ip, + port=port, + item_id=item_id, + stock_in_line_id=stock_in_line_id, + item_code=item_code_val, + item_name=item_name_val, + set_status_message=set_status_message, + base_url=base_url_ref[0], + job_order_id=j.get("id"), + on_recorded=lambda: load_job_orders(from_user_date_change=False), + expiry_text=job_expiry_laser_param(j), + ) + + for w in ( + row, + left, + batch_lbl, + meta_lbl, + code_col, + code_lbl, + name_col, + name_lbl, + ): + w.bind("", _on_click) + w.bind("", _on_mousewheel) + w.bind("", _on_mousewheel) + w.bind("", _on_mousewheel) + if preserve_selection and selected_id is not None and jo.get("id") == selected_id: + found_row = row + if found_row is not None: + set_row_highlight(found_row, True) + selected_row_holder[0] = found_row + + def refresh_visible_list() -> None: + """Re-apply search filter to last fetched rows without hitting the API.""" + raw = last_data_ref[0] + if raw is None: + return + ps = last_plan_start_ref[0] + needle = search_var.get().strip() + shown = _filter_job_orders_by_search(raw, needle) if needle else raw + for w in inner.winfo_children(): + w.destroy() + _build_list_from_data(shown, ps, preserve_selection=True) + + search_entry.bind("", lambda e: refresh_visible_list()) + + def load_job_orders(from_user_date_change: bool = False) -> None: + if after_id_ref[0] is not None: + root.after_cancel(after_id_ref[0]) + after_id_ref[0] = None + date_str = date_var.get().strip() + try: + plan_start = date.fromisoformat(date_str) + except ValueError: + messagebox.showerror("日期錯誤", f"請使用 yyyy-MM-dd 格式。目前:{date_str}") + return + if from_user_date_change: + selected_row_holder[0] = None + selected_jo_id_ref[0] = None + try: + data = fetch_job_orders(base_url_ref[0], plan_start) + except requests.RequestException: + set_status_error() + after_id_ref[0] = root.after(RETRY_MS, lambda: load_job_orders(from_user_date_change=False)) + return + set_status_ok() + old_data = last_data_ref[0] + last_data_ref[0] = data + last_plan_start_ref[0] = plan_start + data_changed = not _data_equal(old_data, data) + if data_changed or from_user_date_change: + printing_busy = ( + dataflex_busy_ref[0] + or label_busy_ref[0] + or laser_send_busy_ref[0] + ) + # Do not destroy/rebuild all rows while printing — that removes click bindings and + # can hide DataFlex「停止列印」. Retry until idle (one deferred pass at a time). + if printing_busy and not from_user_date_change: + after_id_ref[0] = root.after( + JOB_LIST_DEFER_WHILE_PRINTING_MS, + lambda: load_job_orders(from_user_date_change=False), + ) + else: + # Rebuild list: clear and rebuild from current data (last_data_ref already updated) + for w in inner.winfo_children(): + w.destroy() + preserve = not from_user_date_change + needle = search_var.get().strip() + shown = _filter_job_orders_by_search(data, needle) if needle else data + _build_list_from_data(shown, plan_start, preserve_selection=preserve) + if from_user_date_change: + canvas.yview_moveto(0) + if JOB_LIST_AUTO_REFRESH_MS > 0: + after_id_ref[0] = root.after( + JOB_LIST_AUTO_REFRESH_MS, + lambda: load_job_orders(from_user_date_change=False), + ) + + # Load default (today) on start; then start printer connection check + root.after(100, lambda: load_job_orders(from_user_date_change=True)) + root.after(300, check_printer) + + root.mainloop() + + +def _startup_error_log_path() -> str: + if getattr(sys, "frozen", False): + base = os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(base, "bag4_startup_error.log") + + +if __name__ == "__main__": + try: + main() + except SystemExit: + raise + except Exception: + import traceback + + log_path = _startup_error_log_path() + try: + with open(log_path, "w", encoding="utf-8") as f: + traceback.print_exc(file=f) + except OSError: + log_path = "(could not write log file)" + msg = f"Bag4 啟動失敗,詳情已寫入:\n{log_path}" + print(msg, file=sys.stderr) + traceback.print_exc() + try: + _err_root = tk.Tk() + _err_root.withdraw() + messagebox.showerror("Bag4", msg) + _err_root.destroy() + except Exception: + pass + if getattr(sys, "frozen", False): + try: + input("按 Enter 關閉…") + except (EOFError, KeyboardInterrupt): + pass + sys.exit(1) \ No newline at end of file diff --git a/python/Bag4.spec b/python/Bag4.spec new file mode 100644 index 0000000..b531464 --- /dev/null +++ b/python/Bag4.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['Bag4.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='Bag4', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/python/__pycache__/Bag4.cpython-314.pyc b/python/__pycache__/Bag4.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..267a3928b8d1c330d7048de10a8cd6bf645bf3aa GIT binary patch literal 164826 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

5^^xtbY4iTqZh4xI4r)yVqppwJPR7N>3Ol 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 str: + if value is None: + return "NULL" + return "'" + value.replace("\\", "\\\\").replace("'", "''") + "'" + + +def sql_int(value: int | None) -> str: + return "NULL" if value is None else str(value) + + +def norm_temp(raw) -> str | None: + if raw is None: + return None + s = str(raw).strip().replace("℃", "").replace("°C", "").replace(" ", "") + if not s or s.lower() in ("n/a", "none", "null", "-", "nan"): + return None + return s + + +def is_minus18(temp: str | None) -> bool: + return temp in ("-18", "-18.0") + + +def to_days(raw) -> int | None: + if raw is None or raw == "": + return None + if isinstance(raw, str) and raw.strip().lower() in ("n/a", "-", "none", "null"): + return None + try: + n = int(round(float(raw))) + except (TypeError, ValueError): + return None + return n if n > 0 else None + + +def pick_days(t1, d1, t2, d2) -> tuple[int | None, str | None, int | None]: + """Return (chilledDays, chilledTemp, minus18Days).""" + chilled_days = None + chilled_temp = None + minus18_days = None + for temp, days in ((norm_temp(t1), to_days(d1)), (norm_temp(t2), to_days(d2))): + if not temp or not days: + continue + if is_minus18(temp): + if minus18_days is None: + minus18_days = days + elif chilled_days is None: + chilled_days = days + chilled_temp = temp + return chilled_days, chilled_temp, minus18_days + + +def load_rows(xlsx: Path) -> tuple[str, list[dict]]: + wb = openpyxl.load_workbook(xlsx, data_only=True) + sheet_name = PREFERRED_SHEET if PREFERRED_SHEET in wb.sheetnames else wb.sheetnames[0] + ws = wb[sheet_name] + kept: list[dict] = [] + seen: set[str] = set() + for row in ws.iter_rows(min_row=4, values_only=True): + code_raw = row[3] + if not code_raw: + continue + code = str(code_raw).strip().upper() + if not code or code in seen: + continue + chilled, storage, minus18 = pick_days(row[5], row[6], row[7], row[8]) + if chilled is None and minus18 is None: + continue + seen.add(code) + use_minus18 = 1 if chilled is None else 0 + name = str(row[4]).strip() if row[4] else "" + cat = str(row[2]).strip() if row[2] else "" + remark = f"{cat} {name}".strip()[:255] + kept.append( + { + "itemCode": code, + "defaultDays": chilled, + "minus18Days": minus18, + "useMinus18": use_minus18, + "storageC": "-18" if use_minus18 else storage, + "remarks": remark, + } + ) + return sheet_name, kept + + +def render_sql(sheet_name: str, kept: list[dict], source_name: str) -> str: + today = dt.date.today().isoformat() + n_flag = sum(1 for r in kept if r["useMinus18"] == 1) + lines = [ + "--liquibase formatted sql", + "", + "--changeset fpsms:seed_item_default_shelf_life_minus18_flag", + f"--comment: Upsert joExpiry.xlsx sheet {sheet_name}: defaultDays + minus18Days + useMinus18 flag", + f"-- generated {today}; rows={len(kept)} useMinus18=1 (frozen-only default)={n_flag}", + "INSERT INTO `item_default_shelf_life`", + "(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`,", + " `itemCode`, `defaultDays`, `minus18Days`, `useMinus18`, `openedDays`, `storageC`, `remarks`)", + "VALUES", + ] + value_rows = [] + for r in kept: + value_rows.append( + "(" + + ", ".join( + [ + "NOW()", + "'system'", + "0", + "NOW()", + "'system'", + "0", + sql_str(r["itemCode"]), + sql_int(r["defaultDays"]), + sql_int(r["minus18Days"]), + str(r["useMinus18"]), + "NULL", + sql_str(r["storageC"]), + sql_str(r["remarks"]), + ] + ) + + ")" + ) + lines.append(",\n".join(value_rows)) + lines.append("ON DUPLICATE KEY UPDATE") + lines.append(" `defaultDays` = VALUES(`defaultDays`),") + lines.append(" `minus18Days` = VALUES(`minus18Days`),") + lines.append(" `useMinus18` = VALUES(`useMinus18`),") + lines.append(" `openedDays` = VALUES(`openedDays`),") + lines.append(" `storageC` = VALUES(`storageC`),") + lines.append(" `remarks` = VALUES(`remarks`),") + lines.append(" `modified` = NOW(),") + lines.append(" `modifiedBy` = 'system',") + lines.append(" `deleted` = 0;") + lines.append("") + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "xlsx", + nargs="?", + default=str(Path.home() / "Downloads" / "joExpiry.xlsx"), + ) + args = parser.parse_args() + xlsx = Path(args.xlsx) + if not xlsx.is_file(): + raise SystemExit(f"Excel not found: {xlsx}") + sheet_name, kept = load_rows(xlsx) + sql = render_sql(sheet_name, kept, xlsx.name) + OUT_SQL.parent.mkdir(parents=True, exist_ok=True) + OUT_SQL.write_text(sql, encoding="utf-8") + n_flag = sum(1 for r in kept if r["useMinus18"] == 1) + print(f"sheet={sheet_name} rows={len(kept)} useMinus18=1={n_flag} useMinus18=0={len(kept) - n_flag}") + print(f"wrote {OUT_SQL}") + + +if __name__ == "__main__": + main() diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt index 459ad0b..546f1a6 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt @@ -195,6 +195,17 @@ open class M18MasterDataService( return itemsService.findByM18Id(m18ItemId)?.id } + private fun mapM18ProductType(udfProducttype: String?): String { + return when (udfProducttype) { + M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type + M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type + M18ItemType.FG.type -> ItemType.FG.type + M18ItemType.SFG.type -> ItemType.SFG.type + M18ItemType.ITEM.type -> ItemType.ITEM.type + else -> ItemType.MATERIAL.type + } + } + open fun saveProduct(id: Long): MessageResponse? { try { ensureCunitSeededForAllIfEmpty() @@ -203,20 +214,14 @@ open class M18MasterDataService( val price = itemDetail?.data?.price if (itemDetail != null && pro != null) { + val mappedType = mapM18ProductType(pro.udfProducttype) val existingItem = itemsService.findByM18Id(id) val saveItemRequest = NewItemRequest( code = pro.code, name = pro.desc, // type = if (pro.seriesId == m18Config.SERIESID_PF) ProductType.MATERIAL // else ItemType.PRODUCT, - type = when (pro.udfProducttype) { - M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type - M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type - M18ItemType.FG.type -> ItemType.FG.type - M18ItemType.SFG.type -> ItemType.SFG.type - M18ItemType.ITEM.type -> ItemType.ITEM.type - else -> ItemType.MATERIAL.type - }, + type = mappedType, id = existingItem?.id, description = pro.desc, remarks = null, @@ -247,6 +252,12 @@ open class M18MasterDataService( logger.error("saveItem duplicate code for M18 item $id (code=${pro.code}): ${savedItem.message}") return null } + if (ItemM18IdRemapSupport.isM18IdLinkOnly(existingItem == null, savedItem.message)) { + logger.warn( + "Linked M18 product id=$id code=${pro.code} to existing local item id=$localItemId; skip UOM rebuild" + ) + return savedItem.copy(id = localItemId) + } logger.info("Processing item uom...") // Find the item uom that ready to delete (not in m18) val existingItemUoms = itemUomService.findAllByItemsId(localItemId) @@ -382,6 +393,7 @@ open class M18MasterDataService( val price = itemDetail?.data?.price if (itemDetail != null && pro != null) { + val mappedType = mapM18ProductType(pro.udfProducttype) // ── Use cache instead of direct call ──────────────────────── val existingItem = itemCache.getOrPut(item.id) { itemsService.findByM18Id(item.id) @@ -390,14 +402,7 @@ open class M18MasterDataService( val saveItemRequest = NewItemRequest( code = pro.code, name = pro.desc, - type = when (pro.udfProducttype) { - M18ItemType.CONSUMABLES.type -> ItemType.CONSUMABLES.type - M18ItemType.NONCONSUMABLES.type -> ItemType.NONCONSUMABLES.type - M18ItemType.FG.type -> ItemType.FG.type - M18ItemType.SFG.type -> ItemType.SFG.type - M18ItemType.ITEM.type -> ItemType.ITEM.type - else -> ItemType.MATERIAL.type - }, + type = mappedType, id = existingItem?.id, description = pro.desc, remarks = null, @@ -430,6 +435,13 @@ open class M18MasterDataService( logger.error("saveItem duplicate code for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}") return@forEach } + if (ItemM18IdRemapSupport.isM18IdLinkOnly(existingItem == null, savedItem.message)) { + logger.warn( + "Linked M18 product id=${item.id} code=${pro.code} to existing local item id=$localItemId; skip UOM rebuild" + ) + successList.add(item.id) + return@forEach + } logger.info("Processing item uom...") val existingItemUoms = itemUomService.findAllByItemsId(localItemId) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFile.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFile.kt new file mode 100644 index 0000000..75206f0 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFile.kt @@ -0,0 +1,41 @@ +package com.ffii.fpsms.modules.jobOrder.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Lob +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes + +@Entity +@Table(name = "onpack_template_file") +open class OnPackTemplateFile : BaseEntity() { + + @NotNull + @Size(max = 20) + @Column(name = "machine", length = 20, nullable = false) + open var machine: String? = null + + @NotNull + @Size(max = 50) + @Column(name = "itemCode", length = 50, nullable = false) + open var itemCode: String? = null + + @NotNull + @Size(max = 200) + @Column(name = "fileName", length = 200, nullable = false) + open var fileName: String? = null + + @NotNull + @Column(name = "byteSize", nullable = false) + open var byteSize: Int? = null + + @NotNull + @Lob + @JdbcTypeCode(SqlTypes.BLOB) + @Column(name = "fileBytes", nullable = false, columnDefinition = "MEDIUMBLOB") + open var fileBytes: ByteArray? = null +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFileRepository.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFileRepository.kt new file mode 100644 index 0000000..55c0484 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/OnPackTemplateFileRepository.kt @@ -0,0 +1,29 @@ +package com.ffii.fpsms.modules.jobOrder.entity + +import com.ffii.core.support.AbstractRepository +import org.springframework.stereotype.Repository + +@Repository +interface OnPackTemplateFileRepository : AbstractRepository { + + fun findByDeletedFalseAndMachineAndItemCodeIgnoreCaseAndFileNameIgnoreCase( + machine: String, + itemCode: String, + fileName: String, + ): OnPackTemplateFile? + + fun findFirstByDeletedFalseAndMachineAndFileNameIgnoreCase( + machine: String, + fileName: String, + ): OnPackTemplateFile? + + fun findByMachineAndItemCodeIgnoreCaseAndFileNameIgnoreCase( + machine: String, + itemCode: String, + fileName: String, + ): OnPackTemplateFile? + + fun findByDeletedFalseAndMachineOrderByItemCodeAscFileNameAsc(machine: String): List + + fun findByDeletedFalseOrderByMachineAscItemCodeAscFileNameAsc(): List +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt index 69c887c..3bb4f60 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt @@ -83,6 +83,7 @@ class LaserBag2AutoSendService( jobOrderId = jo.id, jobOrderNo = jo.code, lotNo = jo.lotNo, + expiryDate = jo.expiryDate?.toString(), source = "AUTO", ), ) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackImageTemplateCodec.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackImageTemplateCodec.kt new file mode 100644 index 0000000..a80fa24 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackImageTemplateCodec.kt @@ -0,0 +1,45 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import java.nio.charset.StandardCharsets + +/** + * OnPack `.image` templates are often UTF-16 LE with BOM (Windows export). + * Decode/encode must round-trip the same encoding or SmartDate X40 will refuse the job. + */ +object OnPackImageTemplateCodec { + + private val bomUtf16Le = byteArrayOf(0xFF.toByte(), 0xFE.toByte()) + private val bomUtf16Be = byteArrayOf(0xFE.toByte(), 0xFF.toByte()) + private val bomUtf8 = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) + + fun decode(bytes: ByteArray): Pair ByteArray> { + return when { + bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] -> { + val body = bytes.copyOfRange(2, bytes.size) + val text = String(body, StandardCharsets.UTF_16LE) + text to { s -> bomUtf16Le + s.toByteArray(StandardCharsets.UTF_16LE) } + } + bytes.size >= 2 && bytes[0] == bomUtf16Be[0] && bytes[1] == bomUtf16Be[1] -> { + val body = bytes.copyOfRange(2, bytes.size) + val text = String(body, StandardCharsets.UTF_16BE) + text to { s -> bomUtf16Be + s.toByteArray(StandardCharsets.UTF_16BE) } + } + bytes.size >= 3 && bytes[0] == bomUtf8[0] && bytes[1] == bomUtf8[1] && bytes[2] == bomUtf8[2] -> { + val body = bytes.copyOfRange(3, bytes.size) + val text = String(body, StandardCharsets.UTF_8) + text to { s -> bomUtf8 + s.toByteArray(StandardCharsets.UTF_8) } + } + bytes.size >= 2 && bytes[0] == 0x3C.toByte() && bytes[1] == 0x00.toByte() -> { + val text = String(bytes, StandardCharsets.UTF_16LE) + text to { s -> s.toByteArray(StandardCharsets.UTF_16LE) } + } + else -> { + val utf8 = String(bytes, StandardCharsets.UTF_8) + utf8 to { s -> s.toByteArray(StandardCharsets.UTF_8) } + } + } + } + + fun hasUtf16LeBom(bytes: ByteArray): Boolean = + bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXml.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXml.kt new file mode 100644 index 0000000..ad3c0f4 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXml.kt @@ -0,0 +1,152 @@ +package com.ffii.fpsms.modules.jobOrder.service + +/** + * Puts today's expiry BMP into 汁水機 OnPack `.image` XML without replacing LOGO_3 + * (production / print date). + * + * If the designer template already has an expiry slot (`LOGO_5` or `LOGO_EXP`), that + * field's FileName is rewritten and geometry is left as in the XML (no second field). + * Old 4-logo templates still get a generated `LOGO_EXP` (QR moved down). + */ +object OnPackJuiceExpiryXml { + + private val hasLogo4 = Regex("""\s*LOGO_4\s*""") + private val logo3X = Regex( + """(?s)\s*LOGO_3\s*[\s\S]*?[\s\S]*?\s*(\d+)\s*""", + ) + private val logo3Y = Regex( + """(?s)\s*LOGO_3\s*[\s\S]*?[\s\S]*?\s*(\d+)\s*""", + ) + private val logo3HeightReplace = Regex( + """(?s)(\s*LOGO_3\s*[\s\S]*?)\s*\d+\s*()""", + ) + private val logo4Height = Regex( + """(?s)\s*LOGO_4\s*[\s\S]*?\s*(\d+)\s*""", + ) + private val logo4YReplace = Regex( + """(?s)(\s*LOGO_4\s*[\s\S]*?[\s\S]*?)\s*\d+\s*()""", + ) + private val logo4HeightReplace = Regex( + """(?s)(\s*LOGO_4\s*[\s\S]*?)\s*\d+\s*()""", + ) + private val canvasSize = Regex( + """(?s)\s*(\d+)\s*\s*\s*(\d+)\s*\s*]*>\s*\s*LOGO_EXP\s*[\s\S]*?""", + ) + private val logo4Open = Regex( + """(?s)(]*>\s*\s*LOGO_4\s*)""", + ) + + fun applyExpiry(xml: String, bmpFileName: String, bmpPixelWidth: Int): String { + val name = OnPackXml.escapeText(bmpFileName.trim()) + if (name.isEmpty() || !xml.contains("")) { + return xml + } + existingExpirySlotName(xml)?.let { slot -> + return rewriteLogoFileName(xml, slot, name) + } + if (!hasLogo4.containsMatchIn(xml)) { + return xml + } + val layout = layoutFor(xml, bmpPixelWidth) + var out = logo3HeightReplace.replaceFirst(xml, "$1${layout.dateHeight}$2") + out = logo4YReplace.replaceFirst(out, "$1${layout.qrY}$2") + out = logo4HeightReplace.replaceFirst(out, "$1${layout.qrHeight}$2") + val field = expiryLogoXml( + layout.x, + layout.expY, + name, + layout.expWidth, + layout.expHeight, + OnPackXml.nextId(xml), + ) + return if (logoExpBlock.containsMatchIn(out)) { + logoExpBlock.replace(out, field) + } else { + insertLogoExp(out, field) + } + } + + /** Designer expiry slot: prefer LOGO_EXP, then LOGO_5 (CoLOS export). */ + fun existingExpirySlotName(xml: String): String? { + if (Regex("""\s*LOGO_EXP\s*""").containsMatchIn(xml)) return "LOGO_EXP" + if (Regex("""\s*LOGO_5\s*""").containsMatchIn(xml)) return "LOGO_5" + return null + } + + fun rewriteLogoFileName(xml: String, slot: String, fileName: String): String { + val re = Regex( + """(?s)(\s*${Regex.escape(slot)}\s*[\s\S]*?)([^<]*)()""", + ) + return re.replaceFirst(xml, "$1$fileName$3") + } + + internal fun layoutFor(xml: String, bmpPixelWidth: Int): JuiceExpiryLayout { + val x = logo3X.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 500 + val logo3YVal = logo3Y.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2500 + val qrH0 = logo4Height.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 3000 + val canvas = canvasSize.find(xml) + val canvasW = canvas?.groupValues?.get(1)?.toIntOrNull() ?: 5300 + val canvasH = canvas?.groupValues?.get(2)?.toIntOrNull() ?: 7100 + val dateHeight = DATE_HEIGHT_UNITS + val expHeight = EXP_HEIGHT_UNITS + val expY = logo3YVal + dateHeight + GAP_AFTER_DATE + val qrBottomMax = (canvasH - 80).coerceAtLeast(expY + expHeight + 40) + var qrY = expY + expHeight + GAP_AFTER_EXP + if (qrY >= qrBottomMax) { + qrY = (expY + expHeight + 80).coerceAtMost(qrBottomMax - 1) + } + val qrHeight = qrH0.coerceAtMost((qrBottomMax - qrY).coerceAtLeast(1)) + val expWidth = (bmpPixelWidth * expHeight / BMP_TARGET_HEIGHT).coerceIn(1800, 4000) + .coerceAtMost((canvasW - x).coerceAtLeast(200)) + return JuiceExpiryLayout( + x = x, + dateHeight = dateHeight, + expY = expY, + expWidth = expWidth, + expHeight = expHeight, + qrY = qrY, + qrHeight = qrHeight, + canvasWidth = canvasW, + canvasHeight = canvasH, + ) + } + + private fun insertLogoExp(xml: String, field: String): String { + if (logo4Open.containsMatchIn(xml)) { + return logo4Open.replaceFirst(xml, "$field$1") + } + return xml.replaceFirst("", "$field") + } + + private fun expiryLogoXml( + x: Int, + y: Int, + bmpFileName: String, + width: Int, + height: Int, + id: Int, + ): String { + return """LOGO_EXP$id$x$y0BLACK$bmpFileName$width$height""" + } + + private const val DATE_HEIGHT_UNITS = 640 + private const val EXP_HEIGHT_UNITS = 260 + private const val GAP_AFTER_DATE = 80 + private const val GAP_AFTER_EXP = 800 + private const val BMP_TARGET_HEIGHT = 180 +} + +internal data class JuiceExpiryLayout( + val x: Int, + val dateHeight: Int, + val expY: Int, + val expWidth: Int, + val expHeight: Int, + val qrY: Int, + val qrHeight: Int, + val canvasWidth: Int = 5300, + val canvasHeight: Int = 7100, +) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXml.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXml.kt new file mode 100644 index 0000000..b7e699d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXml.kt @@ -0,0 +1,61 @@ +package com.ffii.fpsms.modules.jobOrder.service + +/** + * Injects an expiry text field into OnPack2023 lemon `.image` XML without replacing + * TEXT_3 (production / print date, TimeDate offset 0). + * + * Lot-like TEXT_2 is at a higher X; TEXT_3 is lower X (90° rotation). Expiry sits between them. + */ +object OnPackLemonExpiryXml { + + private val text2X = Regex( + """(?s)\s*TEXT_2\s*[\s\S]*?[\s\S]*?\s*(\d+)\s*""", + ) + private val text3X = Regex( + """(?s)\s*TEXT_3\s*[\s\S]*?[\s\S]*?\s*(\d+)\s*""", + ) + private val text2Y = Regex( + """(?s)\s*TEXT_2\s*[\s\S]*?[\s\S]*?\s*(\d+)\s*""", + ) + private val text2PointSize = Regex( + """(?s)(\s*TEXT_2\s*[\s\S]*?)(\d+)()""", + ) + private val textExpStatic = Regex( + """(?s)(\s*TEXT_EXP\s*[\s\S]*?]*>\s*)([^<]*)()""", + ) + + private val hasText3 = Regex("""\s*TEXT_3\s*""") + + fun applyExpiry(xml: String, expiryText: String): String { + val compact = OnPackXml.escapeText(expiryText.trim()) + if (compact.isEmpty() || !hasText3.containsMatchIn(xml) || !xml.contains("")) { + return xml + } + val smallerLot = shrinkText2(xml) + if (textExpStatic.containsMatchIn(smallerLot)) { + return textExpStatic.replace(smallerLot) { m -> + m.groupValues[1] + compact + m.groupValues[3] + } + } + return insertTextExp(smallerLot, compact) + } + + internal fun shrinkText2(xml: String): String { + return text2PointSize.replaceFirst(xml, "$1${TEXT_2_POINT_SIZE}$3") + } + + private fun insertTextExp(xml: String, expiryYyyymmdd: String): String { + val lotX = text2X.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2900 + val dateX = text3X.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2000 + val y = text2Y.find(xml)?.groupValues?.get(1)?.toIntOrNull() ?: 2250 + val expX = (lotX + dateX) / 2 + val field = expiryFieldXml(expX, y, expiryYyyymmdd, OnPackXml.nextId(xml)) + return xml.replaceFirst("", "$field") + } + + private fun expiryFieldXml(x: Int, y: Int, expiryText: String, id: Int): String { + return """TEXT_EXP$id$x$y90BLACKfalsefalseMERGEArial560arialbd.ttf93$expiryText""" + } + + private const val TEXT_2_POINT_SIZE = "520" +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt new file mode 100644 index 0000000..49e0bcd --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt @@ -0,0 +1,364 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.jobOrder.entity.OnPackTemplateFile +import com.ffii.fpsms.modules.jobOrder.entity.OnPackTemplateFileRepository +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedCatalogDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedItemDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateFileDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateUploadResponse +import org.slf4j.LoggerFactory +import org.springframework.core.io.support.PathMatchingResourcePatternResolver +import org.springframework.dao.DataAccessException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import java.sql.Timestamp +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Service +open class OnPackTemplateFileService( + private val repository: OnPackTemplateFileRepository, + private val jdbcDao: JdbcDao, +) { + private val logger = LoggerFactory.getLogger(javaClass) + private val resourceResolver = PathMatchingResourcePatternResolver() + + @Transactional(readOnly = true) + open fun list(machine: String?): List { + return templateTableOr(emptyList()) { + val normalized = machine?.takeIf { it.isNotBlank() }?.let { normalizeMachine(it) } + val sql = buildString { + append( + """ + SELECT id, machine, itemCode, fileName, byteSize, modified + FROM onpack_template_file + WHERE deleted = 0 + """.trimIndent(), + ) + if (normalized != null) append(" AND machine = :machine") + append(" ORDER BY machine, itemCode, fileName") + } + val params = if (normalized == null) emptyMap() else mapOf("machine" to normalized) + jdbcDao.queryForList(sql, params).map { row -> + OnPackTemplateFileDto( + id = (row["id"] as Number).toLong(), + machine = row["machine"]?.toString().orEmpty(), + itemCode = row["itemCode"]?.toString().orEmpty(), + fileName = row["fileName"]?.toString().orEmpty(), + byteSize = (row["byteSize"] as? Number)?.toInt() ?: 0, + modified = formatModified(row["modified"]), + ) + } + } + } + + @Transactional + open fun upload(machineRaw: String, itemCodeRaw: String, files: List): OnPackTemplateUploadResponse { + val machine = normalizeMachine(machineRaw) + val itemCode = normalizeItemCode(itemCodeRaw) + require(files.isNotEmpty()) { "請選擇要上傳的檔案(.image / .bmp / .job)" } + val saved = mutableListOf() + files.forEach { part -> + if (part.isEmpty) return@forEach + val original = part.originalFilename ?: part.name + val storedName = storedFileName(machine, itemCode, original) + val bytes = part.bytes + require(bytes.isNotEmpty()) { "空檔案:$original" } + require(bytes.size <= MAX_BYTES) { "檔案過大(上限 ${MAX_BYTES / (1024 * 1024)}MB):$original" } + val row = try { + repository.findByMachineAndItemCodeIgnoreCaseAndFileNameIgnoreCase( + machine, + itemCode, + storedName, + ) ?: OnPackTemplateFile().apply { + this.machine = machine + this.itemCode = itemCode + this.fileName = storedName + } + } catch (e: DataAccessException) { + throw IllegalArgumentException( + "無法儲存 OnPack 模板。請重啟後端以執行 Liquibase(建立 onpack_template_file)。", + e, + ) + } + row.deleted = false + row.fileBytes = bytes + row.byteSize = bytes.size + row.fileName = storedName + row.itemCode = itemCode + row.machine = machine + try { + repository.save(row) + } catch (e: DataAccessException) { + throw IllegalArgumentException( + "無法儲存 OnPack 模板。請重啟後端以執行 Liquibase(建立 onpack_template_file)。", + e, + ) + } + saved.add(storedName) + } + require(saved.isNotEmpty()) { "沒有可儲存的檔案" } + try { + ensureOnPackQrRow(itemCode, machine, saved) + } catch (e: DataAccessException) { + logger.warn("onpack_qr insert after template upload failed for {}", itemCode, e) + } + return OnPackTemplateUploadResponse(machine = machine, itemCode = itemCode, saved = saved) + } + + @Transactional + open fun softDelete(id: Long) { + val row = try { + repository.findById(id).orElseThrow { IllegalArgumentException("找不到該模板檔") } + } catch (e: DataAccessException) { + throw IllegalArgumentException( + "無法刪除 OnPack 模板。請重啟後端以執行 Liquibase(建立 onpack_template_file)。", + e, + ) + } + row.deleted = true + repository.save(row) + } + + @Transactional(readOnly = true) + open fun loadImage(machine: String, itemCode: String): ByteArray? { + val code = itemCode.trim() + if (code.isEmpty()) return null + return templateTableOr(null) { + imageFileNameCandidates(machine, code).firstNotNullOfOrNull { name -> + repository.findByDeletedFalseAndMachineAndItemCodeIgnoreCaseAndFileNameIgnoreCase( + machine, + code, + name, + )?.fileBytes + } + } + } + + @Transactional(readOnly = true) + open fun loadAsset(machine: String, fileName: String): ByteArray? { + val safe = sanitizeFileName(fileName) ?: return null + return templateTableOr(null) { + repository.findFirstByDeletedFalseAndMachineAndFileNameIgnoreCase(machine, safe)?.fileBytes + } + } + + @Transactional(readOnly = true) + open fun itemCodesWithImage(machine: String): Set { + return templateTableOr(emptySet()) { + jdbcDao.queryForStrings( + """ + SELECT DISTINCT UPPER(TRIM(itemCode)) + FROM onpack_template_file + WHERE deleted = 0 + AND machine = :machine + AND LOWER(fileName) LIKE '%.image' + """.trimIndent(), + mapOf("machine" to machine), + ).mapNotNull { it.trim().uppercase().takeIf { code -> code.isNotEmpty() } }.toSet() + } + } + + @Transactional(readOnly = true) + open fun supportedCatalog(): OnPackSupportedCatalogDto { + return OnPackSupportedCatalogDto( + juice = supportedItems(MACHINE_JUICE), + lemon = supportedItems(MACHINE_LEMON), + ) + } + + private fun supportedItems(machine: String): List { + val registered = registeredCodes(machine) + val inDatabase = itemCodesWithImage(machine) + val builtin = builtinImageCodes(machine) + return mergeSupported(registered, inDatabase, builtin) + } + + private fun registeredCodes(machine: String): Set { + return try { + val templateType = if (machine == MACHINE_LEMON) "text" else "bmp" + val sql = if (machine == MACHINE_LEMON) { + """ + SELECT DISTINCT UPPER(TRIM(code)) AS code + FROM onpack_qr + WHERE LOWER(TRIM(template_type)) = :type + AND TRIM(code) <> '' + """.trimIndent() + } else { + """ + SELECT DISTINCT UPPER(TRIM(code)) AS code + FROM onpack_qr + WHERE COALESCE(NULLIF(TRIM(template_type), ''), 'bmp') = :type + AND TRIM(code) <> '' + """.trimIndent() + } + jdbcDao.queryForList(sql, mapOf("type" to templateType)) + .mapNotNull { it["code"]?.toString()?.trim()?.uppercase()?.takeIf { code -> code.isNotEmpty() } } + .toSet() + } catch (e: DataAccessException) { + logger.warn("onpack_qr lookup failed for machine={}", machine, e) + emptySet() + } + } + + private fun builtinImageCodes(machine: String): Set { + return try { + scanBuiltinImageCodes(machine, resourceResolver) + } catch (e: Exception) { + logger.warn("Failed to scan classpath OnPack templates for machine={}", machine, e) + emptySet() + } + } + + private fun templateTableOr(fallback: T, block: () -> T): T { + return try { + block() + } catch (e: DataAccessException) { + logger.warn( + "onpack_template_file is unavailable; using classpath templates. Restart the backend so Liquibase can create the table.", + e, + ) + fallback + } + } + + private fun formatModified(value: Any?): String? = when (value) { + null -> null + is LocalDateTime -> value.format(ISO_TS) + is Timestamp -> value.toLocalDateTime().format(ISO_TS) + else -> value.toString().takeIf { it.isNotBlank() } + } + + private fun ensureOnPackQrRow(itemCode: String, machine: String, savedNames: List) { + val templateType = if (machine == MACHINE_LEMON) "text" else "bmp" + val filename = savedNames.firstOrNull { it.endsWith(".image", ignoreCase = true) } + ?: savedNames.first() + val existing = jdbcDao.queryForList( + """ + SELECT code FROM onpack_qr + WHERE UPPER(TRIM(code)) = :code + AND LOWER(TRIM(COALESCE(NULLIF(TRIM(template_type), ''), 'bmp'))) = :type + LIMIT 1 + """.trimIndent(), + mapOf("code" to itemCode, "type" to templateType), + ) + if (existing.isNotEmpty()) return + jdbcDao.executeUpdate( + """ + INSERT INTO onpack_qr (code, filename, template_type) + VALUES (:code, :filename, :type) + """.trimIndent(), + mapOf( + "code" to itemCode, + "filename" to filename, + "type" to templateType, + ), + ) + } + + companion object { + const val MACHINE_JUICE = "juice" + const val MACHINE_LEMON = "lemon" + private const val MAX_BYTES = 8 * 1024 * 1024 + private val ISO_TS: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + + fun scanBuiltinImageCodes( + machine: String, + resolver: PathMatchingResourcePatternResolver = PathMatchingResourcePatternResolver(), + ): Set { + val pattern = if (machine == MACHINE_LEMON) { + "classpath*:onpack2030_2/*.image" + } else { + "classpath*:onpack2030/*.image" + } + return resolver.getResources(pattern).mapNotNull { resource -> + itemCodeFromImageFileName(resource.filename ?: return@mapNotNull null) + }.filter { isListedBuiltinCode(it) }.toSet() + } + private val ITEM_CODE = Regex("""^[A-Za-z0-9][A-Za-z0-9._-]{0,48}$""") + private val LISTED_BUILTIN_CODE = Regex("""^PP\d+$""") + private val ALLOWED_EXT = setOf("image", "bmp", "job") + + fun normalizeMachine(raw: String): String { + val v = raw.trim().lowercase() + return when (v) { + MACHINE_JUICE, "bmp", "onpack2030", "汁水機" -> MACHINE_JUICE + MACHINE_LEMON, "text", "onpack2030_2", "檸檬機" -> MACHINE_LEMON + else -> throw IllegalArgumentException("machine 必須是 juice(汁水機)或 lemon(檸檬機)") + } + } + + fun normalizeItemCode(raw: String): String { + val code = raw.trim().uppercase() + require(ITEM_CODE.matches(code)) { "品號格式不正確:$raw" } + return code + } + + fun sanitizeFileName(raw: String): String? { + val base = raw.replace('\\', '/').substringAfterLast('/').trim() + if (base.isEmpty() || base == "." || base == "..") return null + if (base.any { it.code < 32 || it == ':' }) return null + val ext = base.substringAfterLast('.', "").lowercase() + if (ext !in ALLOWED_EXT) return null + return base + } + + fun storedFileName(machine: String, itemCode: String, originalName: String): String { + val safe = sanitizeFileName(originalName) + ?: throw IllegalArgumentException("不支援的檔名(只接受 .image / .bmp / .job):$originalName") + return if (safe.endsWith(".image", ignoreCase = true)) { + if (machine == MACHINE_LEMON) "${itemCode.uppercase()}.image" + else "${itemCode.lowercase()}.image" + } else { + safe + } + } + + fun imageFileNameCandidates(machine: String, itemCode: String): List { + val lower = itemCode.trim().lowercase() + val upper = itemCode.trim().uppercase() + return if (machine == MACHINE_LEMON) { + listOf("$upper.image", "$lower.image") + } else { + listOf("$lower.image", "$upper.image") + } + } + + fun itemCodeFromImageFileName(fileName: String): String? { + val base = fileName.replace('\\', '/').substringAfterLast('/').substringBeforeLast('.') + val code = base.trim().uppercase() + if (code.isEmpty() || code == "DEFAULT") return null + if (!ITEM_CODE.matches(code)) return null + return code + } + + /** Classpath test/dev templates (TEST*, TT_*, LO*, LPP*) stay loadable for ZIP but are not listed as supported items. */ + fun isListedBuiltinCode(code: String): Boolean { + return LISTED_BUILTIN_CODE.matches(code.trim().uppercase()) + } + + fun mergeSupported( + registered: Set, + inDatabase: Set, + builtin: Set, + ): List { + return (registered + inDatabase + builtin) + .map { it.trim().uppercase() } + .filter { it.isNotEmpty() } + .toSortedSet() + .map { code -> + val db = code in inDatabase + val built = code in builtin + OnPackSupportedItemDto( + itemCode = code, + printable = db || built, + inDatabase = db, + builtin = built, + registered = code in registered, + ) + } + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackXml.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackXml.kt new file mode 100644 index 0000000..81d25f7 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackXml.kt @@ -0,0 +1,13 @@ +package com.ffii.fpsms.modules.jobOrder.service + +internal object OnPackXml { + fun escapeText(value: String): String = + value.replace("&", "&").replace("<", "<").replace(">", ">") + + fun nextId(xml: String): Int { + val ids = Regex("""\s*(\d+)\s*""") + .findAll(xml) + .mapNotNull { it.groupValues[1].toIntOrNull() } + return (ids.maxOrNull() ?: 7) + 1 + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt index 3299060..0aa946b 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt @@ -13,6 +13,7 @@ import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest +import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.settings.service.SettingsService import com.ffii.fpsms.modules.stock.entity.StockInLineRepository @@ -56,10 +57,20 @@ import java.time.Duration import java.time.Instant import java.time.LocalDate import java.time.ZoneId +import java.time.format.DateTimeFormatter // Data class to store bitmap bytes + width (for XML) data class BitmapResult(val bytes: ByteArray, val width: Int) +private data class OnPackBmpExportItem( + val codeLower: String, + val itemId: Long, + val stockInLineId: Long, + val itemCode: String, + val productName: String, + val planDate: LocalDate?, +) + /** One Bag2-style laser TCP attempt (internal to [PlasticBagPrinterService]). */ private data class LaserBag2TcpResult( val success: Boolean, @@ -75,10 +86,12 @@ class PlasticBagPrinterService( private val jdbcDao: JdbcDao, private val stockInLineRepository: StockInLineRepository, private val itemUomService: ItemUomService, + private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, private val settingsService: SettingsService, private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, private val environment: Environment, private val objectMapper: ObjectMapper, + private val onPackTemplateFileService: OnPackTemplateFileService, ) { private val logger = LoggerFactory.getLogger(javaClass) private val hongKongZoneId = ZoneId.of("Asia/Hong_Kong") @@ -88,6 +101,67 @@ class PlasticBagPrinterService( private const val DEFAULT_LASER_BAG2_PORT = 45678 private const val DEFAULT_LASER_ITEM_CODES = "PP1175" private const val PACKAGING_PROCESS_NAME = "包裝" + /** Designer product BMP canvas (LOGO), 1-bit. */ + private const val ONPACK_PRODUCT_BMP_WIDTH = 593 + private const val ONPACK_PRODUCT_BMP_HEIGHT = 90 + /** Designer item-code BMP canvas (LOGO_2), 1-bit. */ + private const val ONPACK_CODE_BMP_WIDTH = 385 + private const val ONPACK_CODE_BMP_HEIGHT = 141 + /** Designer production-date BMP canvas (LOGO_3), 1-bit. */ + private const val ONPACK_DATE_BMP_WIDTH = 505 + private const val ONPACK_DATE_BMP_HEIGHT = 141 + /** Designer expiry BMP canvas (LOGO_5), 1-bit. */ + private const val ONPACK_EXPIRY_BMP_WIDTH = 623 + private const val ONPACK_EXPIRY_BMP_HEIGHT = 79 + + /** + * Bag2/Bag4 laser TCP payload. When [expiryDate] is present: + * `json;itemCode;itemName;Expiry Date yyyyMMdd;;` + * otherwise the original 3-field form `json;itemCode;itemName;;`. + */ + fun buildLaserBag2Payload( + itemId: Long?, + stockInLineId: Long?, + itemCode: String?, + itemName: String?, + expiryDate: String? = null, + ): String { + val codeStr = (itemCode ?: "").trim().replace(";", ",") + val nameStr = (itemName ?: "").trim().replace(";", ",") + val expStr = formatLaserExpiryParam(expiryDate) + val head = if (itemId != null && stockInLineId != null) { + "{\"itemId\":$itemId,\"stockInLineId\":$stockInLineId}" + } else { + "0" + } + return if (expStr.isNotEmpty()) { + "$head;$codeStr;$nameStr;$expStr;;" + } else { + "$head;$codeStr;$nameStr;;" + } + } + + /** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */ + fun formatLaserExpiryParam(expiryDate: String?): String { + val raw = expiryDate?.trim().orEmpty() + if (raw.isEmpty()) return "" + if (raw.startsWith("Expiry Date ", ignoreCase = true)) { + return raw.replace(";", ",") + } + val iso = raw.take(10) + try { + return ItemDefaultShelfLifeService.formatPrintLabel(LocalDate.parse(iso)) + } catch (_: Exception) { + } + if (raw.length == 8 && raw.all { it.isDigit() }) { + try { + val d = LocalDate.parse(raw, DateTimeFormatter.BASIC_ISO_DATE) + return ItemDefaultShelfLifeService.formatPrintLabel(d) + } catch (_: Exception) { + } + } + return raw.replace(";", ",") + } } fun getLaserBag2Settings(): LaserBag2SettingsResponse { @@ -176,8 +250,22 @@ class PlasticBagPrinterService( } val ids = filtered.mapNotNull { it.id } val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) + val printDate = ItemDefaultShelfLifeService.today() + val shelfLifeByCode = itemDefaultShelfLifeService.printShelfLifeByItemCodes( + filtered.map { it.bom?.item?.code ?: it.bom?.code }, + ) return filtered.map { jo -> - PyJobOrderListMapper.toLaserListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService) + val itemCode = jo.bom?.item?.code ?: jo.bom?.code + val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem(itemCode, shelfLifeByCode, printDate) + PyJobOrderListMapper.toLaserListItem( + jo, + printed[jo.id!!], + stockInLineRepository, + itemUomService, + defaultShelfLifeDays = days, + useMinus18 = useMinus18, + expiryDate = expiry, + ) } } @@ -203,6 +291,7 @@ class PlasticBagPrinterService( stockInLineId = request.stockInLineId, itemCode = request.itemCode, itemName = request.itemName, + expiryDate = request.expiryDate, ) val response = if (first.success) { LaserBag2SendResponse( @@ -220,6 +309,7 @@ class PlasticBagPrinterService( stockInLineId = request.stockInLineId, itemCode = request.itemCode, itemName = request.itemName, + expiryDate = request.expiryDate, ) LaserBag2SendResponse( success = second.success, @@ -292,14 +382,9 @@ class PlasticBagPrinterService( stockInLineId: Long?, itemCode: String?, itemName: String?, + expiryDate: String? = null, ): LaserBag2TcpResult { - val codeStr = (itemCode ?: "").trim().replace(";", ",") - val nameStr = (itemName ?: "").trim().replace(";", ",") - val payload = if (itemId != null && stockInLineId != null) { - "{\"itemId\":$itemId,\"stockInLineId\":$stockInLineId};$codeStr;$nameStr;;" - } else { - "0;$codeStr;$nameStr;;" - } + val payload = buildLaserBag2Payload(itemId, stockInLineId, itemCode, itemName, expiryDate) val bytes = payload.toByteArray(StandardCharsets.UTF_8) var socket: Socket? = null try { @@ -564,7 +649,21 @@ class PlasticBagPrinterService( return baos.toByteArray() } - fun generateOnPackQrZip(jobOrders: List): ByteArray { + /** + * 汁水機 OnPack: templates under classpath `onpack2030/{code}.image`. + * Always swaps LOGO_4 to the generated QR BMP. + * When [includeExpiry] is true, also generates product/code/date/expiry BMPs (`{code}Product.bmp`, + * `{code}Code.bmp`, `{code}Date.bmp` from [printDate] / bagPrint filter, `{code}exp.bmp`). + * When [includeExpiry] is true, writes `{code}exp.bmp` into the template expiry slot + * (`LOGO_5` / `LOGO_EXP` if present, otherwise injects `LOGO_EXP`). Prefers + * `onpack2030_exp/{code}.image` over the main `onpack2030` template. + * Old ZIP callers must pass false; they keep `onpack2030` unchanged. + */ + fun generateOnPackQrZip( + jobOrders: List, + includeExpiry: Boolean = false, + printDate: LocalDate? = null, + ): ByteArray { val normalizedJobOrders = jobOrders .map { OnPackQrJobOrderRequest( @@ -608,31 +707,98 @@ class PlasticBagPrinterService( ?: return@mapNotNull null val itemId = stockInLine.item?.id ?: return@mapNotNull null val stockInLineId = stockInLine.id ?: return@mapNotNull null - Triple(codeLower, itemId, stockInLineId) + val jo = jobOrderRepository.findById(order.jobOrderId).orElse(null) + val baseName = jo?.bom?.name ?: stockInLine.item?.name + val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc + val productName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc) + ?: codeLower.uppercase() + val itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase() + OnPackBmpExportItem( + codeLower, + itemId, + stockInLineId, + itemCode, + productName, + jo?.planStart?.toLocalDate(), + ) } require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } - val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet() + val codesUpper = exportItemsRaw.map { it.itemCode }.toSet() val allowedBmpCodes = codesOnPackMatchingTemplateType(codesUpper, "bmp") - val exportItems = exportItemsRaw.filter { allowedBmpCodes.contains(it.first.uppercase()) } + val exportItems = exportItemsRaw.filter { allowedBmpCodes.contains(it.itemCode) } require(exportItems.isNotEmpty()) { "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" } + val effectivePrintDate = printDate + ?: exportItems.firstNotNullOfOrNull { it.planDate } + ?: ItemDefaultShelfLifeService.today() + val baos = ByteArrayOutputStream() ZipOutputStream(baos).use { zos -> val addedEntries = linkedSetOf() - exportItems.forEach { (codeLower, itemId, stockInLineId) -> - val imageTemplate = loadOnPackImageTemplateOrNull(codeLower) ?: return@forEach + exportItems.forEach { item -> + val codeLower = item.codeLower + val imageTemplate = loadOnPackImageTemplateOrNull(codeLower, includeExpiry) ?: return@forEach - val qrContent = """{"itemId": $itemId, "stockInLineId": $stockInLineId}""" + val qrContent = """{"itemId": ${item.itemId}, "stockInLineId": ${item.stockInLineId}}""" // Target approximately 470x389 BMP, but with larger visible QR and very little vertical whitespace. // Width = 386 + (42 * 2) = 470 // Height = 386 + (1 * 2) = 388 (~389) val bmp = createQrCodeBitmap(qrContent, contentSize = 386, horizontalPadding = 42, verticalPadding = 1) val qrBmpFileName = "${codeLower}qr.bmp" val imageFileName = "$codeLower.image" - val imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName) + var imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName) + if (includeExpiry) { + val productFile = "${codeLower}Product.bmp" + val codeFile = "${codeLower}Code.bmp" + val dateFile = "${codeLower}Date.bmp" + val productBmp = createMonochromeBitmapFixed( + item.productName, + ONPACK_PRODUCT_BMP_WIDTH, + ONPACK_PRODUCT_BMP_HEIGHT, + ) + val codeBmp = createMonochromeBitmapFixed( + item.itemCode, + ONPACK_CODE_BMP_WIDTH, + ONPACK_CODE_BMP_HEIGHT, + ) + val dateBmp = createMonochromeBitmapFixed( + ItemDefaultShelfLifeService.formatProductionDatePrintLabel(effectivePrintDate), + ONPACK_DATE_BMP_WIDTH, + ONPACK_DATE_BMP_HEIGHT, + ) + imageContent = withOnPackLogoFile(imageContent, "LOGO", productFile) + imageContent = withOnPackLogoFile(imageContent, "LOGO_2", codeFile) + imageContent = withOnPackLogoFile(imageContent, "LOGO_3", dateFile) + if (addedEntries.add(productFile)) { + addToZip(zos, productFile, productBmp.bytes) + } + if (addedEntries.add(codeFile)) { + addToZip(zos, codeFile, codeBmp.bytes) + } + if (addedEntries.add(dateFile)) { + addToZip(zos, dateFile, dateBmp.bytes) + } + } + val expiryLabel = if (includeExpiry) { + itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate) + } else { + null + } + if (!expiryLabel.isNullOrBlank()) { + val expBmp = createMonochromeBitmapFixed( + expiryLabel, + ONPACK_EXPIRY_BMP_WIDTH, + ONPACK_EXPIRY_BMP_HEIGHT, + ) + val expBmpFileName = "${codeLower}exp.bmp" + imageContent = withOnPackExpiryLogo(imageContent, expBmpFileName, expBmp.width) + if (addedEntries.add(expBmpFileName)) { + addToZip(zos, expBmpFileName, expBmp.bytes) + } + } if (addedEntries.add(qrBmpFileName)) { addToZip(zos, qrBmpFileName, bmp.bytes) @@ -640,6 +806,23 @@ class PlasticBagPrinterService( if (addedEntries.add(imageFileName)) { addToZip(zos, imageFileName, imageContent) } + val decodedXmlForAssets = decodeOnPackImageTemplateForTextEdit(imageContent).first + extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName -> + if (bmpName.equals(qrBmpFileName, ignoreCase = true)) return@forEach + if (bmpName.endsWith("exp.bmp", ignoreCase = true)) return@forEach + if (bmpName.endsWith("Product.bmp", ignoreCase = true)) return@forEach + if (bmpName.endsWith("Code.bmp", ignoreCase = true)) return@forEach + if (bmpName.endsWith("Date.bmp", ignoreCase = true)) return@forEach + if (!addedEntries.add(bmpName)) return@forEach + val bmpBytes = loadJuiceAssetOrNull(bmpName) ?: return@forEach + addToZip(zos, bmpName, bmpBytes) + } + val jobFileName = "${codeLower}.job" + loadJuiceAssetOrNull(jobFileName)?.let { jobBytes -> + if (addedEntries.add(jobFileName)) { + addToZip(zos, jobFileName, jobBytes) + } + } } require(addedEntries.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } @@ -651,8 +834,13 @@ class PlasticBagPrinterService( /** * OnPack2023 檸檬機: templates under classpath `onpack2030_2/{code}.image` with embedded QR (Static text). * Only replaces `...` under `` with JSON payload. No separate .bmp in zip. + * When [includeExpiry] is true, also injects TEXT_EXP (does not change TEXT_3 production date). Old ZIP callers must pass false. */ - fun generateOnPackQrTextZip(jobOrders: List): ByteArray { + fun generateOnPackQrTextZip( + jobOrders: List, + includeExpiry: Boolean = false, + printDate: LocalDate? = null, + ): ByteArray { val normalizedJobOrders = jobOrders .map { OnPackQrJobOrderRequest( @@ -711,11 +899,25 @@ class PlasticBagPrinterService( val addedEntries = linkedSetOf() exportItems.forEach { (codeLower, itemId, stockInLineId) -> val imageTemplate = loadOnPack2030_2ImageTemplateOrNull(codeLower) ?: run { - logger.warn("OnPack text ZIP: missing classpath template onpack2030_2/{}.image", codeLower.uppercase()) + logger.warn("OnPack text ZIP: missing template for {}", codeLower.uppercase()) return@forEach } val imageFileName = "${codeLower.uppercase()}.image" - val imageContent = withOnPackStaticQrText(codeLower, imageTemplate, itemId, stockInLineId) + val expiryLabel = if (includeExpiry) { + itemDefaultShelfLifeService.expiryDatePrintLabel( + codeLower, + printDate ?: ItemDefaultShelfLifeService.today(), + ) + } else { + null + } + val imageContent = withOnPackStaticQrText( + codeLower, + imageTemplate, + itemId, + stockInLineId, + expiryCompact = expiryLabel, + ) if (addedEntries.add(imageFileName)) { addToZip(zos, imageFileName, imageContent) } @@ -723,7 +925,7 @@ class PlasticBagPrinterService( extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName -> if (!addedEntries.add(bmpName)) return@forEach val bmpBytes = loadOnPack2030_2AssetOrNull(bmpName) ?: run { - logger.warn("OnPack text ZIP: missing classpath asset onpack2030_2/{}", bmpName) + logger.warn("OnPack text ZIP: missing asset {}", bmpName) return@forEach } addToZip(zos, bmpName, bmpBytes) @@ -816,10 +1018,26 @@ class PlasticBagPrinterService( else -> return emptySet() } val rows = jdbcDao.queryForList(sql, mapOf("codes" to codesUpper.toList())) - return rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet() + val fromTable = rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet() + val machine = if (normalizedType == "text") { + OnPackTemplateFileService.MACHINE_LEMON + } else { + OnPackTemplateFileService.MACHINE_JUICE + } + val fromDbFiles = onPackTemplateFileService.itemCodesWithImage(machine) + .filter { it in codesUpper } + .toSet() + return fromTable + fromDbFiles } - private fun loadOnPackImageTemplateOrNull(codeLower: String): ByteArray? { + private fun loadOnPackImageTemplateOrNull(codeLower: String, forExpiry: Boolean = false): ByteArray? { + onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_JUICE, codeLower)?.let { return it } + if (forExpiry) { + val expiryResource = ClassPathResource("onpack2030_exp/${codeLower}.image") + if (expiryResource.exists()) { + return expiryResource.inputStream.use { it.readBytes() } + } + } val resourcePath = "onpack2030/${codeLower}.image" val resource = ClassPathResource(resourcePath) if (!resource.exists()) return null @@ -828,6 +1046,7 @@ class PlasticBagPrinterService( /** Template files on classpath use uppercase code, e.g. `onpack2030_2/PP1175.image`. */ private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? { + onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_LEMON, codeLower)?.let { return it } val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image" val resource = ClassPathResource(resourcePath) if (!resource.exists()) return null @@ -836,11 +1055,24 @@ class PlasticBagPrinterService( private fun loadOnPack2030_2AssetOrNull(fileName: String): ByteArray? { val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null } + onPackTemplateFileService.loadAsset(OnPackTemplateFileService.MACHINE_LEMON, safe)?.let { return it } val resource = ClassPathResource("onpack2030_2/$safe") if (!resource.exists()) return null return resource.inputStream.use { it.readBytes() } } + private fun loadJuiceAssetOrNull(fileName: String): ByteArray? { + val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null } + onPackTemplateFileService.loadAsset(OnPackTemplateFileService.MACHINE_JUICE, safe)?.let { return it } + listOf("onpack2030_exp/$safe", "onpack2030/$safe").forEach { path -> + val resource = ClassPathResource(path) + if (resource.exists()) { + return resource.inputStream.use { it.readBytes() } + } + } + return null + } + /** Collect `xxx.bmp` inside each `...` block (decoded template XML). */ private fun extractLogoBmpFileNamesFromOnPackImageXml(xml: String): Set { val out = linkedSetOf() @@ -853,14 +1085,17 @@ class PlasticBagPrinterService( } private fun withOnPackLogo4Bmp(imageBytes: ByteArray, qrBmpFileName: String): ByteArray { - // Use ISO-8859-1 one-byte mapping so all original bytes are preserved, - // while replacing only ASCII XML fragment for LOGO_4 filename. - val oneByteText = String(imageBytes, StandardCharsets.ISO_8859_1) - val replaced = oneByteText.replace( - Regex("""(\s*LOGO_4\s*[\s\S]*?)([^<]+)()"""), - "$1$qrBmpFileName$3", - ) - return replaced.toByteArray(StandardCharsets.ISO_8859_1) + return withOnPackLogoFile(imageBytes, "LOGO_4", qrBmpFileName) + } + + private fun withOnPackLogoFile(imageBytes: ByteArray, fieldName: String, bmpFileName: String): ByteArray { + val (xml, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) + return encodeBack(OnPackJuiceExpiryXml.rewriteLogoFileName(xml, fieldName, bmpFileName)) + } + + private fun withOnPackExpiryLogo(imageBytes: ByteArray, bmpFileName: String, bmpWidth: Int): ByteArray { + val (xml, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) + return encodeBack(OnPackJuiceExpiryXml.applyExpiry(xml, bmpFileName, bmpWidth)) } /** @@ -868,39 +1103,16 @@ class PlasticBagPrinterService( * substring/regex matching (`hasNameQr=false` while XML is valid). */ private fun decodeOnPackImageTemplateForTextEdit(bytes: ByteArray): Pair ByteArray> { - val bomUtf16Le = byteArrayOf(0xFF.toByte(), 0xFE.toByte()) - val bomUtf16Be = byteArrayOf(0xFE.toByte(), 0xFF.toByte()) - val bomUtf8 = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) - - when { - bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] -> { - val body = bytes.copyOfRange(2, bytes.size) - val text = String(body, StandardCharsets.UTF_16LE) - return text to { s -> bomUtf16Le + s.toByteArray(StandardCharsets.UTF_16LE) } - } - bytes.size >= 2 && bytes[0] == bomUtf16Be[0] && bytes[1] == bomUtf16Be[1] -> { - val body = bytes.copyOfRange(2, bytes.size) - val text = String(body, StandardCharsets.UTF_16BE) - return text to { s -> bomUtf16Be + s.toByteArray(StandardCharsets.UTF_16BE) } - } - bytes.size >= 3 && bytes[0] == bomUtf8[0] && bytes[1] == bomUtf8[1] && bytes[2] == bomUtf8[2] -> { - val body = bytes.copyOfRange(3, bytes.size) - val text = String(body, StandardCharsets.UTF_8) - return text to { s -> bomUtf8 + s.toByteArray(StandardCharsets.UTF_8) } - } - // UTF-16 LE without BOM: "<" == 0x3C 0x00 - bytes.size >= 2 && bytes[0] == 0x3C.toByte() && bytes[1] == 0x00.toByte() -> { - val text = String(bytes, StandardCharsets.UTF_16LE) - return text to { s -> s.toByteArray(StandardCharsets.UTF_16LE) } - } - else -> { - val utf8 = String(bytes, StandardCharsets.UTF_8) - return utf8 to { s -> s.toByteArray(StandardCharsets.UTF_8) } - } - } + return OnPackImageTemplateCodec.decode(bytes) } - private fun withOnPackStaticQrText(forCode: String, imageBytes: ByteArray, itemId: Long, stockInLineId: Long): ByteArray { + private fun withOnPackStaticQrText( + forCode: String, + imageBytes: ByteArray, + itemId: Long, + stockInLineId: Long, + expiryCompact: String? = null, + ): ByteArray { val payload = """{"itemId": $itemId, "stockInLineId": $stockInLineId}""" val (oneByteText, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes) // Must NOT use Static[^>]*StaticSrc — [^>]* already eats type='StaticSrc.V1', so StaticSrc can never match. @@ -934,7 +1146,12 @@ class PlasticBagPrinterService( ) } val withQrCellWidth = replaceQrV1CellWidth67To50(replaced) - return encodeBack(withQrCellWidth) + val withExpiry = if (expiryCompact.isNullOrBlank()) { + withQrCellWidth + } else { + OnPackLemonExpiryXml.applyExpiry(withQrCellWidth, expiryCompact) + } + return encodeBack(withExpiry) } /** First `67` inside `` → 50 (export tuning). */ @@ -952,6 +1169,33 @@ class PlasticBagPrinterService( return if (collapsed.length <= maxLen) collapsed else collapsed.take(maxLen) + "…" } + /** 1-bit BMP at an exact canvas (no crop). Used for OnPack expiry so it matches the designer slot. */ + private fun createMonochromeBitmapFixed(text: String, width: Int, height: Int): BitmapResult { + val img = BufferedImage(width, height, BufferedImage.TYPE_BYTE_BINARY) + img.createGraphics().apply { + color = Color.WHITE + fillRect(0, 0, width, height) + color = Color.BLACK + setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF) + var fontSize = (height * 0.92).toInt().coerceAtLeast(1) + var font = Font("SimSun", Font.BOLD, fontSize) + var metrics = getFontMetrics(font) + while (fontSize > 8 && metrics.stringWidth(text) > width) { + fontSize-- + font = Font("SimSun", Font.BOLD, fontSize) + metrics = getFontMetrics(font) + } + this.font = font + val x = 0 + val y = metrics.ascent + ((height - metrics.height) / 2).coerceAtLeast(0) + drawString(text, x, y) + dispose() + } + val baos = ByteArrayOutputStream() + ImageIO.write(img, "bmp", baos) + return BitmapResult(baos.toByteArray(), img.width) + } + private fun createMonochromeBitmap(text: String, targetHeight: Int): BitmapResult { // Step 1: Measure text width with temporary image val tempImg = BufferedImage(1, 1, BufferedImage.TYPE_BYTE_BINARY) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt new file mode 100644 index 0000000..c33197e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt @@ -0,0 +1,55 @@ +package com.ffii.fpsms.modules.jobOrder.web + +import com.ffii.fpsms.modules.jobOrder.service.OnPackTemplateFileService +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedCatalogDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateFileDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateUploadResponse +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/plastic/onpack-templates") +class OnPackTemplateController( + private val onPackTemplateFileService: OnPackTemplateFileService, +) { + @GetMapping + fun list( + @RequestParam(required = false) machine: String?, + ): List { + return onPackTemplateFileService.list(machine) + } + + @GetMapping("/supported") + fun supported(): OnPackSupportedCatalogDto { + return onPackTemplateFileService.supportedCatalog() + } + + @PostMapping + fun upload( + @RequestParam machine: String, + @RequestParam itemCode: String, + @RequestParam("files") files: List?, + ): ResponseEntity { + return ResponseEntity.ok(onPackTemplateFileService.upload(machine, itemCode, files ?: emptyList())) + } + + @DeleteMapping("/{id}") + fun delete(@PathVariable id: Long): ResponseEntity { + onPackTemplateFileService.softDelete(id) + return ResponseEntity.noContent().build() + } + + @ExceptionHandler(IllegalArgumentException::class) + fun badRequest(e: IllegalArgumentException): ResponseEntity> { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("message" to (e.message ?: "Invalid request"))) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt index f752803..fe880c8 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt @@ -60,7 +60,8 @@ class PlasticBagPrinterController( } /** - * Bag2.py laser TCP protocol: `{"itemId":n,"stockInLineId":m};code;name;;` or `0;code;name;;` + * Bag2/Bag4 laser TCP protocol: `{"itemId":n,"stockInLineId":m};code;name;;` or + * `{"itemId":n,"stockInLineId":m};code;name;Expiry Date yyyyMMdd;;` when expiryDate is set. */ @PostMapping("/print-laser-bag2") fun printLaserBag2(@RequestBody request: LaserBag2SendRequest): ResponseEntity { @@ -148,6 +149,51 @@ class PlasticBagPrinterController( } } + /** + * Same 汁水機 ZIP as [downloadOnPackQr], plus expiry BMP from item_default_shelf_life. + * Uses designer `.image` layout (`LOGO_5` / `LOGO_EXP`) when present; otherwise injects LOGO_EXP. + * Old [downloadOnPackQr] is unchanged. + */ + @PostMapping("/download-onpack-qr-with-expiry") + fun downloadOnPackQrWithExpiry( + @RequestBody request: OnPackQrDownloadRequest, + response: HttpServletResponse, + ) { + try { + val zipBytes = plasticBagPrinterService.generateOnPackQrZip( + request.jobOrders, + includeExpiry = true, + printDate = request.planDate, + ) + response.contentType = "application/zip" + response.setHeader( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"onpack_qr_exp.zip\"" + ) + response.setContentLength(zipBytes.size) + response.outputStream.write(zipBytes) + response.outputStream.flush() + } catch (e: IllegalArgumentException) { + response.status = HttpServletResponse.SC_BAD_REQUEST + response.contentType = "text/plain;charset=UTF-8" + response.writer.write(e.message ?: "Invalid request") + response.writer.flush() + } catch (e: Exception) { + logger.error("POST /plastic/download-onpack-qr-with-expiry failed", e) + try { + if (!response.isCommitted) { + response.reset() + } + } catch (_: Exception) { + /* ignore */ + } + response.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR + response.contentType = "text/plain;charset=UTF-8" + response.writer.write(e.message ?: "Download failed") + response.writer.flush() + } + } + /** OnPack2023 檸檬機: `onpack2030_2` templates with embedded QR (Static text only; no separate .bmp). */ @PostMapping("/download-onpack-qr-text") fun downloadOnPackQrText( @@ -194,6 +240,50 @@ class PlasticBagPrinterController( return ResponseEntity.ok(plasticBagPrinterService.pushOnPackQrTextZipToNgpcl(request.jobOrders)) } + /** + * Same lemon OnPack ZIP as [downloadOnPackQrText], plus TEXT_EXP from item_default_shelf_life. + * Does not replace TEXT_3 (production / print date). Old [downloadOnPackQrText] is unchanged. + */ + @PostMapping("/download-onpack-qr-text-with-expiry") + fun downloadOnPackQrTextWithExpiry( + @RequestBody request: OnPackQrDownloadRequest, + response: HttpServletResponse, + ) { + try { + val zipBytes = plasticBagPrinterService.generateOnPackQrTextZip( + request.jobOrders, + includeExpiry = true, + printDate = request.planDate, + ) + response.contentType = "application/zip" + response.setHeader( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"onpack2023_lemon_qr_exp.zip\"" + ) + response.setContentLength(zipBytes.size) + response.outputStream.write(zipBytes) + response.outputStream.flush() + } catch (e: IllegalArgumentException) { + response.status = HttpServletResponse.SC_BAD_REQUEST + response.contentType = "text/plain;charset=UTF-8" + response.writer.write(e.message ?: "Invalid request") + response.writer.flush() + } catch (e: Exception) { + logger.error("POST /plastic/download-onpack-qr-text-with-expiry failed", e) + try { + if (!response.isCommitted) { + response.reset() + } + } catch (_: Exception) { + /* ignore */ + } + response.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR + response.contentType = "text/plain;charset=UTF-8" + response.writer.write(e.message ?: "Download failed") + response.writer.flush() + } + } + /** * Test API to generate and download the printer job files as a ZIP. * ONPACK2030 diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt index 5c0c81c..370b957 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt @@ -1,7 +1,8 @@ package com.ffii.fpsms.modules.jobOrder.web.model /** - * Body for Bag2.py-style laser TCP send: `json;itemCode;itemName;;` (UTF-8). + * Body for Bag2/Bag4-style laser TCP send: `json;itemCode;itemName;;` or + * `json;itemCode;itemName;Expiry Date yyyyMMdd;;` when [expiryDate] is set (UTF-8). * Optional [printerIp] / [printerPort] override system settings [LASER_PRINT.host] / [LASER_PRINT.port]. * * Optional job metadata is used to persist [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS] @@ -17,6 +18,11 @@ data class LaserBag2SendRequest( val jobOrderId: Long? = null, val jobOrderNo: String? = null, val lotNo: String? = null, + /** + * Print-time expiry from the job-order list (`yyyy-MM-dd`, compact `yyyyMMdd`, + * or already `Expiry Date yyyyMMdd`). Sent as the 4th TCP field. + */ + val expiryDate: String? = null, /** AUTO (auto-send) or MANUAL (/laserPrint); optional. */ val source: String? = null, ) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt new file mode 100644 index 0000000..9bbeef9 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt @@ -0,0 +1,29 @@ +package com.ffii.fpsms.modules.jobOrder.web.model + +data class OnPackTemplateFileDto( + val id: Long, + val machine: String, + val itemCode: String, + val fileName: String, + val byteSize: Int, + val modified: String?, +) + +data class OnPackTemplateUploadResponse( + val machine: String, + val itemCode: String, + val saved: List, +) + +data class OnPackSupportedItemDto( + val itemCode: String, + val printable: Boolean, + val inDatabase: Boolean, + val builtin: Boolean, + val registered: Boolean, +) + +data class OnPackSupportedCatalogDto( + val juice: List, + val lemon: List, +) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt index eae5fd2..e41ad71 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt @@ -52,6 +52,8 @@ data class PrinterStatusResponse( data class OnPackQrDownloadRequest( val jobOrders: List, + /** /bagPrint filter date (job plan date). Used by expiry ZIP for LOGO_3 production date. */ + val planDate: java.time.LocalDate? = null, ) data class OnPackQrJobOrderRequest( diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLife.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLife.kt new file mode 100644 index 0000000..f4e4d50 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLife.kt @@ -0,0 +1,43 @@ +package com.ffii.fpsms.modules.master.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +/** + * Shelf life by item code for bag / OnPack expiry print. + * [useMinus18]: 0 = print uses [defaultDays] (chilled); 1 = print uses [minus18Days]. + */ +@Entity +@Table(name = "item_default_shelf_life") +open class ItemDefaultShelfLife : BaseEntity() { + + @NotNull + @Size(max = 50) + @Column(name = "itemCode", length = 50, nullable = false, unique = true) + open var itemCode: String? = null + + @Column(name = "defaultDays") + open var defaultDays: Int? = null + + @Column(name = "minus18Days") + open var minus18Days: Int? = null + + @NotNull + @Column(name = "useMinus18", nullable = false) + open var useMinus18: Boolean? = false + + @Column(name = "openedDays") + open var openedDays: Int? = null + + @Size(max = 20) + @Column(name = "storageC", length = 20) + open var storageC: String? = null + + @Size(max = 255) + @Column(name = "remarks", length = 255) + open var remarks: String? = null +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt new file mode 100644 index 0000000..5093a11 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt @@ -0,0 +1,12 @@ +package com.ffii.fpsms.modules.master.entity + +import com.ffii.core.support.AbstractRepository +import org.springframework.stereotype.Repository + +@Repository +interface ItemDefaultShelfLifeRepository : AbstractRepository { + + fun findByDeletedFalseAndItemCodeIgnoreCase(itemCode: String): ItemDefaultShelfLife? + + fun findByDeletedFalseAndItemCodeIn(itemCodes: Collection): List +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt new file mode 100644 index 0000000..fc0b183 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt @@ -0,0 +1,83 @@ +package com.ffii.fpsms.modules.master.service + +import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLife +import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLifeRepository +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +data class ItemPrintShelfLife( + val effectiveDays: Int, + val useMinus18: Boolean, +) + +/** + * Lookup shelf life days and compute print-date expiry. + * Uses [ItemDefaultShelfLife.useMinus18] to pick chilled [defaultDays] vs [minus18Days]. + */ +@Service +class ItemDefaultShelfLifeService( + private val repository: ItemDefaultShelfLifeRepository, +) { + fun printShelfLifeByItemCodes(codes: Collection): Map { + val normalized = codes.mapNotNull { it?.trim()?.uppercase()?.takeIf { code -> code.isNotEmpty() } } + .distinct() + if (normalized.isEmpty()) return emptyMap() + return repository.findByDeletedFalseAndItemCodeIn(normalized) + .mapNotNull { row -> + val code = row.itemCode?.trim()?.uppercase().orEmpty() + val days = effectiveDays(row) ?: return@mapNotNull null + if (code.isEmpty()) null else code to ItemPrintShelfLife(days, row.useMinus18 == true) + } + .toMap() + } + + fun defaultDaysByItemCodes(codes: Collection): Map = + printShelfLifeByItemCodes(codes).mapValues { it.value.effectiveDays } + + fun defaultDays(itemCode: String?): Int? { + val code = itemCode?.trim()?.uppercase().orEmpty() + if (code.isEmpty()) return null + val row = repository.findByDeletedFalseAndItemCodeIgnoreCase(code) ?: return null + return effectiveDays(row) + } + + fun expiryDate(itemCode: String?, printDate: LocalDate = today()): LocalDate? { + val days = defaultDays(itemCode) ?: return null + return expiryOn(printDate, days) + } + + fun expiryDateIso(itemCode: String?, printDate: LocalDate = today()): String? = + expiryDate(itemCode, printDate)?.toString() + + fun expiryDateCompact(itemCode: String?, printDate: LocalDate = today()): String? = + expiryDate(itemCode, printDate)?.format(COMPACT) + + /** Printed bag wording, e.g. `Expiry Date 20260821`. */ + fun expiryDatePrintLabel(itemCode: String?, printDate: LocalDate = today()): String? = + expiryDate(itemCode, printDate)?.let { formatPrintLabel(it) } + + companion object { + val PRINT_ZONE: ZoneId = ZoneId.of("Asia/Hong_Kong") + private val COMPACT: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE + + fun today(): LocalDate = LocalDate.now(PRINT_ZONE) + + fun formatPrintLabel(d: LocalDate): String = "Expiry Date ${d.format(COMPACT)}" + + /** Production / print date on 汁水機 LOGO_3, e.g. `20260821`. */ + fun formatProductionDatePrintLabel(d: LocalDate): String = d.format(COMPACT) + + fun expiryOn(printDate: LocalDate, defaultDays: Int): LocalDate = + printDate.plusDays(defaultDays.toLong()) + + fun effectiveDays(row: ItemDefaultShelfLife): Int? = + effectiveDays(row.defaultDays, row.minus18Days, row.useMinus18 == true) + + fun effectiveDays(defaultDays: Int?, minus18Days: Int?, useMinus18: Boolean): Int? { + val chosen = if (useMinus18) minus18Days else defaultDays + return chosen?.takeIf { it > 0 } + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupport.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupport.kt new file mode 100644 index 0000000..88c93e8 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupport.kt @@ -0,0 +1,33 @@ +package com.ffii.fpsms.modules.master.service + +/** + * Production-safe rules for linking a new M18 product id onto an existing local item + * (same code+type after M18 recoded the product). + * + * Remap must only update [com.ffii.fpsms.modules.master.entity.Items.m18Id] — never a full + * product overwrite or item_uom rebuild (those delete purchase/stock UOMs). + */ +internal object ItemM18IdRemapSupport { + const val LINKED_MESSAGE = "Linked m18Id to existing item with same code" + + /** + * True when saveItem may attach [requestM18Id] to [duplicatedItemId] instead of + * returning "code already existed". + */ + fun canLinkM18IdToDuplicateCode( + requestId: Long?, + requestM18Id: Long?, + duplicatedItemId: Long?, + ownerOfNewM18IdItemId: Long?, + ): Boolean { + if (requestM18Id == null || duplicatedItemId == null) return false + if (requestId != null && requestId != duplicatedItemId) return false + if (ownerOfNewM18IdItemId != null && ownerOfNewM18IdItemId != duplicatedItemId) return false + return true + } + + /** After saveItem: M18 pull missed by m18Id, then linked by code+type only. */ + fun isM18IdLinkOnly(existingByM18IdWasMissing: Boolean, message: String?): Boolean { + return existingByM18IdWasMissing && message == LINKED_MESSAGE + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt index 8e5a578..452bd0a 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt @@ -581,6 +581,10 @@ open class ItemsService( return itemsRepository.findByCodeAndDeletedFalse(code); } + open fun findByCodeAndType(code: String, type: String): Items? { + return itemsRepository.findByCodeAndTypeAndDeletedFalse(code, type) + } + open fun findByM18Id(m18Id: Long): Items? { return itemsRepository.findByM18IdAndDeletedIsFalse(m18Id) } @@ -650,34 +654,67 @@ open class ItemsService( @Transactional open fun saveItem(request: NewItemRequest): MessageResponse { val duplicatedItem = itemsRepository.findByCodeAndTypeAndDeletedFalse(request.code, request.type) + val ownerOfNewM18Id = request.m18Id?.let { findByM18Id(it) } if (duplicatedItem != null && duplicatedItem.id != request.id) { - if (request.m18Id != null && request.id == null && duplicatedItem.m18Id == null) { - duplicatedItem.m18Id = request.m18Id - duplicatedItem.m18LastModifyDate = request.m18LastModifyDate - val linked = itemsRepository.saveAndFlush(duplicatedItem) + val canLink = ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( + requestId = request.id, + requestM18Id = request.m18Id, + duplicatedItemId = duplicatedItem.id, + ownerOfNewM18IdItemId = ownerOfNewM18Id?.id, + ) + if (!canLink) { return MessageResponse( - id = linked.id, - code = linked.code, - name = linked.name, - type = linked.type.toString(), - message = "Linked m18Id to existing item with same code", - errorPosition = null, + id = request.id ?: duplicatedItem.id, + code = request.code, + name = request.name, + type = request.type.toString(), + message = "The item code has already existed", + errorPosition = "code" ) } + // M18 recode: attach new proId only. Do not overwrite name/QC/UOM via this path. + if (duplicatedItem.m18Id != request.m18Id) { + logger.warn( + "Remapping item m18Id: localId=${duplicatedItem.id} code=${duplicatedItem.code} oldM18Id=${duplicatedItem.m18Id} newM18Id=${request.m18Id}" + ) + } + duplicatedItem.m18Id = request.m18Id + duplicatedItem.m18LastModifyDate = request.m18LastModifyDate ?: duplicatedItem.m18LastModifyDate + val linked = itemsRepository.saveAndFlush(duplicatedItem) + return MessageResponse( + id = linked.id, + code = linked.code, + name = linked.name, + type = linked.type.toString(), + message = ItemM18IdRemapSupport.LINKED_MESSAGE, + errorPosition = null, + ) + } + // Prefer local id so an existing item can have its m18Id updated from the UI. + val item = when { + request.id != null && request.id > 0 -> + itemsRepository.findByIdAndDeletedFalse(request.id) ?: Items() + ownerOfNewM18Id != null -> ownerOfNewM18Id + else -> Items() + } + logger.info("item: $item") + if (ownerOfNewM18Id != null && ownerOfNewM18Id.id != item.id) { return MessageResponse( - id = request.id ?: duplicatedItem.id, + id = request.id ?: item.id, code = request.code, name = request.name, type = request.type.toString(), - message = "The item code has already existed", - errorPosition = "code" + message = "M18 ID already used by another item", + errorPosition = "m18Id" ) } - val item = if (request.m18Id != null) findByM18Id(request.m18Id) ?: Items() - else if (request.id != null && request.id > 0) itemsRepository.findByIdAndDeletedFalse(request.id) ?: Items() - else Items() - logger.info("item: $item") - if (item.m18LastModifyDate == request.m18LastModifyDate) { + val m18IdUnchanged = request.m18Id == null || item.m18Id == request.m18Id + // Skip only for unchanged M18 pulls (request carries lastModifyDate). UI saves do not. + if (request.m18LastModifyDate != null && + item.id != null && + item.m18LastModifyDate == request.m18LastModifyDate && + m18IdUnchanged + ) { return MessageResponse( id = item.id, code = item.code, diff --git a/src/main/java/com/ffii/fpsms/py/PyController.kt b/src/main/java/com/ffii/fpsms/py/PyController.kt index 9e63c9a..3f51450 100644 --- a/src/main/java/com/ffii/fpsms/py/PyController.kt +++ b/src/main/java/com/ffii/fpsms/py/PyController.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.py import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService +import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import org.springframework.format.annotation.DateTimeFormat @@ -28,6 +29,7 @@ open class PyController( private val itemUomService: ItemUomService, private val plasticBagPrinterService: PlasticBagPrinterService, private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService, + private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, ) { companion object { private const val PACKAGING_PROCESS_NAME = "包裝" @@ -53,8 +55,22 @@ open class PyController( ) val ids = orders.mapNotNull { it.id } val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) + val printDate = ItemDefaultShelfLifeService.today() + val shelfLifeByCode = itemDefaultShelfLifeService.printShelfLifeByItemCodes( + orders.map { it.bom?.item?.code ?: it.bom?.code }, + ) val list = orders.map { jo -> - PyJobOrderListMapper.toListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService) + val itemCode = jo.bom?.item?.code ?: jo.bom?.code + val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem(itemCode, shelfLifeByCode, printDate) + PyJobOrderListMapper.toListItem( + jo, + printed[jo.id!!], + stockInLineRepository, + itemUomService, + defaultShelfLifeDays = days, + useMinus18 = useMinus18, + expiryDate = expiry, + ) } return ResponseEntity.ok(list) } diff --git a/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt b/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt index 0ac36f2..95b737c 100644 --- a/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt +++ b/src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt @@ -1,6 +1,7 @@ package com.ffii.fpsms.py import java.math.BigDecimal +import java.time.LocalDate import java.time.LocalDateTime /** @@ -27,4 +28,10 @@ data class PyJobOrderListItem( val labelPrintedQty: Long = 0, /** Cumulative qty from 激光機 submits (LASER). */ val laserPrintedQty: Long = 0, + /** Effective shelf life days used for print (chilled or -18, according to useMinus18). */ + val defaultShelfLifeDays: Int? = null, + /** True when expiry is computed from minus18Days instead of chilled defaultDays. */ + val useMinus18: Boolean? = null, + /** Print date (today, Asia/Hong_Kong) + effective shelf life days. */ + val expiryDate: LocalDate? = null, ) diff --git a/src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt b/src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt index 66e8a6a..9ca929d 100644 --- a/src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt +++ b/src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt @@ -1,8 +1,11 @@ package com.ffii.fpsms.py import com.ffii.fpsms.modules.jobOrder.entity.JobOrder +import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService +import com.ffii.fpsms.modules.master.service.ItemPrintShelfLife import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.stock.entity.StockInLineRepository +import java.time.LocalDate object PyJobOrderListMapper { @@ -80,6 +83,9 @@ object PyJobOrderListMapper { printed: PrintedQtyByChannel?, stockInLineRepository: StockInLineRepository, itemUomService: ItemUomService, + defaultShelfLifeDays: Int? = null, + useMinus18: Boolean? = null, + expiryDate: LocalDate? = null, ): PyJobOrderListItem { val itemCode = jo.bom?.item?.code ?: jo.bom?.code val baseName = jo.bom?.name ?: jo.bom?.item?.name @@ -107,6 +113,9 @@ object PyJobOrderListMapper { bagPrintedQty = p.bagPrintedQty, labelPrintedQty = p.labelPrintedQty, laserPrintedQty = p.laserPrintedQty, + defaultShelfLifeDays = defaultShelfLifeDays, + useMinus18 = useMinus18, + expiryDate = expiryDate, ) } @@ -116,6 +125,9 @@ object PyJobOrderListMapper { printed: PrintedQtyByChannel?, stockInLineRepository: StockInLineRepository, itemUomService: ItemUomService, + defaultShelfLifeDays: Int? = null, + useMinus18: Boolean? = null, + expiryDate: LocalDate? = null, ): PyJobOrderListItem { val itemCode = jo.bom?.item?.code ?: jo.bom?.code val baseName = jo.bom?.name ?: jo.bom?.item?.name @@ -143,6 +155,20 @@ object PyJobOrderListMapper { bagPrintedQty = p.bagPrintedQty, labelPrintedQty = p.labelPrintedQty, laserPrintedQty = p.laserPrintedQty, + defaultShelfLifeDays = defaultShelfLifeDays, + useMinus18 = useMinus18, + expiryDate = expiryDate, ) } + + fun shelfLifeForItem( + itemCode: String?, + byCode: Map, + printDate: LocalDate = ItemDefaultShelfLifeService.today(), + ): Triple { + val info = byCode[itemCode?.trim()?.uppercase().orEmpty()] + val days = info?.effectiveDays + val expiry = days?.let { ItemDefaultShelfLifeService.expiryOn(printDate, it) } + return Triple(days, info?.useMinus18, expiry) + } } diff --git a/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/01_create_item_default_shelf_life.sql b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/01_create_item_default_shelf_life.sql new file mode 100644 index 0000000..0425737 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/01_create_item_default_shelf_life.sql @@ -0,0 +1,21 @@ +--liquibase formatted sql + +--changeset fpsms:create_item_default_shelf_life +--comment: Default (non -18 warehouse) shelf life days by item code for bag / OnPack expiry print +CREATE TABLE `item_default_shelf_life` +( + `id` INT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + `itemCode` VARCHAR(50) NOT NULL, + `defaultDays` INT NOT NULL COMMENT 'Main shelf life days; not -18 warehouse', + `openedDays` INT NULL COMMENT '0-4C secondary; unused for print for now', + `storageC` VARCHAR(20) NULL, + `remarks` VARCHAR(255) NULL, + CONSTRAINT pk_item_default_shelf_life PRIMARY KEY (`id`), + UNIQUE KEY uk_item_default_shelf_life_itemCode (`itemCode`) +); diff --git a/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/02_seed_item_default_shelf_life.sql b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/02_seed_item_default_shelf_life.sql new file mode 100644 index 0000000..f453c4b --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/02_seed_item_default_shelf_life.sql @@ -0,0 +1,17 @@ +--liquibase formatted sql + +--changeset fpsms:seed_item_default_shelf_life_sample +--comment: Sample F-codes from the bag print shelf-life sheet (default days only; not -18). Import remaining codes later. +INSERT INTO `item_default_shelf_life` +(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`, `itemCode`, `defaultDays`, `openedDays`, `storageC`, `remarks`) +VALUES +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0013', 365, NULL, '18', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0015', 730, NULL, '18', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0025', 365, 10, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0028', 730, 12, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0032', 365, 12, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0039', 365, 12, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0055', 365, 7, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0075', 180, 14, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0110', 90, 21, '0-5', 'default; not -18 warehouse'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'F0250', 365, 12, '0-5', 'default; not -18 warehouse'); diff --git a/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/03_seed_item_default_shelf_life_from_joexpiry.sql b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/03_seed_item_default_shelf_life_from_joexpiry.sql new file mode 100644 index 0000000..f2fb4fb --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/03_seed_item_default_shelf_life_from_joexpiry.sql @@ -0,0 +1,181 @@ +--liquibase formatted sql + +--changeset fpsms:seed_item_default_shelf_life_from_joexpiry +--comment: Seed item_default_shelf_life from joExpiry.xlsx sheet XXXXXXXX; defaultDays = first non -18 保質期(P+) +-- generated 2026-08-20; skipped -18-only: 55 +INSERT INTO `item_default_shelf_life` +(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`, `itemCode`, `defaultDays`, `openedDays`, `storageC`, `remarks`) +VALUES +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2240', 60, NULL, '0-4', '小菜類 TOA韓式泡菜'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2241', 60, NULL, '0-4', '小菜類 TOA酸甜蘿蔔'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2327', 30, NULL, '0-4', '小菜類 OEM香辣炒菜莆(1KG/包)(加工)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2253', 14, NULL, '0-4', '小菜類 涼拌雙絲(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2244', 14, NULL, '0-4', '小菜類 酸甜蘿蔔粒(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2298', 14, NULL, '0-4', '小菜類 炒筍絲(200G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2018', 14, NULL, '0-4', '小菜類 炸菜肉絲(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2008', 14, NULL, '0-4', '小菜類 雪菜肉絲(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2058', 14, NULL, '0-4', '小菜類 (熟)大冬菇'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2364', 14, NULL, '0-4', '小菜類 炒雪菜(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2376', 14, NULL, '0-4', '小菜類 切冬菇粒(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2383', 14, NULL, '0-4', '小菜類 辣椒菜脯(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1152', 30, NULL, '0-4', '汁水類 魚露味水(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2274', 14, NULL, '0-4', '汁水類 西檸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2284', 14, NULL, '0-4', '汁水類 油醋汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1225', 14, NULL, '0-4', '汁水類 香辣薑汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1178', 30, NULL, '0-4', '汁水類 撈麵豉油(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1043', 14, NULL, '0-4', '汁水類 豆豉汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2206', 14, NULL, '0-4', '汁水類 鮮沙薑汁(雞廠)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1126', 14, NULL, '0-4', '汁水類 石頭鍋汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1213', 30, NULL, '0-4', '汁水類 石頭飯辣汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1137', 14, NULL, '0-4', '汁水類 咖喱魚蛋汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1117', 14, NULL, '0-4', '汁水類 漁樂紅咖哩汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2262', 7, NULL, '0-4', '汁水類 菇奶(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2250', 30, NULL, '0-4', '汁水類 欖角汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1214', 30, NULL, '0-4', '汁水類 炒粉絲汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1209', 30, NULL, '0-4', '汁水類 扒飯豉油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2243', 7, NULL, '0-4', '汁水類 粟米汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2210', 7, NULL, '0-4', '汁水類 牛奶薑汁酒'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2214', 30, NULL, '0-4', '汁水類 丼飯汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1224', 14, NULL, '0-4', '汁水類 柚子蒜蓉汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1216', 14, NULL, '0-4', '汁水類 叉燒蜜汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1144', 7, NULL, '0-4', '汁水類 越式汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1080', 14, NULL, '0-4', '汁水類 咖哩汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2211', 14, NULL, '0-4', '汁水類 瑤柱鮑汁 (蠔油汁)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1234', 14, NULL, '0-4', '汁水類 日式咖哩汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2317', 14, NULL, '0-4', '汁水類 漁樂黃咖哩汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2330', 60, NULL, '0-4', '汁水類 浸雞水(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2360', 14, NULL, '0-4', '汁水類 瑞士汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2333', 14, NULL, '0-4', '汁水類 豬鞍汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2331', 14, NULL, '0-4', '汁水類 青咖哩(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2381', 14, NULL, '0-4', '汁水類 咖哩豬皮(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2088', 14, NULL, '0-4', '肉食類 淨牛腩粒(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2271', 90, NULL, '0-4', '肉食類 豬腳薑(350g/包) - 台嵐款'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2290', 14, NULL, '0-4', '肉食類 豬軟骨(400G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2017', 14, NULL, '0-4', '肉食類 咸瘦肉絲'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2098', 60, NULL, '0-4', '肉食類 臘腸粒(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2127', 14, NULL, '0-4', '肉食類 五香肉丁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2105', 14, NULL, '0-4', '肉食類 咖喱牛腩(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2037', 14, NULL, '0-4', '肉食類 柱侯牛筋腩(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2115', 14, NULL, '0-4', '肉食類 瑞士雞翼(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2193', 14, NULL, '0-4', '肉食類 原條牛奶叉燒'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2063', 14, NULL, '0-4', '肉食類 咖哩牛腩(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2167', 14, NULL, '0-4', '肉食類 牛丼'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2334', 14, NULL, '0-4', '肉食類 原條牛奶叉燒(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2366', 14, NULL, '0-4', '肉食類 清湯牛腩'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2378', 14, NULL, '0-4', '肉食類 醬香鳳爪(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2325', 7, NULL, '0-4', '沙律 薯仔蛋沙律(2.2KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2300', 60, NULL, '0-4', '油類 凱撒牛油(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1088', 14, NULL, '0-4', '油類 海南雞油(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1118', 14, NULL, '0-4', '油類 漁樂煉豬油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1185', 14, NULL, '0-4', '油類 蒜香牛油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1120', 14, NULL, '0-4', '油類 漁樂蝦頭油(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2277', 3, NULL, '0-4', '粉麵﹑飯類 烚意粉(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2276', 3, NULL, '0-4', '粉麵﹑飯類 烚通粉(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1136', 14, NULL, '0-4', '粉麵﹑飯類 白粥'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1133', 14, NULL, '0-4', '粉麵﹑飯類 豬骨粥(1位份量)(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2363', 14, NULL, '0-4', '粉麵﹑飯類 炒麵底(170G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2228', 60, NULL, '0-4', '湯類 香濃沙嗲湯膽(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1180', 7, NULL, '0-4', '湯類 菇湯(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2246', 14, NULL, '0-4', '湯類 酸辣湯(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2282', 14, NULL, '0-4', '湯類 冷麵湯(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2248', 7, NULL, '0-4', '湯類 無添加魚湯(400ml/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2328', 7, NULL, '0-4', '湯類 鹿茸菇湯(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2332', 14, NULL, '0-4', '湯類 雞絲碗仔翅(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2329', 14, NULL, '0-4', '湯類 茶樹菇排骨湯(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2288', 3, NULL, '0-4', '飲品類 香水檸檬汁P+3(0.8L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1175', 4, NULL, '0-4', '飲品類 鮮檸檬汁(P+4)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2237', 10, NULL, '0-4', '飲品類 (樽裝用)凍咖啡底P+10(0.9L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2238', 10, NULL, '0-4', '飲品類 (餐廳用)凍咖啡底P+10(0.9L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2236', 10, NULL, '0-4', '飲品類 (樽裝用)凍奶茶底P+10(1L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2239', 10, NULL, '0-4', '飲品類 (餐廳用)凍奶茶底P+10(1L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2336', 10, NULL, '0-4', '飲品類 (無糖)凍咖啡底P+10(0.9L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2338', 10, NULL, '0-4', '飲品類 (無糖)凍奶茶底P+10(1L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1167', 3, NULL, '0-4', '飲品類 原個檸檬(10個/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2382', 10, NULL, '0-4', '飲品類 凍檸茶膽P+10(1000ML)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2346', 14, NULL, '0-4', '蔬菜類 南乳蓮藕(225G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2226', 14, NULL, '0-4', '餡料類 野菌沙嗲牛肉餡 (2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2080', 14, NULL, '0-4', '餡料類 黑椒牛肉餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2219', 14, NULL, '0-4', '餡料類 葡汁雞餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2126', 14, NULL, '0-4', '餡料類 白汁蘑菇雞批餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2220', 14, NULL, '0-4', '餡料類 糯米糍餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2074', 14, NULL, '0-4', '餡料類 叉燒飽餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2218', 14, NULL, '0-4', '餡料類 青醬白汁雞餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2204', 90, NULL, '0-4', '醬料類 八寶醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1223', 30, NULL, '0-4', '醬料類 韓式泡菜醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2205', 30, NULL, '0-4', '醬料類 沙薑醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1121', 30, NULL, '0-4', '醬料類 漁樂馬拉盞(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2234', 14, NULL, '0-4', '醬料類 韓樂黑炸醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2212', 14, NULL, '0-4', '醬料類 青醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1062', 14, NULL, '0-4', '醬料類 XO醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2227', 60, NULL, '0-4', '醬料類 香辣腐乳醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1042', 14, NULL, '0-4', '醬料類 口水雞汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1181', 14, NULL, '0-4', '醬料類 韓式撈雞醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1018', 60, NULL, '0-4', '醬料類 沙嗲醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2215', 14, NULL, '0-4', '醬料類 海南雞醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1232', 14, NULL, '0-4', '醬料類 剁椒醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1222', 30, NULL, '0-4', '醬料類 拌飯醬汁(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2235', 7, NULL, '0-4', '醬料類 芝士醬(菠菜用)(1LB)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1215', 14, NULL, '0-4', '醬料類 黑松露醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2254', 60, NULL, '0-4', '醬料類 蒜蓉豆豉粒(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1093', 7, NULL, '0-4', '醬料類 肉燥拉麵醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1211', 14, NULL, '0-4', '醬料類 船麵醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1231', 7, NULL, '0-4', '醬料類 金銀蒜(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1237', 14, NULL, '0-4', '醬料類 蒜泥(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1202', 14, NULL, '0-4', '醬料類 薑蓉'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1044', 60, NULL, '0-4', '醬料類 漁樂沙茶醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2326', 60, NULL, '0-4', '醬料類 南乳醬(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2322', 120, NULL, '0-4', '醬料類 麥芽糖(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1188', 30, NULL, '0-4', '醬料類 咖喱膽(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2373', 14, NULL, '0-4', '醬料類 燒味飯汁(2KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2375', 14, NULL, '0-4', '醬料類 車仔麵醬(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2182', 14, NULL, '0-4', '雜項 熟薏米 (2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2231', 7, NULL, '0-4', '雜項 花膠(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2384', 14, NULL, '0-4', '柚皮 (300G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2386', 7, NULL, '0-4', '飲品類 楊枝甘露(250G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2387', 14, NULL, '0-4', '肉食類 滷水雞翼尖(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2388', 14, NULL, '0-4', '小菜類 日式蘿蔔(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2393', 7, NULL, '0-4', '醬料類 芝士醬(菠菜用)(2LB)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2395', 14, NULL, '0-4', '肉食類 法式田螺(500G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2396', 14, NULL, '0-4', '肉食類 沙嗲牛肉(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2398', 14, NULL, '0-4', '汁水類 PP葡汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2399', 14, NULL, '0-4', '汁水類 PP蒜茸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2400', 14, NULL, '0-4', '汁水類 PP甜酸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2401', 14, NULL, '0-4', '汁水類 PP照燒汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2402', 14, NULL, '0-4', '汁水類 PP沙嗲牛肉汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2403', 14, NULL, '0-4', '汁水類 PP牛肝菌汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2404', 14, NULL, '0-4', '汁水類 PP黑椒汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2405', 14, NULL, '0-4', '汁水類 PP肉醬汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2406', 7, NULL, '0-4', '汁水類 PP白汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2407', 14, NULL, '0-4', '汁水類 PP鮮茄膽(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2408', 14, NULL, '0-4', '汁水類 PP龍蝦湯膽(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2409', 14, NULL, '0-4', '汁水類 PP焗飯汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2410', 14, NULL, '0-4', '汁水類 PP雜菜湯(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2411', 14, NULL, '0-4', '汁水類 PP碗仔翅(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2412', 14, NULL, '0-4', '小菜類 PP酸甜蘿蔔(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2415', 14, NULL, '0-4', '油類 PP龍蝦油(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP0261', 4, NULL, '0-4', '飲品類 青檸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2416', 7, NULL, '0-4', '飲品類 芒果汁底(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2422', 14, NULL, '0-4', '肉食類 泡椒雞翼尖(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2229', 7, NULL, '0-4', '小菜類 特級泡菜(2磅/包)(P+7)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1131', 7, NULL, '0-4', '汁水類 牛肝菌汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2249', 3, NULL, '0-4', '肉食類 醃西冷牛扒(10oz/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1148', 7, NULL, '0-4', '粉麵﹑飯類 皮蛋瘦肉粥(500g/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1091', 7, NULL, '0-4', '湯類 龍蝦湯膽'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2068', 7, NULL, '0-4', '湯類 碗仔翅'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2113', 7, NULL, '0-4', '醬料類 魚香肉醬(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1217', 7, NULL, '0-4', '醬料類 奶油芝士醬') +ON DUPLICATE KEY UPDATE + `defaultDays` = VALUES(`defaultDays`), + `openedDays` = VALUES(`openedDays`), + `storageC` = VALUES(`storageC`), + `remarks` = VALUES(`remarks`), + `modified` = NOW(), + `modifiedBy` = 'system', + `deleted` = 0; + +-- skipped -18-only item codes (not used for print yet): +-- PP1076, PP1069, PP1078, PP2252, PP1041, PP1066, PP1082, PP1157, PP1067, PP1074, PP1149, PP2306 +-- PP1210, PP2342, PP2349, PP2169, PP2242, PP2301, PP2269, PP2106, PP2292, PP2267, PP2144, PP2024 +-- PP2345, PP1065, MF0419, MB0857, MF0514, PP2251, PP2265, PP2260, PP1220, PP2335, PP2279, PP2278 +-- PP2341, PP2289, PP2368, PP2286, PP1071, PP2001, PP2061, PP2273, PP2272, PP2145, PP3002, MF0524 +-- MF0530, MF0539, MF0551, MG1665, PP0221, PP2347, PP2348 diff --git a/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/04_item_default_shelf_life_minus18_flag.sql b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/04_item_default_shelf_life_minus18_flag.sql new file mode 100644 index 0000000..e37a5c4 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/04_item_default_shelf_life_minus18_flag.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql + +--changeset fpsms:item_default_shelf_life_minus18_flag +--comment: Per-item flag useMinus18: 0 = print uses defaultDays (chilled), 1 = print uses minus18Days +ALTER TABLE `item_default_shelf_life` + MODIFY COLUMN `defaultDays` INT NULL COMMENT 'Non -18 warehouse shelf life days', + ADD COLUMN `minus18Days` INT NULL COMMENT '-18 warehouse shelf life days' AFTER `defaultDays`, + ADD COLUMN `useMinus18` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = expiry uses minus18Days; 0 = uses defaultDays' AFTER `minus18Days`; diff --git a/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/05_seed_item_default_shelf_life_minus18_flag.sql b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/05_seed_item_default_shelf_life_minus18_flag.sql new file mode 100644 index 0000000..802e5a0 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260820_item_default_shelf_life/05_seed_item_default_shelf_life_minus18_flag.sql @@ -0,0 +1,220 @@ +--liquibase formatted sql + +--changeset fpsms:seed_item_default_shelf_life_minus18_flag +--comment: Upsert joExpiry.xlsx sheet XXXXXXXX: defaultDays + minus18Days + useMinus18 flag +-- generated 2026-08-20; rows=201 useMinus18=1 (frozen-only default)=43 +INSERT INTO `item_default_shelf_life` +(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`, + `itemCode`, `defaultDays`, `minus18Days`, `useMinus18`, `openedDays`, `storageC`, `remarks`) +VALUES +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2240', 60, NULL, 0, NULL, '0-4', '小菜類 TOA韓式泡菜'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2241', 60, NULL, 0, NULL, '0-4', '小菜類 TOA酸甜蘿蔔'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2327', 30, NULL, 0, NULL, '0-4', '小菜類 OEM香辣炒菜莆(1KG/包)(加工)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2253', 14, 60, 0, NULL, '0-4', '小菜類 涼拌雙絲(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2244', 14, 60, 0, NULL, '0-4', '小菜類 酸甜蘿蔔粒(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2298', 14, 60, 0, NULL, '0-4', '小菜類 炒筍絲(200G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2018', 14, 60, 0, NULL, '0-4', '小菜類 炸菜肉絲(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2008', 14, 60, 0, NULL, '0-4', '小菜類 雪菜肉絲(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2058', 14, 60, 0, NULL, '0-4', '小菜類 (熟)大冬菇'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2364', 14, 60, 0, NULL, '0-4', '小菜類 炒雪菜(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2376', 14, 60, 0, NULL, '0-4', '小菜類 切冬菇粒(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2383', 14, 60, 0, NULL, '0-4', '小菜類 辣椒菜脯(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1076', NULL, 150, 1, NULL, '-18', '汁水類 OEM照燒汁(10包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1069', NULL, 150, 1, NULL, '-18', '汁水類 OEM甜酸汁(糖醋汁)(10包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1078', NULL, 150, 1, NULL, '-18', '汁水類 OEM沙嗲牛肉汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2252', NULL, 150, 1, NULL, '-18', '汁水類 OEM牛肝菌汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1041', NULL, 150, 1, NULL, '-18', '汁水類 OEM葡汁(10包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1066', NULL, 150, 1, NULL, '-18', '汁水類 OEM蒜茸汁(10包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1082', NULL, 150, 1, NULL, '-18', '汁水類 OEM鮮茄膽(12包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1157', NULL, 150, 1, NULL, '-18', '汁水類 OEM焗飯汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1067', NULL, 150, 1, NULL, '-18', '汁水類 OEM黑椒汁(12包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1074', NULL, 150, 1, NULL, '-18', '汁水類 OEM白汁(12包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1149', NULL, 30, 1, NULL, '-18', '汁水類 泰式鳳爪汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1152', 30, 60, 0, NULL, '0-4', '汁水類 魚露味水(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2274', 14, 60, 0, NULL, '0-4', '汁水類 西檸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2284', 14, 60, 0, NULL, '0-4', '汁水類 油醋汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2306', NULL, 150, 1, NULL, '-18', '汁水類 蟹黃汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1225', 14, 60, 0, NULL, '0-4', '汁水類 香辣薑汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1178', 30, 60, 0, NULL, '0-4', '汁水類 撈麵豉油(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1043', 14, 60, 0, NULL, '0-4', '汁水類 豆豉汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2206', 14, 60, 0, NULL, '0-4', '汁水類 鮮沙薑汁(雞廠)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1126', 14, 60, 0, NULL, '0-4', '汁水類 石頭鍋汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1213', 30, 60, 0, NULL, '0-4', '汁水類 石頭飯辣汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1137', 14, 60, 0, NULL, '0-4', '汁水類 咖喱魚蛋汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1117', 14, 60, 0, NULL, '0-4', '汁水類 漁樂紅咖哩汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2262', 7, 60, 0, NULL, '0-4', '汁水類 菇奶(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2250', 30, 60, 0, NULL, '0-4', '汁水類 欖角汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1214', 30, 60, 0, NULL, '0-4', '汁水類 炒粉絲汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1209', 30, 60, 0, NULL, '0-4', '汁水類 扒飯豉油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2243', 7, 60, 0, NULL, '0-4', '汁水類 粟米汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2210', 7, 60, 0, NULL, '0-4', '汁水類 牛奶薑汁酒'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2214', 30, 60, 0, NULL, '0-4', '汁水類 丼飯汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1224', 14, 60, 0, NULL, '0-4', '汁水類 柚子蒜蓉汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1216', 14, 60, 0, NULL, '0-4', '汁水類 叉燒蜜汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1210', NULL, 60, 1, NULL, '-18', '汁水類 泰式剌身汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1144', 7, 60, 0, NULL, '0-4', '汁水類 越式汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1080', 14, 60, 0, NULL, '0-4', '汁水類 咖哩汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2211', 14, 60, 0, NULL, '0-4', '汁水類 瑤柱鮑汁 (蠔油汁)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1234', 14, 60, 0, NULL, '0-4', '汁水類 日式咖哩汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2317', 14, 60, 0, NULL, '0-4', '汁水類 漁樂黃咖哩汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2342', NULL, 60, 1, NULL, '-18', '汁水類 UURO龍蝦膽(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2330', 60, 60, 0, NULL, '0-4', '汁水類 浸雞水(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2360', 14, 60, 0, NULL, '0-4', '汁水類 瑞士汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2333', 14, 60, 0, NULL, '0-4', '汁水類 豬鞍汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2349', NULL, 150, 1, NULL, '-18', '汁水類 芋絲汁(200G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2331', 14, 60, 0, NULL, '0-4', '汁水類 青咖哩(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2381', 14, 60, 0, NULL, '0-4', '汁水類 咖哩豬皮(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2169', NULL, 150, 1, NULL, '-18', '肉食類 OEM滷肉(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2242', NULL, 150, 1, NULL, '-18', '肉食類 TOA梅菜扣肉'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2301', NULL, 150, 1, NULL, '-18', '肉食類 OEM法式羊架(300G小包)(4小包/袋)(8袋/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2088', 14, 60, 0, NULL, '0-4', '肉食類 淨牛腩粒(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2271', 90, 60, 0, NULL, '0-4', '肉食類 豬腳薑(350g/包) - 台嵐款'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2290', 14, 60, 0, NULL, '0-4', '肉食類 豬軟骨(400G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2269', NULL, 30, 1, NULL, '-18', '肉食類 南乳豬手(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2106', NULL, 30, 1, NULL, '-18', '肉食類 東坡肉(1人份/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2292', NULL, 30, 1, NULL, '-18', '肉食類 五香牛雜(180G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2017', 14, 60, 0, NULL, '0-4', '肉食類 咸瘦肉絲'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2098', 60, NULL, 0, NULL, '0-4', '肉食類 臘腸粒(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2127', 14, 60, 0, NULL, '0-4', '肉食類 五香肉丁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2105', 14, 60, 0, NULL, '0-4', '肉食類 咖喱牛腩(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2267', NULL, 30, 1, NULL, '-18', '肉食類 法式牛肋條 (320g/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2037', 14, 60, 0, NULL, '0-4', '肉食類 柱侯牛筋腩(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2115', 14, 60, 0, NULL, '0-4', '肉食類 瑞士雞翼(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2193', 14, 60, 0, NULL, '0-4', '肉食類 原條牛奶叉燒'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2063', 14, 60, 0, NULL, '0-4', '肉食類 咖哩牛腩(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2167', 14, 60, 0, NULL, '0-4', '肉食類 牛丼'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2144', NULL, 30, 1, NULL, '-18', '肉食類 泰式鳳爪(1.5磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2024', NULL, 60, 1, NULL, '-18', '肉食類 豬肚'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2334', 14, 60, 0, NULL, '0-4', '肉食類 原條牛奶叉燒(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2345', NULL, 60, 1, NULL, '-18', '肉食類 一品煲(380G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2366', 14, 60, 0, NULL, '0-4', '肉食類 清湯牛腩'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2378', 14, 60, 0, NULL, '0-4', '肉食類 醬香鳳爪(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2325', 7, 60, 0, NULL, '0-4', '沙律 薯仔蛋沙律(2.2KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1065', NULL, 150, 1, NULL, '-18', '油類 OEM龍蝦油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2300', 60, 60, 0, NULL, '0-4', '油類 凱撒牛油(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1088', 14, 60, 0, NULL, '0-4', '油類 海南雞油(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1118', 14, 60, 0, NULL, '0-4', '油類 漁樂煉豬油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1185', 14, 60, 0, NULL, '0-4', '油類 蒜香牛油'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1120', 14, 60, 0, NULL, '0-4', '油類 漁樂蝦頭油(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'MF0419', NULL, 60, 1, NULL, '-18', '粉麵﹑飯類 白飯(24個/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'MB0857', NULL, 60, 1, NULL, '-18', '粉麵﹑飯類 TOA蛋炒飯 (36個/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'MF0514', NULL, 150, 1, NULL, '-18', '粉麵﹑飯類 TOA 海南雞油飯(280g/個 24個/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2277', 3, 60, 0, NULL, '0-4', '粉麵﹑飯類 烚意粉(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2276', 3, 60, 0, NULL, '0-4', '粉麵﹑飯類 烚通粉(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1136', 14, 60, 0, NULL, '0-4', '粉麵﹑飯類 白粥'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1133', 14, 60, 0, NULL, '0-4', '粉麵﹑飯類 豬骨粥(1位份量)(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2363', 14, 60, 0, NULL, '0-4', '粉麵﹑飯類 炒麵底(170G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2251', NULL, 150, 1, NULL, '-18', '湯類 OEM龍蝦湯膽(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2265', NULL, 150, 1, NULL, '-18', '湯類 OEM中式例湯(350g/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2260', NULL, 150, 1, NULL, '-18', '湯類 OEM碗仔翅(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1220', NULL, 150, 1, NULL, '-18', '湯類 OEM意大利菜湯'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2335', NULL, 150, 1, NULL, '-18', '湯類 OEM雜菜湯(12包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2228', 60, 60, 0, NULL, '0-4', '湯類 香濃沙嗲湯膽(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1180', 7, 60, 0, NULL, '0-4', '湯類 菇湯(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2279', NULL, 30, 1, NULL, '-18', '湯類 茶樹菇排骨湯(170G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2278', NULL, 30, 1, NULL, '-18', '湯類 胡椒豬肚雞湯(655G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2246', 14, 60, 0, NULL, '0-4', '湯類 酸辣湯(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2282', 14, 60, 0, NULL, '0-4', '湯類 冷麵湯(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2248', 7, 60, 0, NULL, '0-4', '湯類 無添加魚湯(400ml/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2328', 7, 60, 0, NULL, '0-4', '湯類 鹿茸菇湯(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2341', NULL, 60, 1, NULL, '-18', '湯類 日式雞湯(2KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2332', 14, 60, 0, NULL, '0-4', '湯類 雞絲碗仔翅(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2329', 14, 60, 0, NULL, '0-4', '湯類 茶樹菇排骨湯(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2288', 3, NULL, 0, NULL, '0-4', '飲品類 香水檸檬汁P+3(0.8L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1175', 4, NULL, 0, NULL, '0-4', '飲品類 鮮檸檬汁(P+4)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2237', 10, NULL, 0, NULL, '0-4', '飲品類 (樽裝用)凍咖啡底P+10(0.9L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2238', 10, NULL, 0, NULL, '0-4', '飲品類 (餐廳用)凍咖啡底P+10(0.9L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2236', 10, NULL, 0, NULL, '0-4', '飲品類 (樽裝用)凍奶茶底P+10(1L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2239', 10, NULL, 0, NULL, '0-4', '飲品類 (餐廳用)凍奶茶底P+10(1L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2336', 10, NULL, 0, NULL, '0-4', '飲品類 (無糖)凍咖啡底P+10(0.9L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2338', 10, NULL, 0, NULL, '0-4', '飲品類 (無糖)凍奶茶底P+10(1L/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1167', 3, NULL, 0, NULL, '0-4', '飲品類 原個檸檬(10個/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2368', NULL, 150, 1, NULL, '-18', '飲品類 冷壓菠蘿汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2382', 10, NULL, 0, NULL, '0-4', '飲品類 凍檸茶膽P+10(1000ML)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2346', 14, 60, 0, NULL, '0-4', '蔬菜類 南乳蓮藕(225G/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2226', 14, 60, 0, NULL, '0-4', '餡料類 野菌沙嗲牛肉餡 (2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2080', 14, 60, 0, NULL, '0-4', '餡料類 黑椒牛肉餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2219', 14, 60, 0, NULL, '0-4', '餡料類 葡汁雞餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2126', 14, 60, 0, NULL, '0-4', '餡料類 白汁蘑菇雞批餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2220', 14, 60, 0, NULL, '0-4', '餡料類 糯米糍餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2074', 14, 60, 0, NULL, '0-4', '餡料類 叉燒飽餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2218', 14, 60, 0, NULL, '0-4', '餡料類 青醬白汁雞餡'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2286', NULL, 150, 1, NULL, '-18', '醬料類 OEM黑松露醬(500G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1071', NULL, 150, 1, NULL, '-18', '醬料類 OEM肉醬汁(12包/箱)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2001', NULL, 150, 1, NULL, '-18', '醬料類 金沙咸蛋黃(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2204', 90, 60, 0, NULL, '0-4', '醬料類 八寶醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1223', 30, 60, 0, NULL, '0-4', '醬料類 韓式泡菜醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2205', 30, 60, 0, NULL, '0-4', '醬料類 沙薑醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1121', 30, 60, 0, NULL, '0-4', '醬料類 漁樂馬拉盞(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2234', 14, 60, 0, NULL, '0-4', '醬料類 韓樂黑炸醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2212', 14, 60, 0, NULL, '0-4', '醬料類 青醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1062', 14, 60, 0, NULL, '0-4', '醬料類 XO醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2227', 60, 60, 0, NULL, '0-4', '醬料類 香辣腐乳醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1042', 14, 60, 0, NULL, '0-4', '醬料類 口水雞汁(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1181', 14, 60, 0, NULL, '0-4', '醬料類 韓式撈雞醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1018', 60, 60, 0, NULL, '0-4', '醬料類 沙嗲醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2215', 14, 60, 0, NULL, '0-4', '醬料類 海南雞醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1232', 14, 60, 0, NULL, '0-4', '醬料類 剁椒醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1222', 30, 60, 0, NULL, '0-4', '醬料類 拌飯醬汁(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2235', 7, 60, 0, NULL, '0-4', '醬料類 芝士醬(菠菜用)(1LB)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1215', 14, 60, 0, NULL, '0-4', '醬料類 黑松露醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2254', 60, 60, 0, NULL, '0-4', '醬料類 蒜蓉豆豉粒(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1093', 7, 60, 0, NULL, '0-4', '醬料類 肉燥拉麵醬'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1211', 14, 60, 0, NULL, '0-4', '醬料類 船麵醬(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1231', 7, 60, 0, NULL, '0-4', '醬料類 金銀蒜(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1237', 14, 60, 0, NULL, '0-4', '醬料類 蒜泥(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1202', 14, 60, 0, NULL, '0-4', '醬料類 薑蓉'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1044', 60, 60, 0, NULL, '0-4', '醬料類 漁樂沙茶醬(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2326', 60, 60, 0, NULL, '0-4', '醬料類 南乳醬(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2322', 120, NULL, 0, NULL, '0-4', '醬料類 麥芽糖(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1188', 30, NULL, 0, NULL, '0-4', '醬料類 咖喱膽(2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2373', 14, 60, 0, NULL, '0-4', '醬料類 燒味飯汁(2KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2375', 14, 60, 0, NULL, '0-4', '醬料類 車仔麵醬(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2182', 14, NULL, 0, NULL, '0-4', '雜項 熟薏米 (2磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2231', 7, NULL, 0, NULL, '0-4', '雜項 花膠(1磅/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2384', 14, NULL, 0, NULL, '0-4', '柚皮 (300G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2386', 7, NULL, 0, NULL, '0-4', '飲品類 楊枝甘露(250G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2387', 14, 60, 0, NULL, '0-4', '肉食類 滷水雞翼尖(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2388', 14, 60, 0, NULL, '0-4', '小菜類 日式蘿蔔(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2393', 7, 60, 0, NULL, '0-4', '醬料類 芝士醬(菠菜用)(2LB)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2395', 14, 60, 0, NULL, '0-4', '肉食類 法式田螺(500G)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2396', 14, 60, 0, NULL, '0-4', '肉食類 沙嗲牛肉(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2398', 14, 60, 0, NULL, '0-4', '汁水類 PP葡汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2399', 14, 60, 0, NULL, '0-4', '汁水類 PP蒜茸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2400', 14, 60, 0, NULL, '0-4', '汁水類 PP甜酸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2401', 14, 60, 0, NULL, '0-4', '汁水類 PP照燒汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2402', 14, 60, 0, NULL, '0-4', '汁水類 PP沙嗲牛肉汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2403', 14, 60, 0, NULL, '0-4', '汁水類 PP牛肝菌汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2404', 14, 60, 0, NULL, '0-4', '汁水類 PP黑椒汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2405', 14, 60, 0, NULL, '0-4', '汁水類 PP肉醬汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2406', 7, 60, 0, NULL, '0-4', '汁水類 PP白汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2407', 14, 60, 0, NULL, '0-4', '汁水類 PP鮮茄膽(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2408', 14, 60, 0, NULL, '0-4', '汁水類 PP龍蝦湯膽(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2409', 14, 60, 0, NULL, '0-4', '汁水類 PP焗飯汁(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2410', 14, 60, 0, NULL, '0-4', '汁水類 PP雜菜湯(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2411', 14, 60, 0, NULL, '0-4', '汁水類 PP碗仔翅(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2412', 14, 60, 0, NULL, '0-4', '小菜類 PP酸甜蘿蔔(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2415', 14, 60, 0, NULL, '0-4', '油類 PP龍蝦油(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP0261', 4, NULL, 0, NULL, '0-4', '飲品類 青檸汁(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2416', 7, NULL, 0, NULL, '0-4', '飲品類 芒果汁底(1KG/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2422', 14, NULL, 0, NULL, '0-4', '肉食類 泡椒雞翼尖(1KG)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2229', 7, NULL, 0, NULL, '0-4', '小菜類 特級泡菜(2磅/包)(P+7)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1131', 7, NULL, 0, NULL, '0-4', '汁水類 牛肝菌汁'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2145', NULL, 30, 1, NULL, '-18', '肉食類 炆羊腩(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2249', 3, NULL, 0, NULL, '0-4', '肉食類 醃西冷牛扒(10oz/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1148', 7, NULL, 0, NULL, '0-4', '粉麵﹑飯類 皮蛋瘦肉粥(500g/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP3002', NULL, 60, 1, NULL, '-18', '湯類 蓮藕湯(1人份/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1091', 7, NULL, 0, NULL, '0-4', '湯類 龍蝦湯膽'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2068', 7, NULL, 0, NULL, '0-4', '湯類 碗仔翅'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP2113', 7, NULL, 0, NULL, '0-4', '醬料類 魚香肉醬(1人份量/包)'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'PP1217', 7, NULL, 0, NULL, '0-4', '醬料類 奶油芝士醬') +ON DUPLICATE KEY UPDATE + `defaultDays` = VALUES(`defaultDays`), + `minus18Days` = VALUES(`minus18Days`), + `useMinus18` = VALUES(`useMinus18`), + `openedDays` = VALUES(`openedDays`), + `storageC` = VALUES(`storageC`), + `remarks` = VALUES(`remarks`), + `modified` = NOW(), + `modifiedBy` = 'system', + `deleted` = 0; diff --git a/src/main/resources/db/changelog/changes/20260820_onpack_template_file/01_create_onpack_template_file.sql b/src/main/resources/db/changelog/changes/20260820_onpack_template_file/01_create_onpack_template_file.sql new file mode 100644 index 0000000..bc2a7cb --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260820_onpack_template_file/01_create_onpack_template_file.sql @@ -0,0 +1,21 @@ +--liquibase formatted sql + +--changeset fpsms:create_onpack_template_file +--comment: User-maintained OnPack template files (汁水機 / 檸檬機). ZIP load prefers these over classpath. +CREATE TABLE `onpack_template_file` +( + `id` INT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + `machine` VARCHAR(20) NOT NULL COMMENT 'juice=onpack2030 汁水機; lemon=onpack2030_2 檸檬機', + `itemCode` VARCHAR(50) NOT NULL, + `fileName` VARCHAR(200) NOT NULL, + `byteSize` INT NOT NULL, + `fileBytes` MEDIUMBLOB NOT NULL, + CONSTRAINT pk_onpack_template_file PRIMARY KEY (`id`), + UNIQUE KEY uk_onpack_template_file (`machine`, `itemCode`, `fileName`) +); diff --git a/src/main/resources/onpack2030_exp/pp1181.image b/src/main/resources/onpack2030_exp/pp1181.image new file mode 100644 index 0000000000000000000000000000000000000000..b4dcf4dd0eb374844ba358b8080b4cfb6892ce6c GIT binary patch literal 2996 zcmdUxeQy#$5XR@DVE+e-!1bD(msqK_vDQYT)fm&@akZo%cwn_(Uj040UXQmzF{#09 z4sPdt=9$^OUq2&zU}e^+W!AMZE443HvQKu+?-Lu?k-fCnytm+tkogQhMY?2fEwG9` z79@&;1S|3^QD%h(*DcK1W@iXVYM+}*h zJ+%uLW9p>#kV@~iWGdj&_) z9izKX=Bmw=i|7NjXxTe!kZ(fF36?ulYZpptb@B~thn4WwfS&M_;U*TMQRX*Vo)=vEGMUf~z`K9SV7zdGz9VbP^ZokP3InN;rDG7wj{ZDw*Ol#%G&K zjo8WRJU;?;Wi8Cc$mSMfJnd7JGqRE9ZBh{()y2~o%wlq_BkwgP>5)CdgL>)$$(Xv; zZsevp5xu2ode}VTZRF(DSp&3%;AB_j)V!xup}dx{>*xa_SNsJv0`nFz1di_CBQi~~ zE)2%h;~IVh`Z3rw|Ky`xWbdbF+C_BH0IkW|`3lU_70Dt`Mb1TM$F@fXkC~0jyf zIaYsz;2Ai`Ly=>SqK|JCioV-CDg};{)XhgyozVg3bug?`w{1Fj6^81T9nijS6upOe z6hr5E6^fd~Sqzn5PThPAbyMl)T?fO4!_Zf5*&O~W3>&vh;_jU=Ty&o5j{Scy{K|>C z9)2N{xo96f*Y&VS+Pwk*D$WBU!ybmx2k literal 0 HcmV?d00001 diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt new file mode 100644 index 0000000..376fb3d --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt @@ -0,0 +1,38 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class LaserBag2PayloadTest { + + @Test + fun payload_without_expiry_keeps_original_three_fields() { + assertEquals( + "{\"itemId\":1,\"stockInLineId\":2};PP1175;鮮檸檬汁;;", + PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "PP1175", "鮮檸檬汁", null), + ) + assertEquals( + "0;PP1175;鮮檸檬汁;;", + PlasticBagPrinterService.buildLaserBag2Payload(null, null, "PP1175", "鮮檸檬汁"), + ) + } + + @Test + fun payload_with_iso_expiry_adds_fourth_print_label_field() { + assertEquals( + "{\"itemId\":1,\"stockInLineId\":2};PP1175;鮮檸檬汁;Expiry Date 20260821;;", + PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "PP1175", "鮮檸檬汁", "2026-08-21"), + ) + } + + @Test + fun formatLaserExpiryParam_accepts_iso_compact_and_print_label() { + assertEquals("Expiry Date 20260821", PlasticBagPrinterService.formatLaserExpiryParam("2026-08-21")) + assertEquals("Expiry Date 20260821", PlasticBagPrinterService.formatLaserExpiryParam("20260821")) + assertEquals( + "Expiry Date 20260821", + PlasticBagPrinterService.formatLaserExpiryParam("Expiry Date 20260821"), + ) + assertEquals("", PlasticBagPrinterService.formatLaserExpiryParam(" ")) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXmlTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXmlTest.kt new file mode 100644 index 0000000..a9ace27 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackJuiceExpiryXmlTest.kt @@ -0,0 +1,109 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class OnPackJuiceExpiryXmlTest { + + private val sample = """ + DEG_053007100 + LOGO_3650025000BLACKdate.bmp42031173 + LOGO_4775037500BLACKpp1080qr.bmp39133000 + + """.trimIndent() + + @Test + fun rewriteLogoFileName_updates_logo_not_logo2() { + val xml = """ + + LOGOold-product.bmp + LOGO_2old-code.bmp + + """.trimIndent() + val out = OnPackJuiceExpiryXml.rewriteLogoFileName(xml, "LOGO", "pp1181Product.bmp") + val out2 = OnPackJuiceExpiryXml.rewriteLogoFileName(out, "LOGO_2", "pp1181Code.bmp") + assertTrue(out2.contains("LOGOpp1181Product.bmp")) + assertTrue(out2.contains("LOGO_2pp1181Code.bmp")) + assertFalse(out2.contains("old-product.bmp")) + assertFalse(out2.contains("old-code.bmp")) + } + + @Test + fun applyExpiry_places_expiry_above_moved_qr() { + val out = OnPackJuiceExpiryXml.applyExpiry(sample, "pp1080exp.bmp", 1800) + val layout = OnPackJuiceExpiryXml.layoutFor(sample, 1800) + assertTrue(out.contains("LOGO_EXP")) + assertTrue(out.contains("pp1080exp.bmp")) + assertTrue(out.contains("date.bmp")) + assertTrue(out.contains("pp1080qr.bmp")) + assertTrue(out.contains("${layout.dateHeight}")) + assertTrue(out.contains("${layout.expY}")) + assertTrue(out.contains("${layout.qrY}")) + assertTrue(layout.expY + layout.expHeight <= layout.qrY) + assertEquals(1, Regex("LOGO_3").findAll(out).count()) + val expIdx = out.indexOf("LOGO_EXP") + val qrIdx = out.indexOf("LOGO_4") + assertTrue(expIdx in 1 until qrIdx) + } + + @Test + fun applyExpiry_replaces_existing_logo_exp_filename_and_geometry() { + val once = OnPackJuiceExpiryXml.applyExpiry(sample, "pp1080exp.bmp", 1800) + val twice = OnPackJuiceExpiryXml.applyExpiry(once, "otherexp.bmp", 1800) + assertEquals(1, Regex("LOGO_EXP").findAll(twice).count()) + assertTrue(twice.contains("otherexp.bmp")) + assertFalse(twice.contains("pp1080exp.bmp")) + assertTrue(twice.contains("date.bmp")) + } + + @Test + fun applyExpiry_rewrites_logo5_filename_without_injecting_logo_exp() { + val designed = """ + DEG_053007100 + LOGO_3850020000BLACKdate.bmp42031173 + LOGO_510030000BLACKold-exp.bmp5187657 + LOGO_4925040000BLACKpp1181qr.bmp48362500 + + """.trimIndent() + val out = OnPackJuiceExpiryXml.applyExpiry(designed, "pp1181exp.bmp", 1800) + assertEquals("LOGO_5", OnPackJuiceExpiryXml.existingExpirySlotName(designed)) + assertFalse(out.contains("LOGO_EXP")) + assertEquals(1, Regex("LOGO_5").findAll(out).count()) + assertTrue(out.contains("pp1181exp.bmp")) + assertFalse(out.contains("old-exp.bmp")) + assertTrue(out.contains("3000")) + assertTrue(out.contains("4000")) + assertTrue(out.contains("1173")) + assertTrue(out.contains("date.bmp")) + } + + @Test + fun applyExpiry_inserts_pp2211exp_above_qr_on_real_template() { + val bytes = javaClass.classLoader.getResourceAsStream("onpack2030/pp2211.image")!!.use { it.readBytes() } + val xml = String(bytes.copyOfRange(2, bytes.size), Charsets.UTF_16LE) + val out = OnPackJuiceExpiryXml.applyExpiry(xml, "pp2211exp.bmp", 1800) + val layout = OnPackJuiceExpiryXml.layoutFor(xml, 1800) + assertTrue(out.contains("LOGO_EXP")) + assertTrue(out.contains("pp2211exp.bmp")) + assertTrue(out.contains("pp2211qr.bmp")) + assertTrue(layout.expY + layout.expHeight + 200 <= layout.qrY) + assertTrue(layout.qrY > 3750) + } + + @Test + fun applyExpiry_uses_real_pp1181_exp_template_slot() { + val bytes = javaClass.classLoader.getResourceAsStream("onpack2030_exp/pp1181.image")!!.use { it.readBytes() } + val xml = String(bytes.copyOfRange(2, bytes.size), Charsets.UTF_16LE) + val out = OnPackJuiceExpiryXml.applyExpiry(xml, "pp1181exp.bmp", 1800) + assertEquals("LOGO_5", OnPackJuiceExpiryXml.existingExpirySlotName(xml)) + assertFalse(out.contains("LOGO_EXP")) + assertTrue(out.contains("LOGO_5")) + assertTrue(out.contains("pp1181exp.bmp")) + val y4 = Regex("""(?s)\s*LOGO_4\s*[\s\S]*?\s*(\d+)\s*""").find(out)?.groupValues?.get(1) + assertEquals("4000", y4) + val y5 = Regex("""(?s)\s*LOGO_5\s*[\s\S]*?\s*(\d+)\s*""").find(out)?.groupValues?.get(1) + assertEquals("3000", y5) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXmlTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXmlTest.kt new file mode 100644 index 0000000..2054c58 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackLemonExpiryXmlTest.kt @@ -0,0 +1,40 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class OnPackLemonExpiryXmlTest { + + private val sample = """ + + TEXT_232900225090 + 783 + PP1175-21 + TEXT_342000225090 + 0 + + """.trimIndent() + + @Test + fun applyExpiry_inserts_text_exp_between_lot_and_production_date() { + val out = OnPackLemonExpiryXml.applyExpiry(sample, "Expiry Date : 20/8/2027") + assertTrue(out.contains("TEXT_EXP")) + assertTrue(out.contains("Expiry Date : 20/8/2027")) + assertTrue(out.contains("TEXT_3")) + assertTrue(out.contains("0")) + assertTrue(out.contains("2450")) + assertTrue(out.contains("520")) + assertFalse(out.contains("783")) + } + + @Test + fun applyExpiry_replaces_existing_text_exp() { + val once = OnPackLemonExpiryXml.applyExpiry(sample, "Expiry Date : 20/8/2027") + val twice = OnPackLemonExpiryXml.applyExpiry(once, "Expiry Date : 1/1/2028") + assertEquals(1, Regex("TEXT_EXP").findAll(twice).count()) + assertTrue(twice.contains("Expiry Date : 1/1/2028")) + assertFalse(twice.contains("Expiry Date : 20/8/2027")) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileServiceTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileServiceTest.kt new file mode 100644 index 0000000..a4f7291 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileServiceTest.kt @@ -0,0 +1,87 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class OnPackTemplateFileServiceTest { + + @Test + fun storedFileName_normalizes_image_by_machine() { + assertEquals( + "pp2211.image", + OnPackTemplateFileService.storedFileName("juice", "PP2211", "PP2211.image"), + ) + assertEquals( + "PP1175.image", + OnPackTemplateFileService.storedFileName("lemon", "pp1175", "foo.image"), + ) + assertEquals( + "product.bmp", + OnPackTemplateFileService.storedFileName("juice", "PP2211", "sub/product.bmp"), + ) + } + + @Test + fun sanitizeFileName_rejects_path_and_bad_ext() { + assertNull(OnPackTemplateFileService.sanitizeFileName("../secret.txt")) + assertNull(OnPackTemplateFileService.sanitizeFileName("x.exe")) + assertEquals("a.job", OnPackTemplateFileService.sanitizeFileName("a.job")) + } + + @Test + fun normalizeMachine_accepts_aliases() { + assertEquals("juice", OnPackTemplateFileService.normalizeMachine("汁水機")) + assertEquals("lemon", OnPackTemplateFileService.normalizeMachine("onpack2030_2")) + assertThrows { OnPackTemplateFileService.normalizeMachine("laser") } + } + + @Test + fun imageFileNameCandidates_cover_case() { + val juice = OnPackTemplateFileService.imageFileNameCandidates("juice", "PP2211") + assertTrue(juice.contains("pp2211.image")) + val lemon = OnPackTemplateFileService.imageFileNameCandidates("lemon", "pp1175") + assertTrue(lemon.contains("PP1175.image")) + } + + @Test + fun mergeSupported_marks_juice_vs_sources() { + val rows = OnPackTemplateFileService.mergeSupported( + registered = setOf("PP2211", "PP9999"), + inDatabase = setOf("PP9999"), + builtin = setOf("PP2211", "PP1080"), + ) + val byCode = rows.associateBy { it.itemCode } + assertEquals(setOf("PP1080", "PP2211", "PP9999"), byCode.keys) + assertTrue(byCode.getValue("PP2211").printable && byCode.getValue("PP2211").builtin) + assertTrue(byCode.getValue("PP9999").printable && byCode.getValue("PP9999").inDatabase) + assertTrue(byCode.getValue("PP1080").printable && !byCode.getValue("PP1080").registered) + } + + @Test + fun scanBuiltinImageCodes_finds_juice_and_lemon_templates() { + val juice = OnPackTemplateFileService.scanBuiltinImageCodes("juice") + val lemon = OnPackTemplateFileService.scanBuiltinImageCodes("lemon") + assertTrue(juice.contains("PP2211"), juice.toString()) + assertTrue(juice.size >= 20, "juice builtin count=${juice.size}") + assertTrue(!juice.contains("TESTPP1234"), juice.toString()) + assertTrue(!juice.contains("TESTPP1126"), juice.toString()) + assertTrue(!juice.contains("TT_PP1167"), juice.toString()) + assertTrue(!juice.contains("LO342987"), juice.toString()) + assertTrue(!juice.contains("LPP2290A"), juice.toString()) + assertTrue(lemon.contains("PP1175"), lemon.toString()) + assertTrue(lemon.size >= 5, "lemon builtin count=${lemon.size}") + } + + @Test + fun itemCodeFromImageFileName_skips_non_item_templates() { + assertEquals("PP2211", OnPackTemplateFileService.itemCodeFromImageFileName("pp2211.image")) + assertNull(OnPackTemplateFileService.itemCodeFromImageFileName("default.image")) + assertNull(OnPackTemplateFileService.itemCodeFromImageFileName("日期.image")) + assertTrue(OnPackTemplateFileService.isListedBuiltinCode("PP2211")) + assertTrue(!OnPackTemplateFileService.isListedBuiltinCode("TESTPP1234")) + assertTrue(!OnPackTemplateFileService.isListedBuiltinCode("TT_PP1167")) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateLoadQaTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateLoadQaTest.kt new file mode 100644 index 0000000..17f83dd --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateLoadQaTest.kt @@ -0,0 +1,206 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.xml.sax.InputSource +import java.io.File +import java.io.StringReader +import javax.xml.parsers.DocumentBuilderFactory + +/** + * Load-safety QA for SmartDate X40: rewritten `.image` bytes must stay well-formed, + * keep the original encoding/BOM, unique field names/IDs, and stay on the canvas. + */ +class OnPackTemplateLoadQaTest { + + @Test + fun juice_templates_stay_loadable_after_expiry_rewrite() { + val failures = mutableListOf() + val expiryBmpWidth = 1800 + for (folder in listOf("onpack2030", "onpack2030_exp")) { + for (file in imageFiles(folder)) { + val original = file.readBytes() + val (xml, encodeBack) = OnPackImageTemplateCodec.decode(original) + val stem = file.nameWithoutExtension.lowercase() + val qrName = "${stem}qr.bmp" + val expName = "${stem}exp.bmp" + val withQr = xml.replace( + Regex("""(\s*LOGO_4\s*[\s\S]*?)([^<]+)()"""), + "$1$qrName$3", + ) + val rewritten = OnPackJuiceExpiryXml.applyExpiry(withQr, expName, expiryBmpWidth) + val outBytes = encodeBack(rewritten) + val (roundTrip, _) = OnPackImageTemplateCodec.decode(outBytes) + val label = "$folder/${file.name}" + checkCommon(failures, label, original, outBytes, xml, roundTrip) + val hadLogo4 = Regex("""\s*LOGO_4\s*""").containsMatchIn(xml) + val slot = OnPackJuiceExpiryXml.existingExpirySlotName(xml) + if (slot != null) { + if (slot == "LOGO_5" && Regex("""\s*LOGO_EXP\s*""").containsMatchIn(roundTrip)) { + failures.add("$label: must not inject LOGO_EXP when LOGO_5 exists") + } + val slotFile = Regex( + """(?s)\s*${Regex.escape(slot)}\s*[\s\S]*?([^<]+)""", + ).find(roundTrip)?.groupValues?.get(1) + if (slotFile != expName) { + failures.add("$label: $slot FileName=$slotFile expected $expName") + } + val yOrig = Regex("""(?s)\s*LOGO_4\s*[\s\S]*?\s*(\d+)\s*""").find(xml)?.groupValues?.get(1) + val yNew = Regex("""(?s)\s*LOGO_4\s*[\s\S]*?\s*(\d+)\s*""").find(roundTrip)?.groupValues?.get(1) + if (yOrig != yNew) { + failures.add("$label: designer template QR Y changed $yOrig -> $yNew") + } + continue + } + if (!hadLogo4) { + if (roundTrip.contains("LOGO_EXP")) { + failures.add("$label: template without LOGO_4 must not gain LOGO_EXP") + } + continue + } + if (!roundTrip.contains("LOGO_EXP")) { + failures.add("$label: missing LOGO_EXP") + } + if (!roundTrip.contains("$expName")) { + failures.add("$label: .image does not reference $expName") + } + if (!roundTrip.contains("$qrName")) { + failures.add("$label: LOGO_4 not pointing at $qrName") + } + if (Regex("""LOGO_EXP""").findAll(roundTrip).count() != 1) { + failures.add("$label: LOGO_EXP count != 1") + } + val twice = OnPackJuiceExpiryXml.applyExpiry(roundTrip, expName, expiryBmpWidth) + if (Regex("""LOGO_EXP""").findAll(twice).count() != 1) { + failures.add("$label: second apply duplicated LOGO_EXP") + } + val layout = OnPackJuiceExpiryXml.layoutFor(xml, expiryBmpWidth) + if (layout.expY + layout.expHeight > layout.qrY) { + failures.add("$label: expiry overlaps QR (expBottom=${layout.expY + layout.expHeight} qrY=${layout.qrY})") + } + if (layout.qrY + layout.qrHeight > layout.canvasHeight) { + failures.add("$label: QR below canvas (bottom=${layout.qrY + layout.qrHeight} canvas=${layout.canvasHeight})") + } + if (layout.x + layout.expWidth > layout.canvasWidth + 1) { + failures.add("$label: expiry wider than canvas") + } + } + } + if (failures.isNotEmpty()) { + throw AssertionError(failures.joinToString("\n")) + } + } + + @Test + fun lemon_templates_stay_loadable_after_expiry_rewrite() { + val failures = mutableListOf() + val label = "Expiry Date : 31/8/2026" + for (file in imageFiles("onpack2030_2")) { + val original = file.readBytes() + val (xml, encodeBack) = OnPackImageTemplateCodec.decode(original) + val rewritten = OnPackLemonExpiryXml.applyExpiry(xml, label) + val outBytes = encodeBack(rewritten) + val (roundTrip, _) = OnPackImageTemplateCodec.decode(outBytes) + checkCommon(failures, file.name, original, outBytes, xml, roundTrip) + if (!Regex("""\s*TEXT_3\s*""").containsMatchIn(xml)) { + continue + } + if (!roundTrip.contains("TEXT_EXP")) { + failures.add("${file.name}: missing TEXT_EXP") + } + if (!roundTrip.contains("$label")) { + failures.add("${file.name}: TEXT_EXP wording missing") + } + if (!roundTrip.contains("0")) { + failures.add("${file.name}: TEXT_3 production date OffsetDays changed") + } + if (Regex("""TEXT_EXP""").findAll(roundTrip).count() != 1) { + failures.add("${file.name}: TEXT_EXP count != 1") + } + val twice = OnPackLemonExpiryXml.applyExpiry(roundTrip, "Expiry Date : 1/1/2028") + if (Regex("""TEXT_EXP""").findAll(twice).count() != 1) { + failures.add("${file.name}: second apply duplicated TEXT_EXP") + } + } + if (failures.isNotEmpty()) { + throw AssertionError(failures.joinToString("\n")) + } + } + + @Test + fun juice_qa_covers_production_skus() { + val names = imageFiles("onpack2030").map { it.name.lowercase() }.toSet() + assertTrue(names.contains("pp2211.image"), "pp2211.image must be in juice QA set") + assertTrue(names.contains("pp1080.image"), "pp1080.image must be in juice QA set") + val expNames = imageFiles("onpack2030_exp").map { it.name.lowercase() }.toSet() + assertTrue(expNames.contains("pp1181.image"), "pp1181.image must be in expiry layout set") + } + + private fun checkCommon( + failures: MutableList, + name: String, + original: ByteArray, + outBytes: ByteArray, + originalXml: String, + outXml: String, + ) { + if (OnPackImageTemplateCodec.hasUtf16LeBom(original) && !OnPackImageTemplateCodec.hasUtf16LeBom(outBytes)) { + failures.add("$name: lost UTF-16 LE BOM (X40 will not load)") + } + if (OnPackImageTemplateCodec.hasUtf16LeBom(outBytes) && + outBytes.size >= 4 && + outBytes[2] == 0xFF.toByte() && + outBytes[3] == 0xFE.toByte() + ) { + failures.add("$name: double UTF-16 BOM") + } + if (countTag(outXml, "FieldList") != countTag(originalXml, "FieldList")) { + failures.add("$name: FieldList count changed") + } + if (!outXml.contains("") || !outXml.contains("([^<]+)""").findAll(outXml).map { it.groupValues[1].trim() }.toList()) + if (dupNames.isNotEmpty()) { + failures.add("$name: duplicate field names $dupNames") + } + val dupIds = duplicates(Regex("""(\d+)""").findAll(outXml).map { it.groupValues[1] }.toList()) + if (dupIds.isNotEmpty()) { + failures.add("$name: duplicate field IDs $dupIds") + } + } + + private fun imageFiles(folder: String): List { + val probe = javaClass.classLoader.getResource(folder) + ?: throw AssertionError("Missing classpath folder $folder") + val dir = File(probe.toURI()) + assertTrue(dir.isDirectory, "$folder should be a directory") + return dir.listFiles { f -> f.isFile && f.name.endsWith(".image") }!!.sortedBy { it.name } + } + + private fun wellFormedError(xml: String): String? { + return try { + val factory = DocumentBuilderFactory.newInstance() + factory.isNamespaceAware = false + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + val doc = factory.newDocumentBuilder().parse(InputSource(StringReader(xml))) + if (doc.documentElement.tagName != "Legend") { + "root is ${doc.documentElement.tagName}" + } else { + null + } + } catch (e: Exception) { + e.message ?: e.javaClass.simpleName + } + } + + private fun countTag(xml: String, tag: String): Int = + Regex(")").findAll(xml).count() + + private fun duplicates(values: List): List = + values.groupingBy { it }.eachCount().filter { it.value > 1 }.keys.sorted() +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt new file mode 100644 index 0000000..8accc86 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt @@ -0,0 +1,45 @@ +package com.ffii.fpsms.modules.master.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.LocalDate + +class ItemDefaultShelfLifeServiceTest { + + @Test + fun expiryOn_adds_default_days_to_print_date() { + assertEquals( + LocalDate.of(2027, 8, 20), + ItemDefaultShelfLifeService.expiryOn(LocalDate.of(2026, 8, 20), 365), + ) + assertEquals( + LocalDate.of(2026, 11, 18), + ItemDefaultShelfLifeService.expiryOn(LocalDate.of(2026, 8, 20), 90), + ) + } + + @Test + fun effectiveDays_follows_useMinus18_flag() { + assertEquals(14, ItemDefaultShelfLifeService.effectiveDays(14, 60, false)) + assertEquals(60, ItemDefaultShelfLifeService.effectiveDays(14, 60, true)) + assertEquals(150, ItemDefaultShelfLifeService.effectiveDays(null, 150, true)) + assertEquals(null, ItemDefaultShelfLifeService.effectiveDays(null, null, true)) + assertEquals(null, ItemDefaultShelfLifeService.effectiveDays(null, 150, false)) + } + + @Test + fun formatPrintLabel_uses_yyyyMMdd() { + assertEquals( + "Expiry Date 20260831", + ItemDefaultShelfLifeService.formatPrintLabel(LocalDate.of(2026, 8, 31)), + ) + assertEquals( + "Expiry Date 20270101", + ItemDefaultShelfLifeService.formatPrintLabel(LocalDate.of(2027, 1, 1)), + ) + assertEquals( + "20260821", + ItemDefaultShelfLifeService.formatProductionDatePrintLabel(LocalDate.of(2026, 8, 21)), + ) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupportTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupportTest.kt new file mode 100644 index 0000000..304d0db --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemM18IdRemapSupportTest.kt @@ -0,0 +1,94 @@ +package com.ffii.fpsms.modules.master.service + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ItemM18IdRemapSupportTest { + + @Test + fun canLink_whenM18SyncHasNewProIdAndSameCodeItem() { + assertTrue( + ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( + requestId = null, + requestM18Id = 20022L, + duplicatedItemId = 10L, + ownerOfNewM18IdItemId = null, + ) + ) + } + + @Test + fun canLink_whenExistingItemHasNoM18IdYet() { + assertTrue( + ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( + requestId = null, + requestM18Id = 20022L, + duplicatedItemId = 10L, + ownerOfNewM18IdItemId = null, + ) + ) + } + + @Test + fun cannotLink_whenAnotherItemAlreadyOwnsNewM18Id() { + assertFalse( + ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( + requestId = null, + requestM18Id = 20022L, + duplicatedItemId = 10L, + ownerOfNewM18IdItemId = 99L, + ) + ) + } + + @Test + fun cannotLink_whenRequestTargetsADifferentLocalItem() { + assertFalse( + ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( + requestId = 11L, + requestM18Id = 20022L, + duplicatedItemId = 10L, + ownerOfNewM18IdItemId = null, + ) + ) + } + + @Test + fun cannotLink_whenNoM18IdOnRequest() { + assertFalse( + ItemM18IdRemapSupport.canLinkM18IdToDuplicateCode( + requestId = null, + requestM18Id = null, + duplicatedItemId = 10L, + ownerOfNewM18IdItemId = null, + ) + ) + } + + @Test + fun isM18IdLinkOnly_trueAfterMissThenLink() { + assertTrue( + ItemM18IdRemapSupport.isM18IdLinkOnly( + existingByM18IdWasMissing = true, + message = ItemM18IdRemapSupport.LINKED_MESSAGE, + ) + ) + } + + @Test + fun isM18IdLinkOnly_falseForNormalProductUpdate() { + assertFalse( + ItemM18IdRemapSupport.isM18IdLinkOnly( + existingByM18IdWasMissing = false, + message = ItemM18IdRemapSupport.LINKED_MESSAGE, + ) + ) + assertFalse( + ItemM18IdRemapSupport.isM18IdLinkOnly( + existingByM18IdWasMissing = true, + message = "M18 Item does not have any updates", + ) + ) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt b/src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt index e90561f..70b7485 100644 --- a/src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt +++ b/src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import java.time.LocalDate +import com.ffii.fpsms.modules.master.service.ItemPrintShelfLife class PyJobOrderListMapperTest { @@ -64,14 +66,38 @@ class PyJobOrderListMapperTest { } @Test - fun buildDisplayItemNameForLaser_omits_appended_uom_for_PP1175() { - assertEquals( - "鮮檸檬汁(P+4)", - PyJobOrderListMapper.buildDisplayItemNameForLaser("鮮檸檬汁(P+4)", stockUom, "PP1175"), - ) - assertEquals( - "咖哩汁(1包X2磅)", - PyJobOrderListMapper.buildDisplayItemNameForLaser("咖哩汁(1包X2磅)", stockUom, "PP1080"), - ) - } + fun buildDisplayItemNameForLaser_omits_appended_uom_for_PP1175() { + assertEquals( + "鮮檸檬汁(P+4)", + PyJobOrderListMapper.buildDisplayItemNameForLaser("鮮檸檬汁(P+4)", stockUom, "PP1175"), + ) + assertEquals( + "咖哩汁(1包X2磅)", + PyJobOrderListMapper.buildDisplayItemNameForLaser("咖哩汁(1包X2磅)", stockUom, "PP1080"), + ) + } + + @Test + fun shelfLifeForItem_computes_expiry_from_print_date() { + val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem( + "f0025", + mapOf("F0025" to ItemPrintShelfLife(365, false)), + LocalDate.of(2026, 8, 20), + ) + assertEquals(365, days) + assertEquals(false, useMinus18) + assertEquals(LocalDate.of(2027, 8, 20), expiry) + } + + @Test + fun shelfLifeForItem_is_null_when_item_missing() { + val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem( + "PP1175", + emptyMap(), + LocalDate.of(2026, 8, 20), + ) + assertEquals(null, days) + assertEquals(null, useMinus18) + assertEquals(null, expiry) + } } From 76742d5fe7c4c1e76405a2c97de718b6b1847db7 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sun, 23 Aug 2026 00:11:02 +0800 Subject: [PATCH 05/13] added for the default expiry date --- .../java/com/ffii/fpsms/config/WebConfig.java | 2 +- .../jobOrder/service/OnPackPp1181Master.kt | 34 +++ .../service/OnPackTemplateFileService.kt | 215 +++++++++++++++++- .../service/PlasticBagPrinterService.kt | 181 ++++++++++++--- .../jobOrder/web/OnPackTemplateController.kt | 37 +++ .../web/PlasticBagPrinterController.kt | 58 ++--- .../web/model/OnPackTemplateModels.kt | 24 ++ .../entity/ItemDefaultShelfLifeRepository.kt | 6 + .../modules/master/entity/ItemsRepository.kt | 1 + .../service/ItemDefaultShelfLifeService.kt | 175 +++++++++++++- .../web/ItemDefaultShelfLifeController.kt | 64 ++++++ .../01_create_onpack_expiry_item_code.sql | 18 ++ .../02_seed_onpack_expiry_item_code_pp.sql | 44 ++++ .../03_add_print_name.sql | 6 + .../service/OnPackPp1181MasterTest.kt | 42 ++++ .../ItemDefaultShelfLifeServiceTest.kt | 22 ++ 16 files changed, 845 insertions(+), 84 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt create mode 100644 src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql create mode 100644 src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql create mode 100644 src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt diff --git a/src/main/java/com/ffii/fpsms/config/WebConfig.java b/src/main/java/com/ffii/fpsms/config/WebConfig.java index dcdf839..5ef870a 100644 --- a/src/main/java/com/ffii/fpsms/config/WebConfig.java +++ b/src/main/java/com/ffii/fpsms/config/WebConfig.java @@ -16,7 +16,7 @@ public class WebConfig implements WebMvcConfigurer { registry.addMapping("/**") .allowedHeaders("*") .allowedOrigins("*") - .exposedHeaders("filename", "Content-Disposition") + .exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry") .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"); } diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt new file mode 100644 index 0000000..029bb6d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt @@ -0,0 +1,34 @@ +package com.ffii.fpsms.modules.jobOrder.service + +/** + * Clone 汁水機 expiry ZIP templates from classpath [MASTER_IMAGE] / [MASTER_JOB]. + * FileName stems `pp1181*` become `{code}*`; `.job` points at the cloned `.image`. + */ +object OnPackPp1181Master { + const val MASTER_CODE = "pp1181" + const val MASTER_IMAGE = "onpack2030_exp/$MASTER_CODE.image" + const val MASTER_IMAGE_FALLBACK = "onpack2030/$MASTER_CODE.image" + const val MASTER_JOB = "onpack2030/$MASTER_CODE.job" + + fun rewriteImageXml(masterXml: String, itemCode: String): String { + val codeLower = itemCode.trim().lowercase() + require(codeLower.isNotEmpty()) { "itemCode is blank" } + return masterXml.replace(Regex("pp1181", RegexOption.IGNORE_CASE), codeLower) + } + + fun rewriteJobXml(masterXml: String, imageFileName: String): String { + val name = imageFileName.trim() + require(name.isNotEmpty()) { "imageFileName is blank" } + return masterXml.replace(Regex("""(?i)pp1181\.image"""), name) + } + + fun rewriteImageBytes(masterBytes: ByteArray, itemCode: String): ByteArray { + val (xml, encodeBack) = OnPackImageTemplateCodec.decode(masterBytes) + return encodeBack(rewriteImageXml(xml, itemCode)) + } + + fun rewriteJobBytes(masterBytes: ByteArray, imageFileName: String): ByteArray { + val (xml, encodeBack) = OnPackImageTemplateCodec.decode(masterBytes) + return encodeBack(rewriteJobXml(xml, imageFileName)) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt index 49e0bcd..2dc7790 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt @@ -3,10 +3,16 @@ package com.ffii.fpsms.modules.jobOrder.service import com.ffii.core.support.JdbcDao import com.ffii.fpsms.modules.jobOrder.entity.OnPackTemplateFile import com.ffii.fpsms.modules.jobOrder.entity.OnPackTemplateFileRepository +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeUpdateRequest import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedCatalogDto import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedItemDto import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateFileDto import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateUploadResponse +import com.ffii.fpsms.modules.master.entity.ItemsRepository +import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService +import com.ffii.fpsms.modules.master.service.ItemUomService +import com.ffii.fpsms.py.PyJobOrderListMapper import org.slf4j.LoggerFactory import org.springframework.core.io.support.PathMatchingResourcePatternResolver import org.springframework.dao.DataAccessException @@ -21,6 +27,9 @@ import java.time.format.DateTimeFormatter open class OnPackTemplateFileService( private val repository: OnPackTemplateFileRepository, private val jdbcDao: JdbcDao, + private val itemsRepository: ItemsRepository, + private val itemUomService: ItemUomService, + private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, ) { private val logger = LoggerFactory.getLogger(javaClass) private val resourceResolver = PathMatchingResourcePatternResolver() @@ -169,11 +178,215 @@ open class OnPackTemplateFileService( ) } + @Transactional(readOnly = true) + open fun listExpiryItemCodes(machineRaw: String?): List { + val machine = normalizeMachine(machineRaw ?: MACHINE_JUICE) + val rows = expiryCodeRows(machine) + return enrichExpiryRows(machine, rows) + } + + /** Uppercase item code → non-blank printName override for Product BMP. */ + @Transactional(readOnly = true) + open fun expiryPrintNames(machine: String = MACHINE_JUICE): Map { + val normalized = try { + normalizeMachine(machine) + } catch (_: IllegalArgumentException) { + MACHINE_JUICE + } + return expiryCodeRows(normalized) + .mapNotNull { (code, printName) -> + val name = printName?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + code to name + } + .toMap() + } + + /** Uppercase item codes allowed in 汁水機 expiry ZIP. Falls back to folder PP* if table is empty/missing. */ + @Transactional(readOnly = true) + open fun expiryItemCodes(machine: String = MACHINE_JUICE): Set { + val normalized = try { + normalizeMachine(machine) + } catch (_: IllegalArgumentException) { + MACHINE_JUICE + } + val fromTable = try { + jdbcDao.queryForStrings( + """ + SELECT DISTINCT UPPER(TRIM(itemCode)) + FROM onpack_expiry_item_code + WHERE deleted = 0 + AND machine = :machine + AND TRIM(itemCode) <> '' + """.trimIndent(), + mapOf("machine" to normalized), + ).mapNotNull { it.trim().uppercase().takeIf { code -> code.isNotEmpty() } }.toSet() + } catch (e: DataAccessException) { + logger.warn("onpack_expiry_item_code is unavailable; using folder PP* list", e) + return if (normalized == MACHINE_JUICE) builtinImageCodes(MACHINE_JUICE) else emptySet() + } + return fromTable + } + + @Transactional + open fun addExpiryItemCode(machineRaw: String?, itemCodeRaw: String): OnPackExpiryItemCodeDto { + val machine = normalizeMachine(machineRaw ?: MACHINE_JUICE) + val itemCode = normalizeItemCode(itemCodeRaw) + val existing = try { + jdbcDao.queryForMap( + """ + SELECT id, deleted FROM onpack_expiry_item_code + WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code + LIMIT 1 + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode), + ) + } catch (e: DataAccessException) { + throw IllegalStateException("無法儲存品號。請重啟後端以執行 Liquibase(建立 onpack_expiry_item_code)。", e) + } + if (existing.isPresent) { + val row = existing.get() + val deleted = (row["deleted"] as? Number)?.toInt() == 1 || row["deleted"] == true + if (deleted) { + jdbcDao.executeUpdate( + """ + UPDATE onpack_expiry_item_code + SET deleted = 0, modified = NOW(), modifiedBy = 'ui' + WHERE id = :id + """.trimIndent(), + mapOf("id" to row["id"]), + ) + } + } else { + jdbcDao.executeUpdate( + """ + INSERT INTO onpack_expiry_item_code + (created, createdBy, version, modified, modifiedBy, deleted, machine, itemCode) + VALUES (NOW(), 'ui', 0, NOW(), 'ui', 0, :machine, :code) + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode), + ) + } + return enrichExpiryRows(machine, listOf(itemCode to loadPrintName(machine, itemCode))).first() + } + + @Transactional + open fun updateExpiryItemCode(body: OnPackExpiryItemCodeUpdateRequest): OnPackExpiryItemCodeDto { + val machine = normalizeMachine(body.machine ?: MACHINE_JUICE) + val itemCode = normalizeItemCode(body.itemCode) + if (body.printName != null) { + val stored = body.printName.trim().takeIf { it.isNotEmpty() }?.take(255) + try { + val n = jdbcDao.executeUpdate( + """ + UPDATE onpack_expiry_item_code + SET printName = :printName, modified = NOW(), modifiedBy = 'ui' + WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code AND deleted = 0 + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode, "printName" to stored), + ) + if (n == 0) { + throw IllegalArgumentException("清單中沒有 $itemCode") + } + } catch (e: DataAccessException) { + throw IllegalStateException("無法儲存列印名稱。請重啟後端以執行 Liquibase(printName 欄位)。", e) + } + } + if (body.useMinus18 != null) { + itemDefaultShelfLifeService.setUseMinus18(itemCode, body.useMinus18) + } + return enrichExpiryRows(machine, listOf(itemCode to loadPrintName(machine, itemCode))).first() + } + + @Transactional + open fun removeExpiryItemCode(machineRaw: String?, itemCodeRaw: String) { + val machine = normalizeMachine(machineRaw ?: MACHINE_JUICE) + val itemCode = normalizeItemCode(itemCodeRaw) + try { + jdbcDao.executeUpdate( + """ + UPDATE onpack_expiry_item_code + SET deleted = 1, modified = NOW(), modifiedBy = 'ui' + WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code AND deleted = 0 + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode), + ) + } catch (e: DataAccessException) { + throw IllegalStateException("無法刪除品號。請重啟後端以執行 Liquibase(建立 onpack_expiry_item_code)。", e) + } + } + + private fun expiryCodeRows(machine: String): List> { + return try { + jdbcDao.queryForList( + """ + SELECT UPPER(TRIM(itemCode)) AS itemCode, printName + FROM onpack_expiry_item_code + WHERE deleted = 0 + AND machine = :machine + AND TRIM(itemCode) <> '' + ORDER BY itemCode + """.trimIndent(), + mapOf("machine" to machine), + ).mapNotNull { row -> + val code = row["itemCode"]?.toString()?.trim()?.uppercase().orEmpty() + if (code.isEmpty()) null + else code to row["printName"]?.toString() + } + } catch (e: DataAccessException) { + logger.warn("onpack_expiry_item_code printName list failed; falling back to codes only", e) + expiryItemCodes(machine).sorted().map { it to null } + } + } + + private fun loadPrintName(machine: String, itemCode: String): String? { + return try { + jdbcDao.queryForString( + """ + SELECT printName FROM onpack_expiry_item_code + WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code AND deleted = 0 + LIMIT 1 + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode), + ).trim().takeIf { it.isNotEmpty() } + } catch (_: DataAccessException) { + null + } + } + + private fun enrichExpiryRows(machine: String, rows: List>): List { + val codes = rows.map { it.first } + val items = if (codes.isEmpty()) emptyMap() + else itemsRepository.findByDeletedFalseAndCodeIn((codes + codes.map { it.lowercase() }).distinct()) + .mapNotNull { item -> + val code = item.code?.trim()?.uppercase().orEmpty() + if (code.isEmpty()) null else code to item + } + .toMap() + val shelf = itemDefaultShelfLifeService.findRowsByItemCodes(codes) + return rows.map { (code, printName) -> + val item = items[code] + val stockDesc = item?.id?.let { itemUomService.findStockUnitByItemId(it)?.uom?.udfudesc } + val defaultPrintName = PyJobOrderListMapper.buildDisplayItemName(item?.name, stockDesc) + val sl = shelf[code] + OnPackExpiryItemCodeDto( + machine = machine, + itemCode = code, + printName = printName?.trim()?.takeIf { it.isNotEmpty() }, + defaultPrintName = defaultPrintName, + defaultDays = sl?.defaultDays, + minus18Days = sl?.minus18Days, + useMinus18 = sl?.useMinus18 == true, + effectiveDays = sl?.let { ItemDefaultShelfLifeService.effectiveDays(it) }, + ) + } + } + private fun supportedItems(machine: String): List { val registered = registeredCodes(machine) val inDatabase = itemCodesWithImage(machine) val builtin = builtinImageCodes(machine) - return mergeSupported(registered, inDatabase, builtin) + val expiry = if (machine == MACHINE_JUICE) expiryItemCodes(MACHINE_JUICE) else emptySet() + return mergeSupported(registered + expiry, inDatabase, builtin) } private fun registeredCodes(machine: String): Set { diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt index 0aa946b..42a5cc1 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt @@ -62,6 +62,11 @@ import java.time.format.DateTimeFormatter // Data class to store bitmap bytes + width (for XML) data class BitmapResult(val bytes: ByteArray, val width: Int) +data class OnPackZipResult( + val bytes: ByteArray, + val skippedWithoutExpiry: List = emptyList(), +) + private data class OnPackBmpExportItem( val codeLower: String, val itemId: Long, @@ -652,18 +657,15 @@ class PlasticBagPrinterService( /** * 汁水機 OnPack: templates under classpath `onpack2030/{code}.image`. * Always swaps LOGO_4 to the generated QR BMP. - * When [includeExpiry] is true, also generates product/code/date/expiry BMPs (`{code}Product.bmp`, - * `{code}Code.bmp`, `{code}Date.bmp` from [printDate] / bagPrint filter, `{code}exp.bmp`). - * When [includeExpiry] is true, writes `{code}exp.bmp` into the template expiry slot - * (`LOGO_5` / `LOGO_EXP` if present, otherwise injects `LOGO_EXP`). Prefers - * `onpack2030_exp/{code}.image` over the main `onpack2030` template. - * Old ZIP callers must pass false; they keep `onpack2030` unchanged. + * When [includeExpiry] is true, clones [OnPackPp1181Master] `.image` / `.job` for each code + * on the UI-managed `onpack_expiry_item_code` list, and generates product/code/date/expiry BMPs. + * Old ZIP callers must pass false; they keep per-code `onpack2030` templates unchanged. */ fun generateOnPackQrZip( jobOrders: List, includeExpiry: Boolean = false, printDate: LocalDate? = null, - ): ByteArray { + ): OnPackZipResult { val normalizedJobOrders = jobOrders .map { OnPackQrJobOrderRequest( @@ -699,6 +701,12 @@ class PlasticBagPrinterService( val packagingJobOrders = normalizedJobOrders.filter { it.jobOrderId in allowedJobOrderIds } require(packagingJobOrders.isNotEmpty()) { "No 包裝 process job orders found for export" } + val expiryPrintNames = if (includeExpiry) { + onPackTemplateFileService.expiryPrintNames(OnPackTemplateFileService.MACHINE_JUICE) + } else { + emptyMap() + } + val exportItemsRaw = packagingJobOrders .groupBy { it.itemCode.trim().lowercase() } .mapNotNull { (codeLower, orders) -> @@ -710,9 +718,11 @@ class PlasticBagPrinterService( val jo = jobOrderRepository.findById(order.jobOrderId).orElse(null) val baseName = jo?.bom?.name ?: stockInLine.item?.name val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc - val productName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc) - ?: codeLower.uppercase() + val computedName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc) val itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase() + val productName = expiryPrintNames[itemCode]?.trim()?.takeIf { it.isNotEmpty() } + ?: computedName + ?: itemCode OnPackBmpExportItem( codeLower, itemId, @@ -726,21 +736,67 @@ class PlasticBagPrinterService( require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } val codesUpper = exportItemsRaw.map { it.itemCode }.toSet() - val allowedBmpCodes = codesOnPackMatchingTemplateType(codesUpper, "bmp") - val exportItems = exportItemsRaw.filter { allowedBmpCodes.contains(it.itemCode) } + val allowedBmpCodes = if (includeExpiry) { + onPackTemplateFileService.expiryItemCodes(OnPackTemplateFileService.MACHINE_JUICE) + } else { + codesOnPackMatchingTemplateType(codesUpper, "bmp") + } + val exportItemsListed = exportItemsRaw.filter { allowedBmpCodes.contains(it.itemCode) } - require(exportItems.isNotEmpty()) { "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" } + require(exportItemsListed.isNotEmpty()) { + if (includeExpiry) { + "當日工單沒有在汁水機到期日 ZIP 品號清單中的項目(OnPack 模板可管理此清單)" + } else { + "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" + } + } val effectivePrintDate = printDate - ?: exportItems.firstNotNullOfOrNull { it.planDate } + ?: exportItemsListed.firstNotNullOfOrNull { it.planDate } ?: ItemDefaultShelfLifeService.today() + val skippedWithoutExpiry = mutableListOf() + val exportItems = if (includeExpiry) { + exportItemsListed.filter { item -> + val label = itemDefaultShelfLifeService.expiryDatePrintLabel(item.itemCode, effectivePrintDate) + if (label.isNullOrBlank()) { + skippedWithoutExpiry += item.itemCode + false + } else { + true + } + } + } else { + exportItemsListed + } + + require(exportItems.isNotEmpty()) { + if (includeExpiry && skippedWithoutExpiry.isNotEmpty()) { + "當日汁水機清單品號都沒有預設保質期,無法產生到期日 ZIP。請到設定 → 物品預設保質期新增:${skippedWithoutExpiry.joinToString("、")}" + } else if (includeExpiry) { + "當日工單沒有在汁水機到期日 ZIP 品號清單中的項目(OnPack 模板可管理此清單)" + } else { + "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" + } + } + + val expiryMasterImage = if (includeExpiry) loadPp1181ExpiryMasterImage() else null + val expiryMasterJob = if (includeExpiry) loadPp1181MasterJob() else null + if (includeExpiry) { + require(expiryMasterImage != null) { "找不到 PP1181 到期日主模板(onpack2030_exp/pp1181.image)" } + } + val baos = ByteArrayOutputStream() ZipOutputStream(baos).use { zos -> val addedEntries = linkedSetOf() exportItems.forEach { item -> val codeLower = item.codeLower - val imageTemplate = loadOnPackImageTemplateOrNull(codeLower, includeExpiry) ?: return@forEach + val imageTemplate = if (includeExpiry) { + val master = expiryMasterImage ?: return@forEach + OnPackPp1181Master.rewriteImageBytes(master, codeLower) + } else { + loadOnPackImageTemplateOrNull(codeLower, forExpiry = false) ?: return@forEach + } val qrContent = """{"itemId": ${item.itemId}, "stockInLineId": ${item.stockInLineId}}""" // Target approximately 470x389 BMP, but with larger visible QR and very little vertical whitespace. @@ -806,21 +862,30 @@ class PlasticBagPrinterService( if (addedEntries.add(imageFileName)) { addToZip(zos, imageFileName, imageContent) } - val decodedXmlForAssets = decodeOnPackImageTemplateForTextEdit(imageContent).first - extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName -> - if (bmpName.equals(qrBmpFileName, ignoreCase = true)) return@forEach - if (bmpName.endsWith("exp.bmp", ignoreCase = true)) return@forEach - if (bmpName.endsWith("Product.bmp", ignoreCase = true)) return@forEach - if (bmpName.endsWith("Code.bmp", ignoreCase = true)) return@forEach - if (bmpName.endsWith("Date.bmp", ignoreCase = true)) return@forEach - if (!addedEntries.add(bmpName)) return@forEach - val bmpBytes = loadJuiceAssetOrNull(bmpName) ?: return@forEach - addToZip(zos, bmpName, bmpBytes) + if (!includeExpiry) { + val decodedXmlForAssets = decodeOnPackImageTemplateForTextEdit(imageContent).first + extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName -> + if (bmpName.equals(qrBmpFileName, ignoreCase = true)) return@forEach + if (bmpName.endsWith("exp.bmp", ignoreCase = true)) return@forEach + if (bmpName.endsWith("Product.bmp", ignoreCase = true)) return@forEach + if (bmpName.endsWith("Code.bmp", ignoreCase = true)) return@forEach + if (bmpName.endsWith("Date.bmp", ignoreCase = true)) return@forEach + if (!addedEntries.add(bmpName)) return@forEach + val bmpBytes = loadJuiceAssetOrNull(bmpName) ?: return@forEach + addToZip(zos, bmpName, bmpBytes) + } } val jobFileName = "${codeLower}.job" - loadJuiceAssetOrNull(jobFileName)?.let { jobBytes -> - if (addedEntries.add(jobFileName)) { - addToZip(zos, jobFileName, jobBytes) + if (includeExpiry) { + val masterJob = expiryMasterJob + if (masterJob != null && addedEntries.add(jobFileName)) { + addToZip(zos, jobFileName, OnPackPp1181Master.rewriteJobBytes(masterJob, imageFileName)) + } + } else { + loadJuiceAssetOrNull(jobFileName)?.let { jobBytes -> + if (addedEntries.add(jobFileName)) { + addToZip(zos, jobFileName, jobBytes) + } } } } @@ -828,7 +893,7 @@ class PlasticBagPrinterService( require(addedEntries.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } } - return baos.toByteArray() + return OnPackZipResult(baos.toByteArray(), skippedWithoutExpiry.distinct()) } /** @@ -840,7 +905,7 @@ class PlasticBagPrinterService( jobOrders: List, includeExpiry: Boolean = false, printDate: LocalDate? = null, - ): ByteArray { + ): OnPackZipResult { val normalizedJobOrders = jobOrders .map { OnPackQrJobOrderRequest( @@ -890,9 +955,34 @@ class PlasticBagPrinterService( val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet() val allowedTextCodes = codesOnPackMatchingTemplateType(codesUpper, "text") - val exportItems = exportItemsRaw.filter { allowedTextCodes.contains(it.first.uppercase()) } + val exportItemsListed = exportItemsRaw.filter { allowedTextCodes.contains(it.first.uppercase()) } + + require(exportItemsListed.isNotEmpty()) { "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" } + + val effectivePrintDate = printDate ?: ItemDefaultShelfLifeService.today() + val skippedWithoutExpiry = mutableListOf() + val exportItems = if (includeExpiry) { + exportItemsListed.filter { (codeLower, _, _) -> + val code = codeLower.uppercase() + val label = itemDefaultShelfLifeService.expiryDatePrintLabel(code, effectivePrintDate) + if (label.isNullOrBlank()) { + skippedWithoutExpiry += code + false + } else { + true + } + } + } else { + exportItemsListed + } - require(exportItems.isNotEmpty()) { "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" } + require(exportItems.isNotEmpty()) { + if (includeExpiry && skippedWithoutExpiry.isNotEmpty()) { + "當日檸檬機品號都沒有預設保質期,無法產生到期日 ZIP。請到設定 → 物品預設保質期新增:${skippedWithoutExpiry.joinToString("、")}" + } else { + "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" + } + } val baos = ByteArrayOutputStream() ZipOutputStream(baos).use { zos -> @@ -904,10 +994,7 @@ class PlasticBagPrinterService( } val imageFileName = "${codeLower.uppercase()}.image" val expiryLabel = if (includeExpiry) { - itemDefaultShelfLifeService.expiryDatePrintLabel( - codeLower, - printDate ?: ItemDefaultShelfLifeService.today(), - ) + itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate) } else { null } @@ -939,7 +1026,7 @@ class PlasticBagPrinterService( } require(addedEntries.isNotEmpty()) { "No OnPack text template files could be generated for the selected date" } } - return baos.toByteArray() + return OnPackZipResult(baos.toByteArray(), skippedWithoutExpiry.distinct()) } /** @@ -955,7 +1042,7 @@ class PlasticBagPrinterService( ) } val zipBytes = try { - generateOnPackQrTextZip(jobOrders) + generateOnPackQrTextZip(jobOrders).bytes } catch (e: Exception) { logger.warn("OnPack text ZIP generation failed before NGPCL push", e) return NgpclPushResponse( @@ -1030,6 +1117,26 @@ class PlasticBagPrinterService( return fromTable + fromDbFiles } + private fun loadPp1181ExpiryMasterImage(): ByteArray? { + listOf(OnPackPp1181Master.MASTER_IMAGE, OnPackPp1181Master.MASTER_IMAGE_FALLBACK).forEach { path -> + val resource = ClassPathResource(path) + if (resource.exists()) { + return resource.inputStream.use { it.readBytes() } + } + } + logger.warn("Missing PP1181 expiry master image on classpath") + return null + } + + private fun loadPp1181MasterJob(): ByteArray? { + val resource = ClassPathResource(OnPackPp1181Master.MASTER_JOB) + if (!resource.exists()) { + logger.warn("Missing PP1181 master job on classpath") + return null + } + return resource.inputStream.use { it.readBytes() } + } + private fun loadOnPackImageTemplateOrNull(codeLower: String, forExpiry: Boolean = false): ByteArray? { onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_JUICE, codeLower)?.let { return it } if (forExpiry) { diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt index c33197e..9ca5741 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt @@ -1,6 +1,9 @@ package com.ffii.fpsms.modules.jobOrder.web import com.ffii.fpsms.modules.jobOrder.service.OnPackTemplateFileService +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeRequest +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeUpdateRequest import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedCatalogDto import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateFileDto import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateUploadResponse @@ -11,6 +14,8 @@ import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -33,6 +38,33 @@ class OnPackTemplateController( return onPackTemplateFileService.supportedCatalog() } + /** Item codes included in 汁水機 OnPack expiry ZIP (dynamic PP1181 template). */ + @GetMapping("/expiry-codes") + fun listExpiryCodes( + @RequestParam(required = false) machine: String?, + ): List { + return onPackTemplateFileService.listExpiryItemCodes(machine) + } + + @PostMapping("/expiry-codes") + fun addExpiryCode(@RequestBody body: OnPackExpiryItemCodeRequest): OnPackExpiryItemCodeDto { + return onPackTemplateFileService.addExpiryItemCode(body.machine, body.itemCode) + } + + @PutMapping("/expiry-codes") + fun updateExpiryCode(@RequestBody body: OnPackExpiryItemCodeUpdateRequest): OnPackExpiryItemCodeDto { + return onPackTemplateFileService.updateExpiryItemCode(body) + } + + @DeleteMapping("/expiry-codes") + fun deleteExpiryCode( + @RequestParam(required = false) machine: String?, + @RequestParam itemCode: String, + ): ResponseEntity { + onPackTemplateFileService.removeExpiryItemCode(machine, itemCode) + return ResponseEntity.noContent().build() + } + @PostMapping fun upload( @RequestParam machine: String, @@ -52,4 +84,9 @@ class OnPackTemplateController( fun badRequest(e: IllegalArgumentException): ResponseEntity> { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("message" to (e.message ?: "Invalid request"))) } + + @ExceptionHandler(IllegalStateException::class) + fun unavailable(e: IllegalStateException): ResponseEntity> { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(mapOf("message" to (e.message ?: "Unavailable"))) + } } diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt index fe880c8..9ffd326 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt @@ -1,6 +1,7 @@ package com.ffii.fpsms.modules.jobOrder.web import com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService +import com.ffii.fpsms.modules.jobOrder.service.OnPackZipResult import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest @@ -119,15 +120,8 @@ class PlasticBagPrinterController( response: HttpServletResponse, ) { try { - val zipBytes = plasticBagPrinterService.generateOnPackQrZip(request.jobOrders) - response.contentType = "application/zip" - response.setHeader( - HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"onpack_qr_codes.zip\"" - ) - response.setContentLength(zipBytes.size) - response.outputStream.write(zipBytes) - response.outputStream.flush() + val zip = plasticBagPrinterService.generateOnPackQrZip(request.jobOrders) + writeOnPackZip(response, "onpack_qr_codes.zip", zip) } catch (e: IllegalArgumentException) { response.status = HttpServletResponse.SC_BAD_REQUEST response.contentType = "text/plain;charset=UTF-8" @@ -151,7 +145,7 @@ class PlasticBagPrinterController( /** * Same 汁水機 ZIP as [downloadOnPackQr], plus expiry BMP from item_default_shelf_life. - * Uses designer `.image` layout (`LOGO_5` / `LOGO_EXP`) when present; otherwise injects LOGO_EXP. + * Clones PP1181 `.image` / `.job` for each code on `onpack_expiry_item_code`. * Old [downloadOnPackQr] is unchanged. */ @PostMapping("/download-onpack-qr-with-expiry") @@ -160,19 +154,12 @@ class PlasticBagPrinterController( response: HttpServletResponse, ) { try { - val zipBytes = plasticBagPrinterService.generateOnPackQrZip( + val zip = plasticBagPrinterService.generateOnPackQrZip( request.jobOrders, includeExpiry = true, printDate = request.planDate, ) - response.contentType = "application/zip" - response.setHeader( - HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"onpack_qr_exp.zip\"" - ) - response.setContentLength(zipBytes.size) - response.outputStream.write(zipBytes) - response.outputStream.flush() + writeOnPackZip(response, "onpack_qr_exp.zip", zip) } catch (e: IllegalArgumentException) { response.status = HttpServletResponse.SC_BAD_REQUEST response.contentType = "text/plain;charset=UTF-8" @@ -201,15 +188,8 @@ class PlasticBagPrinterController( response: HttpServletResponse, ) { try { - val zipBytes = plasticBagPrinterService.generateOnPackQrTextZip(request.jobOrders) - response.contentType = "application/zip" - response.setHeader( - HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"onpack2023_lemon_qr.zip\"" - ) - response.setContentLength(zipBytes.size) - response.outputStream.write(zipBytes) - response.outputStream.flush() + val zip = plasticBagPrinterService.generateOnPackQrTextZip(request.jobOrders) + writeOnPackZip(response, "onpack2023_lemon_qr.zip", zip) } catch (e: IllegalArgumentException) { response.status = HttpServletResponse.SC_BAD_REQUEST response.contentType = "text/plain;charset=UTF-8" @@ -250,19 +230,12 @@ class PlasticBagPrinterController( response: HttpServletResponse, ) { try { - val zipBytes = plasticBagPrinterService.generateOnPackQrTextZip( + val zip = plasticBagPrinterService.generateOnPackQrTextZip( request.jobOrders, includeExpiry = true, printDate = request.planDate, ) - response.contentType = "application/zip" - response.setHeader( - HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"onpack2023_lemon_qr_exp.zip\"" - ) - response.setContentLength(zipBytes.size) - response.outputStream.write(zipBytes) - response.outputStream.flush() + writeOnPackZip(response, "onpack2023_lemon_qr_exp.zip", zip) } catch (e: IllegalArgumentException) { response.status = HttpServletResponse.SC_BAD_REQUEST response.contentType = "text/plain;charset=UTF-8" @@ -405,4 +378,15 @@ class PlasticBagPrinterController( } } + private fun writeOnPackZip(response: HttpServletResponse, filename: String, zip: OnPackZipResult) { + if (zip.skippedWithoutExpiry.isNotEmpty()) { + response.setHeader("X-OnPack-Skipped-Expiry", zip.skippedWithoutExpiry.joinToString(",")) + } + response.contentType = "application/zip" + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"") + response.setContentLength(zip.bytes.size) + response.outputStream.write(zip.bytes) + response.outputStream.flush() + } + } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt index 9bbeef9..9aebf14 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt @@ -27,3 +27,27 @@ data class OnPackSupportedCatalogDto( val juice: List, val lemon: List, ) + +data class OnPackExpiryItemCodeDto( + val machine: String, + val itemCode: String, + val printName: String? = null, + val defaultPrintName: String? = null, + val defaultDays: Int? = null, + val minus18Days: Int? = null, + val useMinus18: Boolean = false, + val effectiveDays: Int? = null, +) + +data class OnPackExpiryItemCodeRequest( + val itemCode: String, + val machine: String? = "juice", +) + +data class OnPackExpiryItemCodeUpdateRequest( + val itemCode: String, + val machine: String? = "juice", + /** Empty string clears the override (use default name + unit). Omitted = leave unchanged. */ + val printName: String? = null, + val useMinus18: Boolean? = null, +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt index 5093a11..2223f3e 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt @@ -9,4 +9,10 @@ interface ItemDefaultShelfLifeRepository : AbstractRepository): List + + fun findByItemCodeIgnoreCase(itemCode: String): ItemDefaultShelfLife? + + fun findByIdAndDeletedFalse(id: Long): ItemDefaultShelfLife? + + fun findAllByDeletedFalseOrderByItemCodeAsc(): List } diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt index da77c0c..0384041 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt @@ -14,6 +14,7 @@ interface ItemsRepository : AbstractRepository { fun findByCodeAndTypeAndDeletedFalse(code: String, type: String): Items?; fun findByCodeAndDeletedFalse(code: String): Items?; + fun findByDeletedFalseAndCodeIn(codes: Collection): List fun findByNameAndDeletedFalse(name: String): Items?; fun findByM18IdAndDeletedIsFalse(m18Id: Long): Items?; diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt index fc0b183..5921423 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt @@ -2,7 +2,13 @@ package com.ffii.fpsms.modules.master.service import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLife import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLifeRepository +import com.ffii.fpsms.modules.master.entity.ItemsRepository +import com.ffii.fpsms.modules.master.web.ItemDefaultShelfLifeRequest +import com.ffii.fpsms.modules.master.web.ItemDefaultShelfLifeRow +import org.springframework.http.HttpStatus import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -17,10 +23,11 @@ data class ItemPrintShelfLife( * Uses [ItemDefaultShelfLife.useMinus18] to pick chilled [defaultDays] vs [minus18Days]. */ @Service -class ItemDefaultShelfLifeService( +open class ItemDefaultShelfLifeService( private val repository: ItemDefaultShelfLifeRepository, + private val itemsRepository: ItemsRepository, ) { - fun printShelfLifeByItemCodes(codes: Collection): Map { + open fun printShelfLifeByItemCodes(codes: Collection): Map { val normalized = codes.mapNotNull { it?.trim()?.uppercase()?.takeIf { code -> code.isNotEmpty() } } .distinct() if (normalized.isEmpty()) return emptyMap() @@ -33,31 +40,154 @@ class ItemDefaultShelfLifeService( .toMap() } - fun defaultDaysByItemCodes(codes: Collection): Map = + open fun defaultDaysByItemCodes(codes: Collection): Map = printShelfLifeByItemCodes(codes).mapValues { it.value.effectiveDays } - fun defaultDays(itemCode: String?): Int? { + open fun defaultDays(itemCode: String?): Int? { val code = itemCode?.trim()?.uppercase().orEmpty() if (code.isEmpty()) return null val row = repository.findByDeletedFalseAndItemCodeIgnoreCase(code) ?: return null return effectiveDays(row) } - fun expiryDate(itemCode: String?, printDate: LocalDate = today()): LocalDate? { + open fun findRowsByItemCodes(codes: Collection): Map { + val normalized = codes.mapNotNull { it?.trim()?.uppercase()?.takeIf { code -> code.isNotEmpty() } } + .distinct() + if (normalized.isEmpty()) return emptyMap() + return repository.findByDeletedFalseAndItemCodeIn(normalized) + .mapNotNull { row -> + val code = row.itemCode?.trim()?.uppercase().orEmpty() + if (code.isEmpty()) null else code to row + } + .toMap() + } + + @Transactional + open fun setUseMinus18(itemCode: String?, useMinus18: Boolean): ItemDefaultShelfLifeRow { + val code = normalizeItemCode(itemCode) + if (code.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Item code is required") + } + val row = repository.findByDeletedFalseAndItemCodeIgnoreCase(code) + ?: throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "找不到 $code 的預設保質期。請先到設定 → 物品預設保質期新增。", + ) + row.useMinus18 = useMinus18 + return toRow(repository.save(row)) + } + + open fun expiryDate(itemCode: String?, printDate: LocalDate = today()): LocalDate? { val days = defaultDays(itemCode) ?: return null return expiryOn(printDate, days) } - fun expiryDateIso(itemCode: String?, printDate: LocalDate = today()): String? = + open fun expiryDateIso(itemCode: String?, printDate: LocalDate = today()): String? = expiryDate(itemCode, printDate)?.toString() - fun expiryDateCompact(itemCode: String?, printDate: LocalDate = today()): String? = + open fun expiryDateCompact(itemCode: String?, printDate: LocalDate = today()): String? = expiryDate(itemCode, printDate)?.format(COMPACT) /** Printed bag wording, e.g. `Expiry Date 20260821`. */ - fun expiryDatePrintLabel(itemCode: String?, printDate: LocalDate = today()): String? = + open fun expiryDatePrintLabel(itemCode: String?, printDate: LocalDate = today()): String? = expiryDate(itemCode, printDate)?.let { formatPrintLabel(it) } + open fun list(q: String? = null): List { + val rows = repository.findAllByDeletedFalseOrderByItemCodeAsc() + val names = itemNamesByCode(rows.mapNotNull { it.itemCode }) + val needle = q?.trim()?.lowercase().orEmpty() + return rows + .map { toRow(it, names[it.itemCode?.trim()?.uppercase().orEmpty()]) } + .filter { row -> + if (needle.isEmpty()) true + else row.itemCode.lowercase().contains(needle) || + row.itemName.orEmpty().lowercase().contains(needle) || + row.remarks.orEmpty().lowercase().contains(needle) + } + } + + @Transactional + open fun create(request: ItemDefaultShelfLifeRequest): ItemDefaultShelfLifeRow { + val code = normalizeItemCode(request.itemCode) + validateRequest(request, code) + val existing = repository.findByItemCodeIgnoreCase(code) + if (existing != null && existing.deleted != true) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Item code already exists: $code") + } + val row = existing ?: ItemDefaultShelfLife() + applyRequest(row, request, code) + row.deleted = false + return toRow(repository.save(row)) + } + + @Transactional + open fun update(id: Long, request: ItemDefaultShelfLifeRequest): ItemDefaultShelfLifeRow { + val row = repository.findByIdAndDeletedFalse(id) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Shelf life row $id not found") + val code = normalizeItemCode(request.itemCode) + validateRequest(request, code) + val other = repository.findByItemCodeIgnoreCase(code) + if (other != null && other.id != id) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Item code already exists: $code") + } + applyRequest(row, request, code) + return toRow(repository.save(row)) + } + + @Transactional + open fun markDeleted(id: Long): List { + val row = repository.findByIdAndDeletedFalse(id) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Shelf life row $id not found") + row.deleted = true + repository.save(row) + return list() + } + + private fun applyRequest(row: ItemDefaultShelfLife, request: ItemDefaultShelfLifeRequest, code: String) { + row.itemCode = code + row.defaultDays = request.defaultDays + row.minus18Days = request.minus18Days + row.useMinus18 = request.useMinus18 == true + row.openedDays = request.openedDays + row.storageC = request.storageC?.trim()?.takeIf { it.isNotEmpty() } + row.remarks = request.remarks?.trim()?.takeIf { it.isNotEmpty() } + } + + private fun toRow(row: ItemDefaultShelfLife, itemName: String? = null): ItemDefaultShelfLifeRow { + val code = row.itemCode?.trim().orEmpty() + val name = itemName ?: itemNameFor(code) + return ItemDefaultShelfLifeRow( + id = row.id ?: 0L, + itemCode = code, + itemName = name, + defaultDays = row.defaultDays, + minus18Days = row.minus18Days, + useMinus18 = row.useMinus18 == true, + openedDays = row.openedDays, + storageC = row.storageC, + remarks = row.remarks, + effectiveDays = effectiveDays(row), + ) + } + + private fun itemNameFor(code: String): String? { + if (code.isEmpty()) return null + return itemsRepository.findByCodeAndDeletedFalse(code)?.name?.trim()?.takeIf { it.isNotEmpty() } + } + + private fun itemNamesByCode(codes: Collection): Map { + val raw = codes.mapNotNull { it.trim().takeIf { c -> c.isNotEmpty() } }.distinct() + if (raw.isEmpty()) return emptyMap() + val lookup = (raw + raw.map { it.uppercase() }).distinct() + return itemsRepository.findByDeletedFalseAndCodeIn(lookup) + .mapNotNull { item -> + val code = item.code?.trim()?.uppercase().orEmpty() + val name = item.name?.trim()?.takeIf { it.isNotEmpty() } + if (code.isEmpty() || name == null) null else code to name + } + .toMap() + } + companion object { val PRINT_ZONE: ZoneId = ZoneId.of("Asia/Hong_Kong") private val COMPACT: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE @@ -79,5 +209,34 @@ class ItemDefaultShelfLifeService( val chosen = if (useMinus18) minus18Days else defaultDays return chosen?.takeIf { it > 0 } } + + fun normalizeItemCode(raw: String?): String = + raw?.trim()?.uppercase().orEmpty() + + fun validateRequest(request: ItemDefaultShelfLifeRequest, code: String) { + if (code.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Item code is required") + } + if (code.length > 50) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Item code must be at most 50 characters") + } + requireDays("defaultDays", request.defaultDays) + requireDays("minus18Days", request.minus18Days) + requireDays("openedDays", request.openedDays) + val storage = request.storageC?.trim().orEmpty() + if (storage.length > 20) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "storageC must be at most 20 characters") + } + val remarks = request.remarks?.trim().orEmpty() + if (remarks.length > 255) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "remarks must be at most 255 characters") + } + } + + private fun requireDays(field: String, value: Int?) { + if (value != null && value < 0) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$field must be 0 or greater") + } + } } } diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt new file mode 100644 index 0000000..1fa17f4 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt @@ -0,0 +1,64 @@ +package com.ffii.fpsms.modules.master.web + +import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RequestMapping("itemDefaultShelfLives") +@RestController +class ItemDefaultShelfLifeController( + private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, +) { + @GetMapping + fun list(@RequestParam(required = false) q: String?): List { + return itemDefaultShelfLifeService.list(q) + } + + @PostMapping + fun create(@RequestBody request: ItemDefaultShelfLifeRequest): ItemDefaultShelfLifeRow { + return itemDefaultShelfLifeService.create(request) + } + + @PutMapping("/{id}") + fun update( + @PathVariable id: Long, + @RequestBody request: ItemDefaultShelfLifeRequest, + ): ItemDefaultShelfLifeRow { + return itemDefaultShelfLifeService.update(id, request) + } + + @DeleteMapping("/{id}") + fun delete(@PathVariable id: Long): List { + return itemDefaultShelfLifeService.markDeleted(id) + } +} + +data class ItemDefaultShelfLifeRequest( + val itemCode: String? = null, + val defaultDays: Int? = null, + val minus18Days: Int? = null, + val useMinus18: Boolean? = false, + val openedDays: Int? = null, + val storageC: String? = null, + val remarks: String? = null, +) + +data class ItemDefaultShelfLifeRow( + val id: Long, + val itemCode: String, + val itemName: String? = null, + val defaultDays: Int? = null, + val minus18Days: Int? = null, + val useMinus18: Boolean = false, + val openedDays: Int? = null, + val storageC: String? = null, + val remarks: String? = null, + val effectiveDays: Int? = null, +) diff --git a/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql new file mode 100644 index 0000000..5290ab3 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql @@ -0,0 +1,18 @@ +--liquibase formatted sql + +--changeset fpsms:create_onpack_expiry_item_code +--comment: UI-managed item codes for 汁水機 OnPack expiry ZIP (dynamic PP1181 template) +CREATE TABLE `onpack_expiry_item_code` +( + `id` INT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + `machine` VARCHAR(20) NOT NULL DEFAULT 'juice' COMMENT 'juice=汁水機 expiry ZIP', + `itemCode` VARCHAR(50) NOT NULL, + CONSTRAINT pk_onpack_expiry_item_code PRIMARY KEY (`id`), + UNIQUE KEY uk_onpack_expiry_item_code (`machine`, `itemCode`) +); diff --git a/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql new file mode 100644 index 0000000..e2b0df2 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql @@ -0,0 +1,44 @@ +--liquibase formatted sql + +--changeset fpsms:seed_onpack_expiry_item_code_pp +--comment: Seed juice expiry ZIP list from onpack2030 folder PP*.image +INSERT INTO `onpack_expiry_item_code` +(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`, `machine`, `itemCode`) +VALUES +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1041'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1074'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1078'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1080'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1082'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1088'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1117'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1118'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1126'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1136'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1137'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1144'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1148'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1152'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1156'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1178'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1180'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1181'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1185'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1209'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1213'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1214'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1216'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1217'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1234'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2211'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2214'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2215'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2243'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2248'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2250'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2262'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2282'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2317'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2331'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2341'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2404'); diff --git a/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql new file mode 100644 index 0000000..d179594 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql @@ -0,0 +1,6 @@ +--liquibase formatted sql + +--changeset fpsms:onpack_expiry_item_code_print_name +--comment: Editable OnPack Product BMP name (Chinese name + unit); blank uses items + stock UOM +ALTER TABLE `onpack_expiry_item_code` + ADD COLUMN `printName` VARCHAR(255) NULL COMMENT 'OnPack Product line; overrides items name + unit' AFTER `itemCode`; diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt new file mode 100644 index 0000000..8b6c9f6 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt @@ -0,0 +1,42 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.core.io.ClassPathResource + +class OnPackPp1181MasterTest { + + @Test + fun rewriteImageXml_swaps_pp1181_filenames() { + val xml = """pp1181Product.bmppp1181qr.bmp""" + val out = OnPackPp1181Master.rewriteImageXml(xml, "PP1041") + assertTrue(out.contains("pp1041Product.bmp")) + assertTrue(out.contains("pp1041qr.bmp")) + assertFalse(out.contains("pp1181")) + } + + @Test + fun rewriteJobXml_points_at_cloned_image() { + val out = OnPackPp1181Master.rewriteJobXml( + "PP1181.image", + "pp1041.image", + ) + assertEquals("pp1041.image", out) + } + + @Test + fun classpath_exp_master_rewrites_all_five_slots() { + val resource = ClassPathResource(OnPackPp1181Master.MASTER_IMAGE) + assertTrue(resource.exists(), "missing ${OnPackPp1181Master.MASTER_IMAGE}") + val bytes = OnPackPp1181Master.rewriteImageBytes(resource.inputStream.use { it.readBytes() }, "PP2404") + val (xml, _) = OnPackImageTemplateCodec.decode(bytes) + assertTrue(xml.contains("pp2404Product.bmp")) + assertTrue(xml.contains("pp2404Code.bmp")) + assertTrue(xml.contains("pp2404Date.bmp")) + assertTrue(xml.contains("pp2404qr.bmp")) + assertTrue(xml.contains("pp2404exp.bmp")) + assertFalse(xml.contains("pp1181", ignoreCase = true)) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt index 8accc86..98d2cc0 100644 --- a/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt +++ b/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt @@ -1,7 +1,10 @@ package com.ffii.fpsms.modules.master.service +import com.ffii.fpsms.modules.master.web.ItemDefaultShelfLifeRequest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test +import org.springframework.web.server.ResponseStatusException import java.time.LocalDate class ItemDefaultShelfLifeServiceTest { @@ -42,4 +45,23 @@ class ItemDefaultShelfLifeServiceTest { ItemDefaultShelfLifeService.formatProductionDatePrintLabel(LocalDate.of(2026, 8, 21)), ) } + + @Test + fun normalizeItemCode_trims_and_uppercases() { + assertEquals("F0013", ItemDefaultShelfLifeService.normalizeItemCode(" f0013 ")) + assertEquals("", ItemDefaultShelfLifeService.normalizeItemCode(" ")) + } + + @Test + fun validateRequest_rejects_blank_code_and_negative_days() { + assertThrows(ResponseStatusException::class.java) { + ItemDefaultShelfLifeService.validateRequest(ItemDefaultShelfLifeRequest(), "") + } + assertThrows(ResponseStatusException::class.java) { + ItemDefaultShelfLifeService.validateRequest( + ItemDefaultShelfLifeRequest(itemCode = "F0013", defaultDays = -1), + "F0013", + ) + } + } } From 112adad86329bd44f89eeef17f404e4689a0840c Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sun, 23 Aug 2026 13:20:10 +0800 Subject: [PATCH 06/13] fix for exp date --- python/Bag4.py | 36 ++++++++---- .../service/PlasticBagPrinterService.kt | 56 ++++++++++++------- .../web/PlasticBagPrinterController.kt | 12 +++- .../model/FlexibleExpiryDateDeserializer.kt | 33 +++++++++++ .../web/model/LaserBag2SendRequest.kt | 8 ++- .../jobOrder/service/LaserBag2PayloadTest.kt | 28 ++++++++++ 6 files changed, 140 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/FlexibleExpiryDateDeserializer.kt diff --git a/python/Bag4.py b/python/Bag4.py index b5f924f..388d64d 100644 --- a/python/Bag4.py +++ b/python/Bag4.py @@ -3,7 +3,8 @@ Bag4 v4.0 – FPSMS job orders by plan date, with DataFlex expiry under Lot. Uses the public API GET /py/job-orders and POST /py/job-order-print-submit (no login required). -Same as Bag3, plus expiry (today + defaultShelfLifeDays) on DataFlex ZPL and as the 4th laser TCP param. +Same as Bag3, plus expiry (today + defaultShelfLifeDays) on DataFlex ZPL +and as the 4th laser TCP param (EZCAD job must bind that field). Bag3 remains the line without expiry on DataFlex; do not share settings files. @@ -434,6 +435,26 @@ def format_expiry_print_label(d: date) -> str: return f"Expiry Date {d.strftime('%Y%m%d')}" +def _parse_api_expiry_date(raw) -> Optional[date]: + """ISO `yyyy-MM-dd` or Jackson date-array `[yyyy,M,d]` from @EnableWebMvc.""" + if raw is None or raw == "": + return None + if isinstance(raw, (list, tuple)) and len(raw) >= 3: + try: + return date(int(raw[0]), int(raw[1]), int(raw[2])) + except (TypeError, ValueError): + return None + s = str(raw).strip() + if s.startswith("[") and "," in s: + return None + iso = s[:10] + try: + y, m, day = (int(p) for p in iso.split("-")) + return date(y, m, day) + except ValueError: + return None + + def job_expiry_zpl_text(jo: dict) -> Optional[str]: """Expiry yyyyMMdd from print-time shelf life days, else API expiryDate.""" days = jo.get("defaultShelfLifeDays") @@ -443,15 +464,8 @@ def job_expiry_zpl_text(jo: dict) -> Optional[str]: days = int(days) if isinstance(days, int) and days > 0: return format_expiry_print_label(date.today() + timedelta(days=days)) - iso = jo.get("expiryDate") - if iso: - s = str(iso).strip()[:10] - try: - y, m, day = (int(p) for p in s.split("-")) - return format_expiry_print_label(date(y, m, day)) - except ValueError: - return None - return None + parsed = _parse_api_expiry_date(jo.get("expiryDate")) + return format_expiry_print_label(parsed) if parsed else None def job_expiry_laser_param(jo: dict) -> str: @@ -1251,7 +1265,7 @@ def send_job_to_laser( {"itemId": itemId, "stockInLineId": stockInLineId} ; itemCode ; itemName ; [Expiry Date yyyyMMdd] ;; conn_ref: [socket or None] - reused across calls; closed only when switching printer. When both item_id and stock_in_line_id present, sends JSON first param; else fallback: 0;item_code;item_name;; - Expiry is the 4th field when present (same wording as DataFlex). + Expiry is the 4th field when present (same wording as DataFlex). The EZCAD job must bind it. Returns (success, message). """ code_str = (item_code or "").strip().replace(";", ",") diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt index 42a5cc1..862c8be 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt @@ -120,9 +120,9 @@ class PlasticBagPrinterService( private const val ONPACK_EXPIRY_BMP_HEIGHT = 79 /** - * Bag2/Bag4 laser TCP payload. When [expiryDate] is present: - * `json;itemCode;itemName;Expiry Date yyyyMMdd;;` - * otherwise the original 3-field form `json;itemCode;itemName;;`. + * Laser TCP: `json;itemCode;itemName;;` or, when [expiryDate] is set, + * `json;itemCode;itemName;Expiry Date yyyyMMdd;;`. + * The lemon EZCAD job must bind a 4th text param or it replies invalid. */ fun buildLaserBag2Payload( itemId: Long?, @@ -146,6 +146,9 @@ class PlasticBagPrinterService( } } + fun laserAckLooksInvalid(ack: String?): Boolean = + ack?.contains("invalid", ignoreCase = true) == true + /** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */ fun formatLaserExpiryParam(expiryDate: String?): String { val raw = expiryDate?.trim().orEmpty() @@ -286,6 +289,18 @@ class PlasticBagPrinterService( * Bag2.py [send_job_to_laser] / [send_job_to_laser_with_retry]: UTF-8 TCP payload and optional ack read. */ fun sendLaserBag2Job(request: LaserBag2SendRequest): LaserBag2SendResponse { + return try { + sendLaserBag2JobInner(request) + } catch (e: Exception) { + logger.error("sendLaserBag2Job unexpected error", e) + LaserBag2SendResponse( + success = false, + message = "送出失敗:${e.message ?: e.javaClass.simpleName}", + ) + } + } + + private fun sendLaserBag2JobInner(request: LaserBag2SendRequest): LaserBag2SendResponse { val ip = (request.printerIp?.trim()?.takeIf { it.isNotEmpty() } ?: resolveLaserBag2Host()).trim() val port = request.printerPort ?: resolveLaserBag2Port() @@ -298,16 +313,10 @@ class PlasticBagPrinterService( itemName = request.itemName, expiryDate = request.expiryDate, ) - val response = if (first.success) { - LaserBag2SendResponse( - success = true, - message = first.message, - payloadSent = first.payload, - printerAck = first.printerAck, - receiveAcknowledged = first.receiveAcknowledged, - ) + val chosen = if (first.success) { + first } else { - val second = sendLaserBag2TcpOnce( + sendLaserBag2TcpOnce( ip = ip, port = port, itemId = request.itemId, @@ -316,14 +325,14 @@ class PlasticBagPrinterService( itemName = request.itemName, expiryDate = request.expiryDate, ) - LaserBag2SendResponse( - success = second.success, - message = second.message, - payloadSent = second.payload, - printerAck = second.printerAck, - receiveAcknowledged = second.receiveAcknowledged, - ) } + val response = LaserBag2SendResponse( + success = chosen.success, + message = chosen.message, + payloadSent = chosen.payload, + printerAck = chosen.printerAck, + receiveAcknowledged = chosen.receiveAcknowledged, + ) if (response.success && response.receiveAcknowledged) { try { persistLaserLastReceiveSuccess(request, response.printerAck) @@ -436,6 +445,15 @@ class PlasticBagPrinterService( } catch (_: SocketTimeoutException) { // Same as Python Bag3: ignore read timeout, payload was still sent } + if (laserAckLooksInvalid(ackRaw)) { + return LaserBag2TcpResult( + false, + "檸檬機回覆 invalid,未接受指令。", + payload, + ackRaw, + false, + ) + } val msg = if (receiveAck) { "已送出激光機:$payload(已確認)" } else { diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt index 9ffd326..e8c51e1 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt @@ -61,12 +61,20 @@ class PlasticBagPrinterController( } /** - * Bag2/Bag4 laser TCP protocol: `{"itemId":n,"stockInLineId":m};code;name;;` or + * Bag2/Bag4 laser TCP: `{"itemId":n,"stockInLineId":m};code;name;;` or * `{"itemId":n,"stockInLineId":m};code;name;Expiry Date yyyyMMdd;;` when expiryDate is set. */ @PostMapping("/print-laser-bag2") fun printLaserBag2(@RequestBody request: LaserBag2SendRequest): ResponseEntity { - val resp = plasticBagPrinterService.sendLaserBag2Job(request) + val resp = try { + plasticBagPrinterService.sendLaserBag2Job(request) + } catch (e: Exception) { + logger.error("print-laser-bag2 failed", e) + LaserBag2SendResponse( + success = false, + message = "送出失敗:${e.message ?: e.javaClass.simpleName}", + ) + } return if (resp.success) { ResponseEntity.ok(resp) } else { diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/FlexibleExpiryDateDeserializer.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/FlexibleExpiryDateDeserializer.kt new file mode 100644 index 0000000..503aff0 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/FlexibleExpiryDateDeserializer.kt @@ -0,0 +1,33 @@ +package com.ffii.fpsms.modules.jobOrder.web.model + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer + +/** + * Accepts `yyyy-MM-dd`, compact `yyyyMMdd`, `Expiry Date yyyyMMdd`, + * or Jackson date-array `[2026,8,27]` (happens when `@EnableWebMvc` uses a raw ObjectMapper). + */ +class FlexibleExpiryDateDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): String? { + return when (p.currentToken) { + JsonToken.VALUE_NULL -> null + JsonToken.VALUE_STRING -> p.valueAsString?.trim()?.takeIf { it.isNotEmpty() } + JsonToken.VALUE_NUMBER_INT -> p.valueAsString + JsonToken.START_ARRAY -> { + val y = p.nextIntValue(0) + val m = p.nextIntValue(0) + val d = p.nextIntValue(0) + while (p.nextToken() != JsonToken.END_ARRAY && p.currentToken != null) { + // skip extra tokens (e.g. nanoseconds on a datetime array) + } + if (y <= 0 || m <= 0 || d <= 0) null else "%04d-%02d-%02d".format(y, m, d) + } + else -> { + p.skipChildren() + null + } + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt index 370b957..c4d30e0 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt @@ -1,13 +1,18 @@ package com.ffii.fpsms.modules.jobOrder.web.model +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.databind.annotation.JsonDeserialize + /** * Body for Bag2/Bag4-style laser TCP send: `json;itemCode;itemName;;` or * `json;itemCode;itemName;Expiry Date yyyyMMdd;;` when [expiryDate] is set (UTF-8). + * The lemon EZCAD job must bind the 4th `;` field or it replies invalid. * Optional [printerIp] / [printerPort] override system settings [LASER_PRINT.host] / [LASER_PRINT.port]. * * Optional job metadata is used to persist [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS] * when the printer returns a receive ack. */ +@JsonIgnoreProperties(ignoreUnknown = true) data class LaserBag2SendRequest( val itemId: Long? = null, val stockInLineId: Long? = null, @@ -20,8 +25,9 @@ data class LaserBag2SendRequest( val lotNo: String? = null, /** * Print-time expiry from the job-order list (`yyyy-MM-dd`, compact `yyyyMMdd`, - * or already `Expiry Date yyyyMMdd`). Sent as the 4th TCP field. + * Jackson `[yyyy,M,d]`, or already `Expiry Date yyyyMMdd`). Sent as the 4th TCP field. */ + @JsonDeserialize(using = FlexibleExpiryDateDeserializer::class) val expiryDate: String? = null, /** AUTO (auto-send) or MANUAL (/laserPrint); optional. */ val source: String? = null, diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt index 376fb3d..fe9ce57 100644 --- a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt @@ -1,6 +1,9 @@ package com.ffii.fpsms.modules.jobOrder.service +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendRequest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test class LaserBag2PayloadTest { @@ -25,6 +28,31 @@ class LaserBag2PayloadTest { ) } + @Test + fun laserAckLooksInvalid_detects_plugin_reject() { + assertEquals(false, PlasticBagPrinterService.laserAckLooksInvalid(null)) + assertEquals(false, PlasticBagPrinterService.laserAckLooksInvalid("receive;;")) + assertEquals(true, PlasticBagPrinterService.laserAckLooksInvalid("errorinvalid data")) + assertEquals(true, PlasticBagPrinterService.laserAckLooksInvalid("INVALID")) + } + + @Test + fun sendRequest_accepts_expiry_as_iso_or_jackson_array() { + val mapper = jacksonObjectMapper() + val fromIso = mapper.readValue( + """{"expiryDate":"2026-08-27","source":"MANUAL"}""", + LaserBag2SendRequest::class.java, + ) + assertEquals("2026-08-27", fromIso.expiryDate) + val fromArray = mapper.readValue( + """{"expiryDate":[2026,8,27],"source":"MANUAL"}""", + LaserBag2SendRequest::class.java, + ) + assertEquals("2026-08-27", fromArray.expiryDate) + val missing = mapper.readValue("{}", LaserBag2SendRequest::class.java) + assertNull(missing.expiryDate) + } + @Test fun formatLaserExpiryParam_accepts_iso_compact_and_print_label() { assertEquals("Expiry Date 20260821", PlasticBagPrinterService.formatLaserExpiryParam("2026-08-21")) From 1b9a7219f8af6ae1c3bc6775843ef0167d910c1c Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sun, 23 Aug 2026 15:00:17 +0800 Subject: [PATCH 07/13] no message --- python/Bag4.py | 88 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/python/Bag4.py b/python/Bag4.py index 388d64d..8ad51cc 100644 --- a/python/Bag4.py +++ b/python/Bag4.py @@ -4,7 +4,7 @@ Bag4 v4.0 – FPSMS job orders by plan date, with DataFlex expiry under Lot. Uses the public API GET /py/job-orders and POST /py/job-order-print-submit (no login required). Same as Bag3, plus expiry (today + defaultShelfLifeDays) on DataFlex ZPL -and as the 4th laser TCP param (EZCAD job must bind that field). +and as the 4th laser TCP param, always printed as `Expiry Date 20260826`. Bag3 remains the line without expiry on DataFlex; do not share settings files. @@ -430,33 +430,63 @@ def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None: raise +EXPIRY_PRINT_PREFIX = "Expiry Date " +EXPIRY_LABEL_PREFIX = "Expiry " + + def format_expiry_print_label(d: date) -> str: - """Printed bag wording, e.g. Expiry Date 20260821.""" - return f"Expiry Date {d.strftime('%Y%m%d')}" + """DataFlex / laser wording: `Expiry Date 20260826`.""" + return f"{EXPIRY_PRINT_PREFIX}{d.strftime('%Y%m%d')}" + + +def format_expiry_label_compact(d: date) -> str: + """標簽機 wording (narrow): `Expiry 20260826`.""" + return f"{EXPIRY_LABEL_PREFIX}{d.strftime('%Y%m%d')}" def _parse_api_expiry_date(raw) -> Optional[date]: - """ISO `yyyy-MM-dd` or Jackson date-array `[yyyy,M,d]` from @EnableWebMvc.""" + """Accept ISO `yyyy-MM-dd`, compact `yyyyMMdd`, `Expiry Date yyyyMMdd`, or `[yyyy,M,d]`.""" if raw is None or raw == "": return None + if isinstance(raw, datetime): + return raw.date() + if isinstance(raw, date): + return raw if isinstance(raw, (list, tuple)) and len(raw) >= 3: try: return date(int(raw[0]), int(raw[1]), int(raw[2])) except (TypeError, ValueError): return None - s = str(raw).strip() - if s.startswith("[") and "," in s: - return None - iso = s[:10] - try: - y, m, day = (int(p) for p in iso.split("-")) - return date(y, m, day) - except ValueError: + s = str(raw).strip().replace(";", ",") + if not s: return None + if s.lower().startswith(EXPIRY_PRINT_PREFIX.lower()): + s = s[len(EXPIRY_PRINT_PREFIX):].strip() + elif s.lower().startswith(EXPIRY_LABEL_PREFIX.lower()): + s = s[len(EXPIRY_LABEL_PREFIX):].strip() + digits = "".join(ch for ch in s if ch.isdigit()) + if len(digits) >= 8: + try: + return date(int(digits[0:4]), int(digits[4:6]), int(digits[6:8])) + except ValueError: + return None + return None + + +def standardize_expiry_print_label(raw) -> str: + """Normalize any expiry input to `Expiry Date 20260826`, or empty if unknown.""" + parsed = _parse_api_expiry_date(raw) + return format_expiry_print_label(parsed) if parsed else "" + + +def standardize_expiry_label_compact(raw) -> str: + """Normalize any expiry input to `Expiry 20260826` for 標簽機, or empty if unknown.""" + parsed = _parse_api_expiry_date(raw) + return format_expiry_label_compact(parsed) if parsed else "" def job_expiry_zpl_text(jo: dict) -> Optional[str]: - """Expiry yyyyMMdd from print-time shelf life days, else API expiryDate.""" + """Expiry as `Expiry Date yyyyMMdd` from shelf-life days, else API expiryDate.""" days = jo.get("defaultShelfLifeDays") if isinstance(days, bool): days = None @@ -464,12 +494,12 @@ def job_expiry_zpl_text(jo: dict) -> Optional[str]: days = int(days) if isinstance(days, int) and days > 0: return format_expiry_print_label(date.today() + timedelta(days=days)) - parsed = _parse_api_expiry_date(jo.get("expiryDate")) - return format_expiry_print_label(parsed) if parsed else None + label = standardize_expiry_print_label(jo.get("expiryDate")) + return label or None def job_expiry_laser_param(jo: dict) -> str: - """Same expiry as DataFlex ZPL, sanitized for `;`-separated laser TCP.""" + """Same `Expiry Date yyyyMMdd` as DataFlex, sanitized for `;`-separated laser TCP.""" return (job_expiry_zpl_text(jo) or "").replace(";", ",") @@ -496,7 +526,7 @@ def generate_zpl_dataflex( code = _zpl_escape((item_code or "—").strip()) label_line = (lot_no or batch_no or "").strip() label_esc = _zpl_escape(label_line) - exp_raw = (expiry_text or "").strip() + exp_raw = standardize_expiry_print_label(expiry_text) exp_esc = _zpl_escape(exp_raw) if exp_raw else "" # QR payload: prefer JSON {"itemId":..., "stockInLineId":...} when both present; else fall back to lot/batch text if item_id is not None and stock_in_line_id is not None: @@ -651,10 +681,12 @@ def generate_zpl_label_small( item_id: Optional[int] = None, stock_in_line_id: Optional[int] = None, lot_no: Optional[str] = None, + expiry_text: Optional[str] = None, font: str = "MingLiUHKSCS", ) -> str: """ - ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot no (or batch) right. + ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot + expiry right. + Expiry is always `Expiry yyyyMMdd` when present (narrow label). QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else batch_no. Unicode (^CI28); font set for Big-5 (e.g. MingLiUHKSCS). """ @@ -662,10 +694,14 @@ def generate_zpl_label_small( code = _zpl_escape((item_code or "—").strip()) label_line2 = (lot_no or batch_no or "—").strip() label_line2_esc = _zpl_escape(label_line2) + exp_esc = _zpl_escape(standardize_expiry_label_compact(expiry_text)) if item_id is not None and stock_in_line_id is not None: qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})) else: qr_data = f"QA,{batch_no}" + exp_zpl = f""" +^FO150,230 +^A@N,36,36,{font}^FD{exp_esc}^FS""" if exp_esc else "" return f"""^XA ^CI28 ^PW500 @@ -678,7 +714,7 @@ def generate_zpl_label_small( ^FO150,110 ^A@N,48,48,{font}^FD{code}^FS ^FO150,175 -^A@N,40,40,{font}^FD{label_line2_esc}^FS +^A@N,40,40,{font}^FD{label_line2_esc}^FS{exp_zpl} ^XZ""" @@ -690,6 +726,7 @@ LABEL_PADDING = 23 LABEL_FONT_NAME_SIZE = 42 LABEL_FONT_CODE_SIZE = 49 LABEL_FONT_BATCH_SIZE = 34 +LABEL_FONT_EXPIRY_SIZE = 34 LABEL_QR_SIZE = 210 @@ -745,9 +782,11 @@ def render_label_to_image( item_id: Optional[int] = None, stock_in_line_id: Optional[int] = None, lot_no: Optional[str] = None, + expiry_text: Optional[str] = None, ) -> "Image.Image": """ Render 標簽機 label as a PIL Image (white bg, black text + QR). + Lot line is followed by `Expiry yyyyMMdd` when [expiry_text] is set. Use this image for printing so Chinese displays correctly; words are drawn bigger. Requires Pillow and qrcode. Raises RuntimeError if not available. """ @@ -772,6 +811,7 @@ def render_label_to_image( font_name = _get_chinese_font(LABEL_FONT_NAME_SIZE) font_code = _get_chinese_font(LABEL_FONT_CODE_SIZE) font_batch = _get_chinese_font(LABEL_FONT_BATCH_SIZE) + font_expiry = _get_chinese_font(LABEL_FONT_EXPIRY_SIZE) x_right = LABEL_PADDING + LABEL_QR_SIZE + LABEL_PADDING y_line = LABEL_PADDING # Line 1: item name (wrap within remaining width) @@ -852,6 +892,13 @@ def render_label_to_image( draw.text((x_right, y_line), batch_str, font=font_batch, fill="black") else: draw.text((x_right, y_line), batch_str, fill="black") + y_line += LABEL_FONT_BATCH_SIZE + 6 + exp_str = standardize_expiry_label_compact(expiry_text) + if exp_str: + if font_expiry: + draw.text((x_right, y_line), exp_str, font=font_expiry, fill="black") + else: + draw.text((x_right, y_line), exp_str, fill="black") return img @@ -1270,7 +1317,7 @@ def send_job_to_laser( """ code_str = (item_code or "").strip().replace(";", ",") name_str = (item_name or "").strip().replace(";", ",") - exp_str = (expiry_text or "").strip().replace(";", ",") + exp_str = standardize_expiry_print_label(expiry_text).replace(";", ",") if item_id is not None and stock_in_line_id is not None: # Use compact JSON so device-side parser doesn't get spaces. @@ -2905,6 +2952,7 @@ def main() -> None: b, item_code, item_name, item_id=item_id, stock_in_line_id=stock_in_line_id, lot_no=lot_no, + expiry_text=job_expiry_zpl_text(j), ) zpl_img = _image_to_zpl_gfa(label_img) run_label_print_batch_thread( From 7824743466f3a326d7d53ce9d806bb8d4b86dbd2 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 25 Aug 2026 22:25:16 +0800 Subject: [PATCH 08/13] do assign won't update pick order.status from compelted to released --- .../ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt index 12462c3..1ad668a 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt @@ -159,6 +159,7 @@ fun findCompletedWithPlasticBoxCartonQtyInPlanStartRange( ): List +/** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.2 | 2026-08-25 */ @Modifying(clearAutomatically = true, flushAutomatically = true) @Query( value = """ @@ -169,6 +170,7 @@ fun findCompletedWithPlasticBoxCartonQtyInPlanStartRange( modifiedBy = :modifiedBy WHERE deliveryOrderPickOrderId = :dopoId AND deleted = 0 + AND LOWER(COALESCE(status, '')) <> 'completed' """, nativeQuery = true ) From b70a2635bd1721a30f7eacf29845e424124b2258 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sun, 30 Aug 2026 12:59:59 +0800 Subject: [PATCH 09/13] label print text resized to 70% --- python/Bag4.py | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/python/Bag4.py b/python/Bag4.py index 8ad51cc..6a284d9 100644 --- a/python/Bag4.py +++ b/python/Bag4.py @@ -517,8 +517,8 @@ def generate_zpl_dataflex( ) -> str: """ Row 1 (from zero): QR code, then item name (rotated 90°). - Row 2: smaller Lot, then expiry at the next X, then item code. - Text is ^A@R (90° CW); the next line under Lot is +X, not +Y. + Row 2: expiry, then Lot at the next X, then item code. + Text is ^A@R (90° CW); the next line under expiry is +X, not +Y. Label and QR use lotNo from API when present, else batch_no (Bxxxxx). Light preprint (~JA/~RO) is sent before labels; full ~JR recovery is only for「打袋重設」. """ @@ -539,9 +539,9 @@ def generate_zpl_dataflex( host_id = _dataflex_host_identification_sgd_prefix(job_order_id) if exp_esc: row2 = f"""^FO0,200 -^A@R,40,40,{font_regular}^FD{label_esc}^FS -^FO42,200 ^A@R,40,40,{font_regular}^FD{exp_esc}^FS +^FO42,200 +^A@R,40,40,{font_regular}^FD{label_esc}^FS ^FO88,200 ^A@R,88,88,{font_bold}^FD{code}^FS""" else: @@ -700,8 +700,8 @@ def generate_zpl_label_small( else: qr_data = f"QA,{batch_no}" exp_zpl = f""" -^FO150,230 -^A@N,36,36,{font}^FD{exp_esc}^FS""" if exp_esc else "" +^FO150,194 +^A@N,25,25,{font}^FD{exp_esc}^FS""" if exp_esc else "" return f"""^XA ^CI28 ^PW500 @@ -712,9 +712,9 @@ def generate_zpl_label_small( ^FO10,110 ^BQN,2,6^FD{qr_data}^FS ^FO150,110 -^A@N,48,48,{font}^FD{code}^FS -^FO150,175 -^A@N,40,40,{font}^FD{label_line2_esc}^FS{exp_zpl} +^A@N,34,34,{font}^FD{code}^FS +^FO150,156 +^A@N,28,28,{font}^FD{label_line2_esc}^FS{exp_zpl} ^XZ""" @@ -724,9 +724,10 @@ LABEL_IMAGE_W = 720 LABEL_IMAGE_H = 530 LABEL_PADDING = 23 LABEL_FONT_NAME_SIZE = 42 -LABEL_FONT_CODE_SIZE = 49 -LABEL_FONT_BATCH_SIZE = 34 -LABEL_FONT_EXPIRY_SIZE = 34 +# Item code / lot / expiry ~70% of original size so all three fit under the name. +LABEL_FONT_CODE_SIZE = 34 +LABEL_FONT_BATCH_SIZE = 24 +LABEL_FONT_EXPIRY_SIZE = 24 LABEL_QR_SIZE = 210 @@ -1229,8 +1230,8 @@ def save_laser_last_count(date_str: str, count: int) -> None: LASER_PUSH_INTERVAL = 2 # seconds between pushes (like sample script) -# Click row with 激光機 selected: send this many times, delay between sends (not after last). -LASER_ROW_SEND_COUNT = 3 +# Click row with 激光機 selected: one payload. Sending 3 queued 2–3 old bags after a job change. +LASER_ROW_SEND_COUNT = 1 LASER_ROW_SEND_DELAY_SEC = 3 @@ -2272,6 +2273,17 @@ def open_dataflex_stop_window( def stop() -> None: stop_event.set() + # Cancel labels already sitting in the DataFlex buffer so the next job + # does not print 2–3 leftover bags of the previous job. + ip, port = session.printer_ip, session.printer_port + + def _cancel_buffer() -> None: + try: + send_dataflex_preprint_reset(ip, port, force=True) + except Exception: + pass + + threading.Thread(target=_cancel_buffer, daemon=True).start() stop_win_ref[0] = None clear_topmost() try: From 71477649a564cc43f55c8f28adaa8b66576d9ddd Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 31 Aug 2026 14:35:27 +0800 Subject: [PATCH 10/13] Add stock lot on-hand report Excel with last-move and PP/PF price. Live lots use ledger last move when lot line is set; otherwise SIL/SOL time. Unit price and stock value only for PP/PF origin. Co-authored-by: Cursor --- .../service/StockLotOnhandReportService.kt | 419 ++++++++++++++++++ .../web/StockLotOnhandReportController.kt | 321 ++++++++++++++ 2 files changed, 740 insertions(+) create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt new file mode 100644 index 0000000..aecdd1f --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt @@ -0,0 +1,419 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * 庫存批次現況(Stock Balance):永遠今天。 + * 現存讀 [inventory_lot_line](available、未過期、in-out > 0)。 + * 最後異動:有 inventoryLotLineId 的帳本用 MAX(id);缺的比 SIL/SOL 時間。 + * 單位均價/庫存總價值只填 root PO 為 PP/PF 的批(TRF 往回走);其他來源空白。 + */ +@Service +open class StockLotOnhandReportService( + private val jdbcDao: JdbcDao, +) { + private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + companion object { + /** Same TRF walk as stock-take variance: root stock-in / PO after transfers. */ + private const val LOT_ROOT_ORIGIN_CTE_SQL = """ +lot_trace AS ( + SELECT + il.id AS lotId, + il.stockInLineId AS silId, + 0 AS depth + FROM inventory_lot il + WHERE il.deleted = 0 + AND il.stockInLineId IS NOT NULL + UNION ALL + SELECT + lt.lotId AS lotId, + il_src.stockInLineId AS silId, + lt.depth + 1 AS depth + FROM lot_trace lt + INNER JOIN stock_in_line sil + ON sil.id = lt.silId + AND sil.deleted = 0 + INNER JOIN stock_transfer_record tr + ON tr.id = sil.stockTransferId + AND tr.deleted = 0 + INNER JOIN stock_out_line sol + ON sol.id = tr.stockOutLineId + AND sol.deleted = 0 + INNER JOIN inventory_lot_line ill_src + ON ill_src.id = sol.inventoryLotLineId + AND ill_src.deleted = 0 + INNER JOIN inventory_lot il_src + ON il_src.id = ill_src.inventoryLotId + AND il_src.deleted = 0 + WHERE lt.depth < 8 + AND il_src.stockInLineId IS NOT NULL + AND ( + UPPER(TRIM(COALESCE(sil.type, ''))) = 'TRF' + OR sil.stockTransferId IS NOT NULL + ) +), +lot_root_origin AS ( + SELECT + lotId, + silId AS rootSilId + FROM ( + SELECT + lotId, + silId, + ROW_NUMBER() OVER (PARTITION BY lotId ORDER BY depth DESC) AS rn + FROM lot_trace + ) t + WHERE t.rn = 1 +)""" + + private const val ROOT_STOCK_IN_JOIN_SQL = """ + LEFT JOIN lot_root_origin lro + ON lro.lotId = il.id + LEFT JOIN stock_in_line root_sil + ON root_sil.id = lro.rootSilId AND root_sil.deleted = 0 + LEFT JOIN purchase_order root_po + ON root_po.id = root_sil.purchaseOrderId AND root_po.deleted = 0 +""" + } + + data class SearchResult( + val rows: List>, + val stockDate: String, + ) + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + fun search( + itemCode: String?, + storeId: String?, + warehouse: String?, + area: String?, + slot: String?, + lotNo: String?, + stockTakeSectionDescription: String?, + lotOrigin: String?, + ): SearchResult { + val asOfDateStr = LocalDate.now().format(dateFmt) + + val masterArgs = HashMap() + val itemCodeSqlIt = buildMultiValueLikeClause(itemCode, "it.code", "itemCodeIt", masterArgs) + val lotNoSql = buildMultiValueLikeClause(lotNo, "il.lotNo", "lotNo", masterArgs) + val storeIdSql = if (!storeId.isNullOrBlank() && storeId.trim() != "All") { + masterArgs["storeId"] = storeId.trim() + "AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')" + } else { + "" + } + val warehouseSql = if (!warehouse.isNullOrBlank() && warehouse.trim() != "All") { + buildMultiValueLikeClause(warehouse, "wh.warehouse", "warehousePart", masterArgs) + } else { + "" + } + val areaSql = if (!area.isNullOrBlank() && area.trim() != "All") { + buildMultiValueLikeClause(area, "wh.area", "areaPart", masterArgs) + } else { + "" + } + val slotSql = if (!slot.isNullOrBlank() && slot.trim() != "All") { + buildMultiValueLikeClause(slot, "wh.slot", "slotPart", masterArgs) + } else { + "" + } + val sectionDescSql = if ( + !stockTakeSectionDescription.isNullOrBlank() && + stockTakeSectionDescription.trim() != "All" + ) { + masterArgs["stockTakeSectionDescription"] = stockTakeSectionDescription.trim() + "AND COALESCE(wh.stockTakeSectionDescription, '') = :stockTakeSectionDescription" + } else { + "" + } + val lotOriginFilterSql = buildLotOriginFilterSql(lotOrigin) + val originJoinSql = ROOT_STOCK_IN_JOIN_SQL + val ctePrefix = "WITH RECURSIVE\n$LOT_ROOT_ORIGIN_CTE_SQL\n" + + val liveLots = jdbcDao.queryForList( + """ + ${ctePrefix}SELECT + ill.id AS inventoryLotLineId, + COALESCE(it.code, '') AS itemNo, + COALESCE(it.name, '') AS itemName, + COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, + COALESCE(il.lotNo, '') AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + COALESCE(wh.store_id, '') AS storeId, + COALESCE(wh.warehouse, '') AS warehousePart, + COALESCE(wh.area, '') AS areaPart, + COALESCE(wh.slot, '') AS slotPart, + (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS lotQtyRaw, + COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw, + UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) AS rootPoPrefix + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND il.deleted = 0 + INNER JOIN items it + ON it.id = il.itemId AND it.deleted = 0 + INNER JOIN warehouse wh + ON wh.id = ill.warehouseId AND wh.deleted = 0 + LEFT JOIN item_uom iu + ON iu.id = ill.stockItemUomId AND iu.deleted = 0 + LEFT JOIN uom_conversion uc + ON uc.id = iu.uomId + $originJoinSql + WHERE ill.deleted = 0 + AND it.code IS NOT NULL AND it.code <> '' + AND LOWER(COALESCE(ill.status, '')) = 'available' + AND (il.expiryDate IS NULL OR il.expiryDate >= CURRENT_DATE) + AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) > 0 + $itemCodeSqlIt + $storeIdSql + $warehouseSql + $areaSql + $slotSql + $sectionDescSql + $lotNoSql + $lotOriginFilterSql + """.trimIndent(), + masterArgs, + ) + + val lotIds = liveLots.map { toLong(it["inventoryLotLineId"]) }.filter { it > 0 } + val lastTrnByLot = HashMap>(lotIds.size * 2) + loadLastTrnFromLedger(lotIds, lastTrnByLot) + val missing = lotIds.distinct().filter { it !in lastTrnByLot } + if (missing.isNotEmpty()) { + val silSolHits = HashMap(missing.size * 2) + fillLastTrnFromLotHeaderSil(missing, silSolHits) + fillLastTrnFromSilLine(missing, silSolHits) + fillLastTrnFromSol(missing, silSolHits) + for ((lotId, hit) in silSolHits) { + lastTrnByLot[lotId] = hit.date to hit.kind + } + } + + val rows = assembleRows(liveLots, lastTrnByLot) + return SearchResult(rows = rows, stockDate = asOfDateStr) + } + + private data class SilSolHit(val ts: String, val date: String, val kind: String) + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun loadLastTrnFromLedger(lotIds: List, out: MutableMap>) { + if (lotIds.isEmpty()) return + // idx_ledger_lot_date_id is (lot, date, id) — bad for MAX(id); force the lot-only index + // (InnoDB secondary includes PK id, so MAX(id) per lot is an index tail lookup). + for (chunk in lotIds.distinct().chunked(2000)) { + val rows = jdbcDao.queryForList( + """ + SELECT + sl.inventoryLotLineId, + DATE_FORMAT(sl.date, '%Y-%m-%d') AS lastTrnDate, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty + FROM stock_ledger sl + INNER JOIN ( + SELECT inventoryLotLineId, MAX(id) AS maxId + FROM stock_ledger FORCE INDEX (idx_ledger_inventoryLotLineId) + WHERE deleted = 0 + AND inventoryLotLineId IN (:lotIds) + GROUP BY inventoryLotLineId + ) t ON t.maxId = sl.id + """.trimIndent(), + mapOf("lotIds" to chunk), + ) + for (r in rows) { + val lotId = toLong(r["inventoryLotLineId"]) + if (lotId <= 0) continue + val date = r["lastTrnDate"]?.toString().orEmpty() + out[lotId] = date to lastTrnType(toDouble(r["inQty"]), toDouble(r["outQty"])) + } + } + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun fillLastTrnFromLotHeaderSil(missing: List, hits: MutableMap): Int { + var n = 0 + for (chunk in missing.chunked(800)) { + val rows = jdbcDao.queryForList( + """ + SELECT + ill.id AS lotLineId, + DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d %H:%i:%s') AS lastTs, + DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d') AS lastTrnDate + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND il.deleted = 0 + INNER JOIN stock_in_line sil + ON sil.id = il.stockInLineId AND sil.deleted = 0 + WHERE ill.deleted = 0 + AND ill.id IN (:lotIds) + """.trimIndent(), + mapOf("lotIds" to chunk), + ) + n += rows.size + applySilSolHits(rows, hits, "入庫") + } + return n + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun fillLastTrnFromSilLine(missing: List, hits: MutableMap): Int { + var n = 0 + for (chunk in missing.chunked(800)) { + val rows = jdbcDao.queryForList( + """ + SELECT + sil.inventoryLotLineId AS lotLineId, + DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d %H:%i:%s') AS lastTs, + DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d') AS lastTrnDate + FROM stock_in_line sil + WHERE sil.deleted = 0 + AND sil.inventoryLotLineId IN (:lotIds) + """.trimIndent(), + mapOf("lotIds" to chunk), + ) + n += rows.size + applySilSolHits(rows, hits, "入庫") + } + return n + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun fillLastTrnFromSol(missing: List, hits: MutableMap): Int { + var n = 0 + for (chunk in missing.chunked(800)) { + val rows = jdbcDao.queryForList( + """ + SELECT + sol.inventoryLotLineId AS lotLineId, + DATE_FORMAT(COALESCE(sol.pickTime, sol.modified, sol.created), '%Y-%m-%d %H:%i:%s') AS lastTs, + DATE_FORMAT(COALESCE(sol.pickTime, sol.modified, sol.created), '%Y-%m-%d') AS lastTrnDate + FROM stock_out_line sol + WHERE sol.deleted = 0 + AND sol.inventoryLotLineId IN (:lotIds) + """.trimIndent(), + mapOf("lotIds" to chunk), + ) + n += rows.size + applySilSolHits(rows, hits, "出庫") + } + return n + } + + private fun applySilSolHits(rows: List>, hits: MutableMap, kind: String) { + for (r in rows) { + val lotId = toLong(r["lotLineId"]) + val ts = r["lastTs"]?.toString().orEmpty() + if (lotId <= 0 || ts.isBlank()) continue + val date = r["lastTrnDate"]?.toString().orEmpty() + val prev = hits[lotId] + val newer = prev == null || + ts > prev.ts || + (ts == prev.ts && kind == "出庫" && prev.kind != "出庫") + if (newer) hits[lotId] = SilSolHit(ts, date, kind) + } + } + + private fun lastTrnType(inQty: Double, outQty: Double): String = + when { + outQty > 0 && inQty <= 0 -> "出庫" + inQty > 0 && outQty <= 0 -> "入庫" + inQty > 0 && outQty > 0 -> if (outQty >= inQty) "出庫" else "入庫" + else -> "" + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun assembleRows( + liveLots: List>, + lastTrnByLot: Map>, + ): List> { + val tot = HashMap() + for (r in liveLots) { + val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" + tot[key] = (tot[key] ?: 0.0) + toDouble(r["lotQtyRaw"]) + } + val out = ArrayList>(liveLots.size) + for (r in liveLots) { + val lotId = toLong(r["inventoryLotLineId"]) + val lotQty = toDouble(r["lotQtyRaw"]) + val avg = toDouble(r["avgUnitPriceRaw"]) + val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" + val last = lastTrnByLot[lotId] + val origin = r["rootPoPrefix"]?.toString().orEmpty().uppercase() + val showPrice = origin == "PP" || origin == "PF" + val row = HashMap(22) + row["inventoryLotLineId"] = lotId.toString() + row["itemNo"] = r["itemNo"] ?: "" + row["itemName"] = r["itemName"] ?: "" + row["unitOfMeasure"] = r["unitOfMeasure"] ?: "" + row["lotNo"] = r["lotNo"] ?: "" + row["expiryDate"] = r["expiryDate"] ?: "" + row["storeId"] = r["storeId"] ?: "" + row["warehousePart"] = r["warehousePart"] ?: "" + row["areaPart"] = r["areaPart"] ?: "" + row["slotPart"] = r["slotPart"] ?: "" + row["lotQtyRaw"] = lotQty + row["totalQtyRaw"] = tot[key] ?: 0.0 + row["avgUnitPriceRaw"] = if (showPrice) avg else "" + row["stockValueRaw"] = if (showPrice) avg * lotQty else "" + row["lastTrnDate"] = last?.first ?: "" + row["lastTrnType"] = last?.second ?: "" + out.add(row) + } + out.sortWith( + compareBy> { it["itemNo"]?.toString().orEmpty() } + .thenBy { it["unitOfMeasure"]?.toString().orEmpty() } + .thenBy { it["lotNo"]?.toString().orEmpty() } + .thenBy { it["storeId"]?.toString().orEmpty() } + .thenBy { it["warehousePart"]?.toString().orEmpty() } + .thenBy { it["areaPart"]?.toString().orEmpty() } + .thenBy { it["slotPart"]?.toString().orEmpty() } + .thenBy { it["inventoryLotLineId"]?.toString().orEmpty() }, + ) + return out + } + + private fun toDouble(v: Any?): Double { + if (v == null) return 0.0 + if (v is Number) return v.toDouble() + return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0 + } + + private fun toLong(v: Any?): Long { + if (v == null) return 0L + if (v is Number) return v.toLong() + return v.toString().toLongOrNull() ?: 0L + } + + /** PP/PF = root PO code prefix after TRF walk; other = not PP/PF (ADJ, TRF of other origin, JO, OPEN, …). */ + private fun buildLotOriginFilterSql(lotOrigin: String?): String { + val v = lotOrigin?.trim().orEmpty() + if (v.isBlank() || v.equals("All", ignoreCase = true)) return "" + return when (v.lowercase()) { + "pp" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PP'" + "pf" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PF'" + "other" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) NOT IN ('PP', 'PF')" + else -> "" + } + } + + private fun buildMultiValueLikeClause( + paramValue: String?, + columnName: String, + paramPrefix: String, + args: MutableMap, + ): String { + if (paramValue.isNullOrBlank()) return "" + val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } + if (values.isEmpty()) return "" + val conditions = values.mapIndexed { index, value -> + val paramName = "${paramPrefix}_$index" + args[paramName] = "%$value%" + "$columnName LIKE :$paramName" + } + return "AND (${conditions.joinToString(" OR ")})" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt new file mode 100644 index 0000000..7aac550 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt @@ -0,0 +1,321 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.StockLotOnhandReportService +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.CellStyle +import org.apache.poi.ss.usermodel.DataFormat +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.streaming.SXSSFWorkbook +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.io.ByteArrayOutputStream +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** + * 庫存批次現況報告 Stock Balance (Excel only, always today) + * Excel: /report/print-stock-lot-onhand-excel + */ +@RestController +@RequestMapping("/report") +class StockLotOnhandReportController( + private val stockLotOnhandReportService: StockLotOnhandReportService, +) { + private data class ExcelStyles( + val title: CellStyle, + val subtitle: CellStyle, + val header: CellStyle, + val text: CellStyle, + val center: CellStyle, + val number: CellStyle, + ) + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + @GetMapping("/print-stock-lot-onhand-excel") + fun exportExcel( + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) warehouse: String?, + @RequestParam(required = false) area: String?, + @RequestParam(required = false) slot: String?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) stockTakeSectionDescription: String?, + @RequestParam(required = false) lotOrigin: String?, + ): ResponseEntity { + val result = stockLotOnhandReportService.search( + itemCode = itemCode, + storeId = storeId, + warehouse = warehouse, + area = area, + slot = slot, + lotNo = lotNo, + stockTakeSectionDescription = stockTakeSectionDescription, + lotOrigin = lotOrigin, + ) + if (result.rows.isEmpty()) { + return ResponseEntity(HttpStatus.NO_CONTENT) + } + val excelBytes = createExcel( + dbData = result.rows, + reportDate = result.stockDate, + itemCode = itemCode, + storeId = storeId, + warehouse = warehouse, + area = area, + slot = slot, + lotNo = lotNo, + stockTakeSectionDescription = stockTakeSectionDescription, + lotOrigin = lotOrigin, + ) + + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + setContentDispositionFormData("attachment", "StockLotOnhandReport.xlsx") + set("filename", "StockLotOnhandReport.xlsx") + } + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + + private fun createStyles(workbook: Workbook): ExcelStyles { + val df: DataFormat = workbook.createDataFormat() + val numberFormat = df.getFormat("#,##0.00;(#,##0.00)") + fun borders(style: CellStyle) { + style.borderTop = BorderStyle.THIN + style.borderBottom = BorderStyle.THIN + style.borderLeft = BorderStyle.THIN + style.borderRight = BorderStyle.THIN + style.verticalAlignment = VerticalAlignment.CENTER + } + fun fill(style: CellStyle, color: IndexedColors) { + style.fillForegroundColor = color.index + style.fillPattern = FillPatternType.SOLID_FOREGROUND + } + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 16 + } + setFont(font) + } + val subtitleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + wrapText = true + val font = workbook.createFont().apply { + fontHeightInPoints = 10 + } + setFont(font) + } + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + wrapText = true + borders(this) + fill(this, IndexedColors.DARK_TEAL) + val font = workbook.createFont().apply { + bold = true + color = IndexedColors.WHITE.index + fontHeightInPoints = 10 + } + setFont(font) + } + val textStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + borders(this) + } + val centerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + borders(this) + } + val numberStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + borders(this) + dataFormat = numberFormat + } + return ExcelStyles( + title = titleStyle, + subtitle = subtitleStyle, + header = headerStyle, + text = textStyle, + center = centerStyle, + number = numberStyle, + ) + } + + private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { + row.createCell(col).apply { + setCellValue(value?.toString() ?: "") + cellStyle = style + } + } + + private fun setNumberCell(row: Row, col: Int, value: Any?, numberStyle: CellStyle) { + val cell = row.createCell(col) + val n = when (value) { + null -> null + is Number -> value.toDouble() + else -> { + val raw = value.toString().trim() + if (raw.isEmpty() || raw == "-") null else raw.replace(",", "").toDoubleOrNull() + } + } + if (n == null) { + cell.setCellValue("") + cell.cellStyle = numberStyle + return + } + cell.setCellValue(n) + cell.cellStyle = numberStyle + } + + private fun displayFilter(raw: String?): String { + val v = raw?.trim().orEmpty() + if (v.isEmpty() || v.equals("All", ignoreCase = true)) return "全部" + if (v.equals("other", ignoreCase = true)) return "其他" + return v + } + + private fun createExcel( + dbData: List>, + reportDate: String, + itemCode: String?, + storeId: String?, + warehouse: String?, + area: String?, + slot: String?, + lotNo: String?, + stockTakeSectionDescription: String?, + lotOrigin: String?, + ): ByteArray { + val workbook = SXSSFWorkbook(100) + workbook.setCompressTempFiles(true) + try { + val styles = createStyles(workbook) + val reportTitle = "庫存批次現況報告" + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) + val headers = listOf( + "貨品編號", "貨品名稱", "貨品單位結餘", "單位", + "批號", "到期日", + "樓層", "倉庫", "區域", "儲位", + "最後異動日", "最後異動(入庫/出庫)", + "批號結餘", "單位均價", "庫存總價值", + ) + val totalColumns = headers.size + var rowIndex = 0 + + val titleRow = sheet.createRow(rowIndex++) + titleRow.heightInPoints = 24f + titleRow.createCell(0).apply { + setCellValue(reportTitle) + cellStyle = styles.title + } + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + + val reportDateTime = + reportDate + + " (" + + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + + ")" + val dateRow = sheet.createRow(rowIndex++) + dateRow.heightInPoints = 18f + dateRow.createCell(0).apply { + setCellValue("報告日期(現況):$reportDateTime") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1)) + + val criteriaText = listOf( + "貨品編號=${displayFilter(itemCode)}", + "樓層=${displayFilter(storeId)}", + "倉庫=${displayFilter(warehouse)}", + "區域=${displayFilter(area)}", + "儲位=${displayFilter(slot)}", + "盤點區域說明=${displayFilter(stockTakeSectionDescription)}", + "批號=${displayFilter(lotNo)}", + "來源=${displayFilter(lotOrigin)}", + ).joinToString(" ") + val criteriaRow = sheet.createRow(rowIndex++) + criteriaRow.heightInPoints = 32f + criteriaRow.createCell(0).apply { + setCellValue("搜尋條件:$criteriaText") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(2, 2, 0, totalColumns - 1)) + + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headerRow.heightInPoints = 22f + headers.forEachIndexed { i, h -> + headerRow.createCell(i).apply { + setCellValue(h) + cellStyle = styles.header + } + } + + if (dbData.isEmpty()) { + val emptyRowIndex = rowIndex + val r = sheet.createRow(rowIndex++) + r.heightInPoints = 22f + r.createCell(0).apply { + setCellValue("查無資料") + cellStyle = styles.center + } + for (c in 1 until totalColumns) { + r.createCell(c).cellStyle = styles.center + } + sheet.addMergedRegion(CellRangeAddress(emptyRowIndex, emptyRowIndex, 0, totalColumns - 1)) + } else { + dbData.forEach { m -> + val r = sheet.createRow(rowIndex++) + r.heightInPoints = 18f + setTextCell(r, 0, m["itemNo"], styles.text) + setTextCell(r, 1, m["itemName"], styles.text) + setNumberCell(r, 2, m["totalQtyRaw"], styles.number) + setTextCell(r, 3, m["unitOfMeasure"], styles.center) + setTextCell(r, 4, m["lotNo"], styles.text) + setTextCell(r, 5, m["expiryDate"], styles.center) + setTextCell(r, 6, m["storeId"], styles.center) + setTextCell(r, 7, m["warehousePart"], styles.center) + setTextCell(r, 8, m["areaPart"], styles.center) + setTextCell(r, 9, m["slotPart"], styles.center) + setTextCell(r, 10, m["lastTrnDate"], styles.center) + setTextCell(r, 11, m["lastTrnType"], styles.center) + setNumberCell(r, 12, m["lotQtyRaw"], styles.number) + setNumberCell(r, 13, m["avgUnitPriceRaw"], styles.number) + setNumberCell(r, 14, m["stockValueRaw"], styles.number) + } + } + + val lastRowIndex = rowIndex - 1 + if (lastRowIndex >= headerRowIndex) { + sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, totalColumns - 1)) + } + sheet.createFreezePane(0, headerRowIndex + 1) + intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) + .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + + val out = ByteArrayOutputStream() + workbook.write(out) + return out.toByteArray() + } finally { + workbook.dispose() + workbook.close() + } + } +} From 6e4c9f308bfb46738365ac8076c7951080e35428 Mon Sep 17 00:00:00 2001 From: tommy Date: Mon, 31 Aug 2026 17:49:38 +0800 Subject: [PATCH 11/13] drink output qty --- .../service/DrinkShipmentQtyService.kt | 400 ++++++++++++++++++ .../service/ProductProcessService.kt | 110 ++--- .../web/ProductProcessController.kt | 14 +- .../web/model/SaveProductProcessRequest.kt | 23 +- 4 files changed, 468 insertions(+), 79 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/productProcess/service/DrinkShipmentQtyService.kt diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/DrinkShipmentQtyService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/DrinkShipmentQtyService.kt new file mode 100644 index 0000000..bafd7c3 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/DrinkShipmentQtyService.kt @@ -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 { + 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>>() + 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 { 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, 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, 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, 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, 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() + } +} 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 72e7aca..857bec3 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 @@ -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 { 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() - 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 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 748c661..10843be 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 @@ -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 { + val parsedDate = date?.takeIf { it.isNotBlank() }?.let { + LocalDate.parse(it, DateTimeFormatter.ISO_DATE) + } + return drinkShipmentQtyService.getDrinkShipmentQty(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 ab9e59e..781a450 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 @@ -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 = 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 = emptyList(), ) \ No newline at end of file From ab4b7b9e9bdf99fcea598edd41cbc06a3b475a2e Mon Sep 17 00:00:00 2001 From: tommy Date: Mon, 31 Aug 2026 18:17:08 +0800 Subject: [PATCH 12/13] revert --- .../service/ProductProcessService.kt | 109 +++++++++++++----- .../web/model/SaveProductProcessRequest.kt | 2 +- 2 files changed, 78 insertions(+), 33 deletions(-) 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 857bec3..235e533 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 @@ -85,6 +85,10 @@ 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 { println(" Service: Finding all ProductProcess with page: ${pageable.pageNumber}, size: ${pageable.pageSize}") @@ -1246,6 +1250,7 @@ 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) @@ -1254,6 +1259,12 @@ 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( @@ -1308,7 +1319,7 @@ open class ProductProcessService( val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) println(" Service: ProductProcessLines: $productProcessLines") - if (productProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { + if (productProcessLines.all { isLineDone(it.status) }) { productProcess.status = ProductProcessStatus.COMPLETED if (productProcess.endTime == null) { productProcess.endTime = LocalDateTime.now() @@ -1500,8 +1511,9 @@ open class ProductProcessService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 - * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 + * 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,因此不再需要「任一包裝即可」特例。) * * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 */ @@ -1651,10 +1663,6 @@ 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] @@ -1676,20 +1684,12 @@ open class ProductProcessService( val lineAggregate = lineStatusByJobOrderId[jobOrderId] val totalLines = lineAggregate?.totalLines ?: 0L - val done = { s: String? -> - val x = s?.trim()?.lowercase() - x == "completed" || x == "pass" - } - - - val jobLines = linesByJobOrder[jobOrderId].orEmpty() - val packing = jobLines.filter { (it.name ?: "").trim() == "包裝" } - val nonPacking = jobLines.filter { (it.name ?: "").trim() != "包裝" } - val nonPackingOk = nonPacking.isEmpty() || nonPacking.all { done(it.status) } - val packingOk = packing.isEmpty() || packing.any { done(it.status) } + // After packaging Complete/Pass autoPasses sibling 包裝 lines, qcReady is simply all lines done. + val jobLines = linesByJobOrder[jobOrderId].orEmpty() + val allLinesDone = jobLines.isNotEmpty() && jobLines.all { isLineDone(it.status) } - val ready = includedInList && stockInLine != null && nonPackingOk && packingOk + val ready = includedInList && stockInLine != null && allLinesDone JobOrderQcKey( jobOrderId = jobOrderId, @@ -1877,10 +1877,7 @@ open class ProductProcessService( val joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList() val productProcessLines = linesByProcessId[productProcess.id ?: 0L].orEmpty() - val finishedCount = productProcessLines.count { - val s = it.status?.trim()?.lowercase() - s == "completed" || s == "pass" - } + val finishedCount = productProcessLines.count { isLineDone(it.status) } val bomIsDrink = productProcess.bom?.isDrink val matchStatus = if (joPickOrdersList.isNotEmpty() && @@ -1954,7 +1951,7 @@ open class ProductProcessService( } /** - * FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass, stock-in exists and not completed/rejected; + * FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass/autoPass, 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 { @@ -2008,7 +2005,7 @@ open class ProductProcessService( val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() } val allLinesDone = jobOrderLines.isNotEmpty() && - jobOrderLines.all { it.status == "Completed" || it.status == "Pass" } + jobOrderLines.all { isLineDone(it.status) } if (!allLinesDone) continue val maxDate = processes.mapNotNull { it.date }.maxOrNull() @@ -2110,6 +2107,12 @@ 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" } @@ -2119,9 +2122,54 @@ 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() + 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 -> completed (via [ifAllLinesCompletedOrPassed]) + * - All Completed/Pass/autoPass -> completed (via [ifAllLinesCompletedOrPassed]) * - Any line started but not all done -> in_progress * - Does not override STOPPED or CANCELLED on the parent. */ @@ -2201,8 +2249,8 @@ open class ProductProcessService( // 获取所有 product process lines val allproductProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) - // 检查是否所有 lines 都是 "Completed" 或 "Pass" - if (allproductProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { + // 检查是否所有 lines 都是 Completed / Pass / autoPass + if (allproductProcessLines.all { isLineDone(it.status) }) { // 更新 product process 的 endTime 和状态 updateProductProcessEndTime(productProcessId) updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED) @@ -2631,9 +2679,6 @@ 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( @@ -3047,7 +3092,7 @@ open class ProductProcessService( } val currentProcesses = operatorLines - .filter { it.endTime == null || (it.status != null && it.status != "Completed" && it.status != "Pass") } + .filter { it.endTime == null || (it.status != null && !isLineDone(it.status)) } .map { line -> val productProcess = line.productProcess val jobOrder = productProcess.jobOrder 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 781a450..01afa35 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 @@ -218,7 +218,7 @@ data class JobOrderProductProcessPageResponse( ) /** - * Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass, stock-in not completed/rejected; + * Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass/autoPass, 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( From 453334fe337101ebe036f8de500859ede3d5a668 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 31 Aug 2026 22:58:58 +0800 Subject: [PATCH 13/13] stock take status fix --- .../stock/service/StockTakeRecordService.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index 4597177..3332710 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -1615,6 +1615,9 @@ open class StockTakeRecordService( ) } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 70 | v1.0.0 | 2026-08-31 + */ open fun checkAndUpdateStockTakeStatus(stockTakeId: Long, stockTakeSection: String): Map { try { val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(stockTakeId) @@ -1648,18 +1651,17 @@ open class StockTakeRecordService( } } - // 輪次預建後:以本 section 本輪「全部記錄是否已完成第一次盤點」為準(不再與即時庫存行逐行對齊) - val allLinesHaveRecords = stockTakeRecords.isNotEmpty() && - stockTakeRecords.all { it.pickerFirstStockTakeQty != null } - + // 以本 section 本輪「列狀態」升單頭。審核員單筆先核(無第一次數量)只要已是 + // pass/completed 即算完成,不再要求每一筆都有 pickerFirstStockTakeQty。 val allRecordsPassed = stockTakeRecords.isNotEmpty() && stockTakeRecords.all { it.status == "pass" || it.status == "completed" } val allRecordsCompleted = stockTakeRecords.isNotEmpty() && stockTakeRecords.all { it.status == "completed" } - // 6. 如果所有记录都已创建且都是 "pass",更新 stock take 状态为 "approving" - if (allLinesHaveRecords && allRecordsCompleted) { + val now = java.time.LocalDateTime.now() + if (allRecordsCompleted) { stockTake.status = StockTakeStatus.COMPLETED - stockTake.planEnd = java.time.LocalDateTime.now() + stockTake.planEnd = now + stockTake.actualEnd = now stockTakeRepository.save(stockTake) println("Stock take $stockTakeId status updated to COMPLETED - all records are completed") return mapOf( @@ -1667,10 +1669,9 @@ open class StockTakeRecordService( "message" to "Stock take status updated to COMPLETED", "updated" to true ) - } else if (allLinesHaveRecords && allRecordsPassed) { - // 如果所有记录都已创建且都是 "pass" 或 "completed",更新 stock take 状态为 "approving" + } else if (allRecordsPassed) { stockTake.status = StockTakeStatus.APPROVING - stockTake.actualEnd = java.time.LocalDateTime.now() + stockTake.actualEnd = now stockTakeRepository.save(stockTake) println("Stock take $stockTakeId status updated to APPROVING - all records are pass") @@ -1684,7 +1685,6 @@ open class StockTakeRecordService( "success" to true, "message" to "Conditions not met for status update", "updated" to false, - "allLinesHaveRecords" to allLinesHaveRecords, "allRecordsPassed" to allRecordsPassed, "allRecordsCompleted" to allRecordsCompleted )