From 0c1cb0d4f3f3016f1c4095df8e0e9a1616e2c4fe Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 5 Aug 2026 11:20:42 +0800 Subject: [PATCH] =?UTF-8?q?actualPickLotLineId=20+=20COALESCE=20=E8=AE=80?= =?UTF-8?q?=E5=8F=96=20ledger=20lotQtyBefore/After=20+=20inventoryLotLineI?= =?UTF-8?q?d=20=E6=88=90=E5=93=81=E5=87=BA=E5=80=89=E5=93=A1=E5=B7=A5?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=E5=A0=B1=E5=91=8A=20rep-016=20Truck=20X=202F?= =?UTF-8?q?/4F=20suggestion=20Just=20Complete=20/=20isIssueJustComplete=20?= =?UTF-8?q?Workbench=20=E7=95=A5=E9=81=8E=20classic=20confirmLotSubstituti?= =?UTF-8?q?on=20=E5=81=9C=20JO=20planStart=EF=BC=8Frenumber=20=E7=94=9F?= =?UTF-8?q?=E7=94=A2=E6=B5=81=E7=A8=8B=20lookback=20+=20tabs=EF=BC=8FQC=20?= =?UTF-8?q?chip=EF=BC=8FRelease=E2=86=92Cancel=20FP-MTMS=20code=20comments?= =?UTF-8?q?=20Report=20=E5=BA=97=E9=8B=AA=E8=A8=82=E5=96=AE=E8=A3=9C?= =?UTF-8?q?=E8=B2=A8=E8=A8=98=E9=8C=84=20=E5=B7=A5=E5=96=AE=E7=94=9F?= =?UTF-8?q?=E7=94=A2=E6=B5=81=E7=A8=8B=20UI=20=E5=86=8D=E9=87=8D=E6=A7=8B?= =?UTF-8?q?=20=E5=B7=A5=E5=96=AE=E6=8F=90=E6=96=99=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E9=A1=AF=E7=A4=BA=E5=8A=A0=E5=BC=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DoFloorSupplierSettingsService.kt | 22 ++ .../DoWorkbenchDopoAssignmentService.kt | 43 ++- .../service/DoWorkbenchMainService.kt | 172 ++++++++++- .../service/WorkbenchReleaseTypeSupport.kt | 6 +- .../web/models/WorkbenchScanPickRequest.kt | 6 + .../jobOrder/service/JoPickOrderService.kt | 87 +++--- .../service/JoWorkbenchMainService.kt | 63 ++-- .../service/JobOrderPlanStartAutoService.kt | 8 +- .../web/model/CreateJobOrderRequest.kt | 3 + .../pickOrder/entity/PickOrderRepository.kt | 50 ++++ .../service/HierarchicalFgPayloadAssembler.kt | 12 +- .../service/PickExecutionIssueService.kt | 37 ++- .../pickOrder/service/PickOrderService.kt | 17 +- .../service/PickOrderWorkbenchService.kt | 12 +- .../entity/ProductProcessRepository.kt | 68 +++++ .../service/ProductProcessService.kt | 202 +++++++++++-- .../web/ProductProcessController.kt | 13 +- .../web/model/SaveProductProcessRequest.kt | 24 +- .../service/DoUserPickAuditReportService.kt | 204 +++++++++++++ .../ShopOrderReplenishmentReportService.kt | 281 ++++++++++++++++++ .../web/DoUserPickAuditReportController.kt | 140 +++++++++ .../modules/report/web/ReportController.kt | 28 ++ .../fpsms/modules/stock/entity/StockLedger.kt | 17 +- .../modules/stock/entity/StockOutLine.kt | 7 + .../modules/stock/entity/SuggestedPickLot.kt | 5 + .../stock/service/StockInLineService.kt | 15 +- .../stock/service/StockLedgerLotSnapshot.kt | 50 ++++ .../stock/service/StockOutLineService.kt | 67 ++++- .../service/StockOutLineWorkbenchService.kt | 18 +- .../stock/service/StockTakeRecordService.kt | 29 +- .../SuggestedPickLotWorkbenchService.kt | 38 ++- .../01_add_audit_columns.sql | 21 ++ .../01_ledger_sol_index.sql | 10 + 33 files changed, 1628 insertions(+), 147 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/DoUserPickAuditReportService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/web/DoUserPickAuditReportController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/StockLedgerLotSnapshot.kt create mode 100644 src/main/resources/db/changelog/changes/20260723_do_user_pick_audit/01_add_audit_columns.sql create mode 100644 src/main/resources/db/changelog/changes/20260724_do_user_pick_audit_perf/01_ledger_sol_index.sql diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt index 9b43642..76f0acf 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt @@ -69,6 +69,28 @@ open class DoFloorSupplierSettingsService( return null } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 + * Warehouse store scope for workbench lot suggestions (`2/F` / `4/F`). + * Uses ticket [dbStoreId] when set; when null (Truck X), derives floor from linked DO + * supplier codes (4F settings list wins; else 2F). Does not write DOPO.storeId. + */ + open fun resolveSuggestionStoreId(dbStoreId: String?, supplierCodes: Collection): String { + val raw = dbStoreId?.trim()?.takeIf { it.isNotEmpty() } + if (raw != null) { + return when (raw.replace("/", "").uppercase(Locale.ROOT)) { + "2F" -> "2/F" + "4F" -> "4/F" + else -> raw + } + } + val (s2, s4) = loadDoFloorSupplierLists() + val any4F = supplierCodes.any { code -> + preferredStoreFloorForSupplier(code, s2, s4) == "4F" + } + return if (any4F) "4/F" else "2/F" + } + data class SqlPreferredFloorCases( /** 例如 `CASE WHEN s.code IN (...) THEN '4F' ... END`(單行,可嵌入原生 SQL) */ val floorStringCase: String, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt index 375422a..fa55cbe 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt @@ -23,6 +23,7 @@ open class DoWorkbenchDopoAssignmentService( private val pickOrderRepository: PickOrderRepository, private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, + private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { @Transactional @@ -95,7 +96,7 @@ open class DoWorkbenchDopoAssignmentService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 * Same UX as [DoPickOrderAssignmentService.assignByLane] but candidates come from * [delivery_order_pick_order] (+ unassigned [pick_order]), not [do_pick_order]. */ @@ -208,7 +209,7 @@ open class DoWorkbenchDopoAssignmentService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 * Legacy lane assign (V1): old FG-style (no atomic conflict guard). */ @Transactional @@ -399,18 +400,19 @@ open class DoWorkbenchDopoAssignmentService( ) } - // 下方逻辑先保持不变(你后续再做第二阶段批次化) + // Suggestion store scope: use DOPO.storeId when set; Truck X (null) → 2/F or 4/F from DO suppliers. val storeIdRow = jdbcDao.queryForList( "SELECT storeId FROM fpsmsdb.delivery_order_pick_order WHERE id = :id AND deleted = 0", mapOf("id" to dopoId), ).firstOrNull() val storeIdKey = storeIdRow?.keys?.find { it.equals("storeId", true) } - val storeId = storeIdKey?.let { storeIdRow[it]?.toString() }?.trim()?.takeIf { it.isNotEmpty() } - + val dbStoreId = storeIdKey?.let { storeIdRow[it]?.toString() }?.trim()?.takeIf { it.isNotEmpty() } + val suggestionStoreId = resolveSuggestionStoreIdForDopo(dopoId, dbStoreId) + for (poId in poIds) { suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( pickOrderId = poId, - storeId = storeId, + storeId = suggestionStoreId, excludeWarehouseCodes = null, ) stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(poId, userId) @@ -438,4 +440,33 @@ open class DoWorkbenchDopoAssignmentService( ) ) } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 + * Truck X: derive 2/F|4/F from linked DO suppliers when DOPO.storeId is null. + */ + private fun resolveSuggestionStoreIdForDopo(dopoId: Long, dbStoreId: String?): String { + if (!dbStoreId.isNullOrBlank()) { + return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList()) + } + val rows = try { + jdbcDao.queryForList( + """ + SELECT DISTINCT s.code AS supplierCode + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 + LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 + WHERE po.deliveryOrderPickOrderId = :dopoId AND po.deleted = 0 + """.trimIndent(), + mapOf("dopoId" to dopoId), + ) + } catch (_: Exception) { + emptyList() + } + val codes = rows.mapNotNull { row -> + val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null + row[k]?.toString() + } + return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes) + } } 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 fd7fdac..5c2bc1e 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 @@ -45,6 +45,7 @@ import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService +import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus import org.springframework.stereotype.Service @@ -266,6 +267,7 @@ open class DoWorkbenchMainService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 * 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], @@ -324,6 +326,7 @@ open class DoWorkbenchMainService( sol.handledBy = request.userId if (sol.startTime == null) sol.startTime = LocalDateTime.now() sol.endTime = LocalDateTime.now() + maybeMarkIssueJustComplete(sol, request.justComplete == true) stockOutLIneRepository.saveAndFlush(sol) val polZero = sol.pickOrderLine updateJoPickOrderHandledByIfJobOrder( @@ -618,6 +621,7 @@ if (sol.startTime == null) sol.startTime = LocalDateTime.now() if (solEndStatus.equals(StockOutLineStatus.COMPLETE.status, ignoreCase = true)) { sol.endTime = LocalDateTime.now() } +maybeMarkIssueJustComplete(sol, request.justComplete == true) stockOutLIneRepository.save(sol) stockOutLIneRepository.flush() sol.id?.let { suggestedPickLotWorkbenchService.linkSplToStockOutLineAfterWorkbenchPick(it) } @@ -887,7 +891,7 @@ return MessageResponse( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 * Workbench Etra view: `isExtra` / `isExtrabatch` / `isExtrasingle` tickets for one day, * grouped by shop then truck / time / loading sequence. * Truck X (`車線-X`, DB `storeId` null) is included and split by supplier preferred floor @@ -1127,7 +1131,7 @@ return MessageResponse( /** * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today). * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day). - * @param releaseTypeFilter when `isExtra`/`etra`/`etraFamily`, filter Etra family (`isExtra`/`isExtrabatch`/`isExtrasingle`). + * @param releaseTypeFilter when `isExtra`/`etra`, filters Etra family (`isExtra` / `isExtrabatch` / `isExtrasingle`). * @param floor optional `2F`/`4F`: for Truck X (null storeId) filter by DO supplier preferred floor. */ open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( @@ -1739,6 +1743,100 @@ return MessageResponse( ) } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 + * Store scope for workbench lot (re)suggestions. + * Prefer [requestStoreId]; else DOPO.storeId; Truck X (both null) → 2/F|4/F from DO suppliers. + */ + private fun resolveWorkbenchSuggestionStoreId(pickOrderId: Long, requestStoreId: String?): String { + if (!requestStoreId.isNullOrBlank()) { + return doFloorSupplierSettingsService.resolveSuggestionStoreId(requestStoreId, emptyList()) + } + val header = try { + jdbcDao.queryForList( + """ + SELECT dop.storeId AS storeId + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order_pick_order dop + ON dop.id = po.deliveryOrderPickOrderId AND dop.deleted = 0 + WHERE po.id = :poId AND po.deleted = 0 + LIMIT 1 + """.trimIndent(), + mapOf("poId" to pickOrderId), + ).firstOrNull() + } catch (_: Exception) { + null + } + val dbStoreId = header?.let { row -> + val k = row.keys.find { it.equals("storeId", true) } ?: return@let null + row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() } + } + if (!dbStoreId.isNullOrBlank()) { + return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList()) + } + val supplierRows = try { + jdbcDao.queryForList( + """ + SELECT DISTINCT s.code AS supplierCode + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 + LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 + WHERE po.id = :poId AND po.deleted = 0 + """.trimIndent(), + mapOf("poId" to pickOrderId), + ) + } catch (_: Exception) { + emptyList() + } + val codes = supplierRows.mapNotNull { row -> + val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null + row[k]?.toString() + } + return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes) + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 + * UI / lot-print / scan-pick store scope for a DOPO. + * Truck X keeps DB storeId null; response may expose derived 2/F|4/F (not written to DB). + */ + private fun resolveDisplayStoreIdForDopo(dopoId: Long, dbStoreId: String?): String { + if (!dbStoreId.isNullOrBlank()) { + return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList()) + } + val supplierRows = try { + jdbcDao.queryForList( + """ + SELECT DISTINCT s.code AS supplierCode + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 + LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 + WHERE po.deliveryOrderPickOrderId = :dopoId AND po.deleted = 0 + """.trimIndent(), + mapOf("dopoId" to dopoId), + ) + } catch (_: Exception) { + emptyList() + } + val codes = supplierRows.mapNotNull { row -> + val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null + row[k]?.toString() + } + return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes) + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 + * Copy DOPO header map with Truck X display storeId filled for hierarchical fgInfo. + */ + private fun enrichDopoInfoStoreIdForUi(doPickOrderInfo: Map, dopoId: Long): Map { + val storeKey = doPickOrderInfo.keys.find { it.equals("store_id", true) } ?: "store_id" + val dbStoreId = doPickOrderInfo[storeKey]?.toString()?.trim()?.takeIf { it.isNotEmpty() } + if (!dbStoreId.isNullOrBlank()) return doPickOrderInfo + val resolved = resolveDisplayStoreIdForDopo(dopoId, null) + return doPickOrderInfo.toMutableMap().apply { this[storeKey] = resolved } + } + /** * EXISTS clause: Truck X ticket has at least one DO whose supplier preferred floor matches [floorKey] * (`2F` / `4F`), and **no** linked DO on the other floor. @@ -1772,7 +1870,7 @@ return MessageResponse( """.trimIndent() } - /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */ private fun queryWorkbenchReleasedDopoList( shopName: String?, storeId: String?, @@ -2021,7 +2119,7 @@ return MessageResponse( payload = assembleHierarchicalFgPayload( jdbcDao = jdbcDao, doPickOrderId = doPickOrderId, - doPickOrderInfo = doPickOrderInfoSafe, + doPickOrderInfo = enrichDopoInfoStoreIdForUi(doPickOrderInfoSafe, doPickOrderId), pickOrdersInfo = pickOrdersInfo, availableQtyInOutOnly = true, timingSink = ::mark, @@ -2086,13 +2184,12 @@ return MessageResponse( return mapOf("fgInfo" to null, "pickOrders" to emptyList()) } - // Completed-record detail should keep each PO as an independent group - // (ticket can link multiple POs, e.g. 20 + 25), not merged into a single 45-line bucket. + val doPickOrderInfoForUi = enrichDopoInfoStoreIdForUi(doPickOrderInfo, deliveryOrderPickOrderId) val perPoPayloads = pickOrdersInfo.map { poInfo -> assembleHierarchicalFgPayload( jdbcDao = jdbcDao, doPickOrderId = deliveryOrderPickOrderId, - doPickOrderInfo = doPickOrderInfo, + doPickOrderInfo = doPickOrderInfoForUi, pickOrdersInfo = listOf(poInfo), availableQtyInOutOnly = true, ) @@ -2176,6 +2273,13 @@ return MessageResponse( } else { emptyList() } + val dopoId = (row["doPickOrderId"] as? Number)?.toLong() ?: 0L + val dbStoreId = row["storeId"]?.toString()?.trim()?.takeIf { it.isNotEmpty() } + val displayStoreId = if (dopoId > 0L) { + resolveDisplayStoreIdForDopo(dopoId, dbStoreId) + } else { + dbStoreId.orEmpty() + } mapOf( "doPickOrderId" to (row["doPickOrderId"] ?: 0L), @@ -2200,7 +2304,7 @@ return MessageResponse( "truckLanceCode" to (row["truckLanceCode"] ?: ""), "DepartureTime" to (row["DepartureTime"]?.toString() ?: ""), "ticketNo" to (row["ticketNo"] ?: ""), - "storeId" to (row["storeId"] ?: ""), + "storeId" to displayStoreId, "qrCodeData" to (row["doPickOrderId"] ?: 0L), ) } @@ -2282,6 +2386,7 @@ return MessageResponse( var postMs = 0L try { if (pickOrderId != null) { + val suggestionStoreId = resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes( pickOrderId = pickOrderId, poType = null, @@ -2294,7 +2399,7 @@ return MessageResponse( suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineNextSingleLot( pickOrderLineId = polId, targetQty = explicitRemainder, - storeId = requestStoreId, + storeId = suggestionStoreId, excludeInventoryLotLineId = scannedIllId, excludeWarehouseCodes = resuggestExcludeWarehouseCodes, ) @@ -2328,7 +2433,7 @@ return MessageResponse( rebuildMs = measureTimeMillis { suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine( pickOrderLineId = polId, - storeId = requestStoreId, + storeId = suggestionStoreId, excludeWarehouseCodes = resuggestExcludeWarehouseCodes, ) } @@ -2485,6 +2590,9 @@ return MessageResponse( val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val previousBalance = onHandAfter + deltaQty.toDouble() val newBalance = previousBalance - deltaQty.toDouble() + val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } + ?: sol.inventoryLotLine + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val ledger = StockLedger().apply { this.stockOutLine = sol this.inventory = inventory @@ -2494,14 +2602,54 @@ return MessageResponse( this.type = "NOR" this.itemId = solItem.id this.itemCode = solItem.code - this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id - ?: inventory.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id, + inventory.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = ledger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = deltaQty.negate(), + ) // Do not flush per pick; transaction flush is sufficient and avoids slow roundtrips/locks. stockLedgerRepository.save(ledger) } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 + * DO user-audit: Just Complete is reportable only when the item still has pickable stock + * at complete time (AVAILABLE, not expired, in−out > 0). + * + * Legitimate (do not flag): suggested lot exhausted → system splits remainder SOL → JC qty=0 + * with no other available lots. + * Issue (flag): between split and JC a new/other available lot exists but user still JC. + */ + private fun maybeMarkIssueJustComplete(sol: StockOutLine, justComplete: Boolean) { + if (!justComplete) return + val pol = sol.pickOrderLine ?: return + val po = pol.pickOrder + val isDo = po?.type == PickOrderType.DELIVERY_ORDER + if (!isDo) return + + val itemId = pol.item?.id ?: sol.item?.id ?: return + val today = LocalDate.now() + val excludeIllId = sol.inventoryLotLine?.id + val hasPickableLot = inventoryLotLineRepository.findAllByItemIdIn(listOf(itemId)) + .asSequence() + .filter { !it.deleted } + .filter { excludeIllId == null || it.id != excludeIllId } + .filter { it.status == InventoryLotLineStatus.AVAILABLE } + .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } + .any { StockLedgerLotSnapshot.availableQty(it) > BigDecimal.ZERO } + if (hasPickableLot) { + sol.isIssueJustComplete = true + } + } + private fun resolveWorkbenchPreviousBalance( itemId: Long, inventory: Inventory, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt index dc1a5ed..e892303 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt @@ -15,7 +15,7 @@ object WorkbenchReleaseTypeSupport { fun singleFamilyTypes(): List = listOf(SINGLE, IS_EXTRA_SINGLE) - /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */ fun summaryFilterSql(releaseType: String, column: String = "dop.releaseType"): String = when (releaseType.trim().lowercase()) { "batch" -> batchFamilySql(column) @@ -25,7 +25,7 @@ object WorkbenchReleaseTypeSupport { else -> "" } - /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */ fun assignFilterSql(releaseType: String?, column: String = "dop.releaseType"): String { val n = releaseType?.trim()?.lowercase().orEmpty() return when (n) { @@ -46,7 +46,7 @@ object WorkbenchReleaseTypeSupport { " AND LOWER(COALESCE($column, '')) = 'isextra' " /** - * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 + * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 * Standalone `isExtra` plus merged `isExtrabatch` / `isExtrasingle` (Etra workbench). */ fun etraFamilySql(column: String = "dop.releaseType"): String = 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 f53b9db..a3e0474 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,6 +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 * 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). @@ -25,6 +26,11 @@ data class WorkbenchScanPickRequest( */ val inventoryLotLineId: Long? = null, val qty: BigDecimal? = null, + /** + * When true, UI used Just Complete (no QR). Backend may set [StockOutLine.isIssueJustComplete] + * for DO user-audit reporting. + */ + val justComplete: Boolean? = false, val userId: Long = 1L, ) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt index 5752b71..4214ee4 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt @@ -231,6 +231,7 @@ open class JoPickOrderService( } return joPickOrderRecordRepository.saveAll(joPickOrderRecords) } + /** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */ open fun getAllJobOrderLotsWithDetailsHierarchical(userId: Long): JobOrderLotsHierarchicalResponse { println("=== Debug: getAllJobOrderLotsWithDetailsHierarchical ===") println("today: ${LocalDate.now()}") @@ -557,6 +558,7 @@ open class JoPickOrderService( } // Get completed job order pick orders (for second tab) // Fix the getCompletedJobOrderLotsHierarchical method + /** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */ open fun getCompletedJobOrderLotsHierarchical(userId: Long): Map { println("=== Debug: getCompletedJobOrderLotsHierarchical (repo version) ===") println("today: ${LocalDate.now()}") @@ -625,13 +627,20 @@ open class JoPickOrderService( .filter { it.deleted == false } } else emptyList() - val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id } + // Prefer actualPickLotLine for display (after scan/switch); fallback suggested + val inventoryLotLineIds = suggestedPickLots + .mapNotNull { it.actualPickLotLine?.id ?: it.suggestedLotLine?.id } + .distinct() val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { inventoryLotLineRepository .findAllByIdIn(inventoryLotLineIds) .filter { it.deleted == false } } else emptyList() + + val inventoryLotLineById = inventoryLotLines + .filter { it.id != null } + .associateBy { it.id!! } val inventoryLotIds = inventoryLotLines.mapNotNull { it.inventoryLot?.id }.distinct() val inventoryLots = if (inventoryLotIds.isNotEmpty()) { @@ -664,16 +673,19 @@ open class JoPickOrderService( } val lots = lineSuggestedLots.mapNotNull { spl -> - val ill = spl.suggestedLotLine ?: return@mapNotNull null - if (ill.deleted == true) return@mapNotNull null + val displayIllId = (spl.actualPickLotLine?.takeIf { it.deleted != true }?.id) + ?: (spl.suggestedLotLine?.takeIf { it.deleted != true }?.id) + ?: return@mapNotNull null + val ill = inventoryLotLineById[displayIllId] ?: return@mapNotNull null val il = ill.inventoryLot ?: return@mapNotNull null if (il.deleted == true) return@mapNotNull null val warehouse = ill.warehouse - val sol = stockOutLines.firstOrNull { - it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id - } + val sol = spl.stockOutLine?.takeIf { it.deleted != true && it.pickOrderLine?.id == pol.id } + ?: stockOutLines.firstOrNull { + it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id + } val availableQty = if (sol?.status == "rejected") { null @@ -1140,7 +1152,7 @@ open fun recordSecondScanIssue(request: SecondScanIssueRequest): MessageResponse il.lotNo as lotNo, w.name as storeLocation FROM fpsmsdb.suggested_pick_lot spl - JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id + JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) JOIN fpsmsdb.inventory_lot il ON ill.inventoryLotId = il.id LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId WHERE spl.pickOrderLineId = :pickOrderLineId @@ -1506,7 +1518,7 @@ open fun getCompletedJobOrderPickOrderLotDetails(pickOrderId: Long): List - // 通过关系链:InventoryLotLine -> InventoryLot -> StockInLine -> id - ill.inventoryLot?.stockInLine?.id?.let { stockInLineId -> - illId to stockInLineId - } + val stockInLinesByInventoryLotLineId = buildMap { + (inventoryLotLines + stockOutInventoryLotLines).forEach { ill -> + val id = ill.id ?: return@forEach + if (!containsKey(id)) { + ill.inventoryLot?.stockInLine?.id?.let { put(id, it) } } - .toMap() - } else { - emptyMap() + } } // 获取 jo_pick_order 记录 @@ -2617,22 +2629,22 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List - val ill = spl.suggestedLotLine - if (ill == null || ill.deleted == true) return@mapNotNull null + val displayIllId = (spl.actualPickLotLine?.takeIf { it.deleted != true }?.id) + ?: (spl.suggestedLotLine?.takeIf { it.deleted != true }?.id) + ?: return@mapNotNull null + val ill = inventoryLotLineById[displayIllId] ?: return@mapNotNull null val il = ill.inventoryLot if (il == null || il.deleted == true) return@mapNotNull null val warehouse = ill.warehouse - val sol = stockOutLines.firstOrNull { - it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id - } - val jpo = joPickOrders.firstOrNull { it.itemId == item?.id } - val handlerName = jpo?.handledBy?.let { uid -> - userService.find(uid).orElse(null)?.name - } - println("handlerName: $handlerName") + val sol = spl.stockOutLine?.takeIf { it.deleted != true && it.pickOrderLine?.id == pol.id } + ?: stockOutLines.firstOrNull { + it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id + } + val jpoInner = joPickOrders.firstOrNull { it.itemId == item?.id } val availableQty = if (sol?.status == "rejected") { null } else { @@ -2655,7 +2667,6 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List "pending" } - // 获取 stockInLineId val stockInLineId = ill.id?.let { illId -> stockInLinesByInventoryLotLineId[illId] } @@ -2685,11 +2696,11 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt index 03c66be..a2ef172 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt @@ -32,7 +32,8 @@ import java.util.Locale /** * Job Order Workbench–only flows (assign prime, hierarchical payload for scan-pick UI). - * [getJobOrderLotsHierarchicalByPickOrderIdWorkbench] uses **in − out** for available qty (matches workbench scan-pick). + * [getJobOrderLotsHierarchicalByPickOrderIdWorkbench] uses **in − out** for available qty (matches workbench scan-pick) + * and displays COALESCE(actualPickLotLine, suggestedLotLine). * Non-workbench hierarchical stays in [JoPickOrderService.getJobOrderLotsHierarchicalByPickOrderId] (in − out − hold). */ @Service @@ -190,7 +191,9 @@ open class JoWorkbenchMainService( } /** - * Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line. + * FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 + * Hierarchical pick UI for JO Workbench: available qty **in − out**; + * lot display uses COALESCE(actualPickLotLine, suggestedLotLine) (same as DO workbench). */ open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") @@ -239,7 +242,10 @@ open class JoWorkbenchMainService( emptyList() } - val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id } + // Prefer actualPickLotLine for display (after scan/switch); fallback suggested (old / not yet picked). + val inventoryLotLineIds = suggestedPickLots + .mapNotNull { displayIllForWorkbench(it)?.id } + .distinct() val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { inventoryLotLineRepository.findAllByIdIn(inventoryLotLineIds) @@ -253,16 +259,6 @@ open class JoWorkbenchMainService( inventoryLotRepository.findAllByIdIn(inventoryLotIds).filter { it.deleted == false } } - val stockOutLines = if (pickOrderLineIds.isNotEmpty() && inventoryLotLineIds.isNotEmpty()) { - pickOrderLineIds.flatMap { polId -> - inventoryLotLineIds.flatMap { illId -> - stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse(polId, illId) - } - } - } else { - emptyList() - } - val stockOutLinesByPickOrderLine = pickOrderLineIds.associateWith { polId -> stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) } @@ -322,27 +318,33 @@ open class JoWorkbenchMainService( val uom = pol.uom val lineId = pol.id!! val lineSuggestedLots = suggestedPickLots.filter { it.pickOrderLine?.id == pol.id } + val lineSols = stockOutLinesByPickOrderLine[lineId] ?: emptyList() val jpo = joPickOrders.firstOrNull { it.itemId == item?.id } val handlerName = jpo?.handledBy?.let { uid -> userService.find(uid).orElse(null)?.name } val lots = lineSuggestedLots.mapNotNull { spl -> - val ill = spl.suggestedLotLine - if (ill == null || ill.deleted == true) return@mapNotNull null + val displayIllId = displayIllForWorkbench(spl)?.id ?: return@mapNotNull null + // Prefer freshly loaded ILL (actual or suggested) so lotNo/location match display id. + val ill = inventoryLotLineById[displayIllId] + ?: displayIllForWorkbench(spl) + ?: return@mapNotNull null + if (ill.deleted == true) return@mapNotNull null val il = ill.inventoryLot if (il == null || il.deleted == true) return@mapNotNull null val warehouse = ill.warehouse - val sol = stockOutLines.firstOrNull { - it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id + val linkedSolId = spl.stockOutLine?.takeIf { it.deleted != true }?.id + val sol = when { + linkedSolId != null -> lineSols.firstOrNull { it.id == linkedSolId } + else -> lineSols.firstOrNull { it.inventoryLotLineId == ill.id } } val jpoInner = joPickOrders.firstOrNull { it.itemId == item?.id } val handlerNameInner = jpoInner?.handledBy?.let { uid -> userService.find(uid).orElse(null)?.name } - //println("handlerName: $handlerNameInner") val availableQty = if (sol?.status == "rejected") { null } else { @@ -377,7 +379,7 @@ open class JoWorkbenchMainService( location = warehouse?.code, availableQty = availableQty?.toDouble(), requiredQty = spl.qty?.toDouble() ?: 0.0, - actualPickQty = sol?.qty ?: 0.0, + actualPickQty = sol?.qty?.toDouble() ?: 0.0, processingStatus = processingStatus, lotAvailability = lotAvailability, pickOrderId = pickOrder.id, @@ -387,7 +389,7 @@ open class JoWorkbenchMainService( stockOutLineId = sol?.id, stockInLineId = stockInLineId, suggestedPickLotId = spl.id, - stockOutLineQty = sol?.qty ?: 0.0, + stockOutLineQty = sol?.qty?.toDouble() ?: 0.0, stockOutLineStatus = sol?.status, routerIndex = warehouse?.order?.toString(), routerArea = warehouse?.code, @@ -397,9 +399,9 @@ open class JoWorkbenchMainService( matchBy = jpoInner?.matchBy, matchQty = jpoInner?.matchQty?.toDouble() ) - } + }.distinctBy { it.lotId } - val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol -> + val stockouts = lineSols.map { sol -> val illId = sol.inventoryLotLineId val ill = if (illId != null) inventoryLotLineById[illId] else null val lot = ill?.inventoryLot @@ -412,7 +414,10 @@ open class JoWorkbenchMainService( illAvailableQtyWorkbench(ill) } val splForSol = lineSuggestedLots.firstOrNull { sp -> - sp.suggestedLotLine?.id != null && sp.suggestedLotLine?.id == illId + sp.stockOutLine?.id != null && sp.stockOutLine?.id == sol.id + } ?: lineSuggestedLots.firstOrNull { sp -> + val displayId = displayIllForWorkbench(sp)?.id + displayId != null && displayId == illId } StockOutLineDetailResponse( @@ -456,6 +461,18 @@ open class JoWorkbenchMainService( } } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 + * Workbench UI display lot: prefer actual pick (after scan/switch), fallback suggested (pending / old data). + */ + private fun displayIllForWorkbench(spl: com.ffii.fpsms.modules.stock.entity.SuggestedPickLot): InventoryLotLine? { + val actual = spl.actualPickLotLine + if (actual != null && actual.deleted != true) return actual + val suggested = spl.suggestedLotLine + if (suggested != null && suggested.deleted != true) return suggested + return null + } + private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalWorkbenchResponse { println("❌ $message") return JobOrderLotsHierarchicalWorkbenchResponse( diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt index b1872c1..cca5992 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt @@ -21,7 +21,8 @@ import java.util.concurrent.atomic.AtomicBoolean * [JobOrder.planStart] fell on the previous calendar day. * * - Branch A: no pick submitted, product process pending → hide job order. - * - Branch B: pick submitted, product process still pending → reschedule to today 00:00:00 and renumber. + * - Branch B (disabled): pick submitted, process pending — previously rescheduled + renumbered; + * now SKIP so unfinished JOs stay visible via Production Process list lookback instead. */ @Service open class JobOrderPlanStartAutoService( @@ -47,6 +48,7 @@ open class JobOrderPlanStartAutoService( val errors: Int = 0, ) + /** FP-MTMS Version Checklist | Functions Ref. No. 42 | v1.0.0 | 2026-08-03 */ open fun runAutoProcess(runAt: LocalDateTime = LocalDateTime.now()): JobOrderPlanStartAutoReport { if (!inFlight.compareAndSet(false, true)) { logger.warn("Job order plan-start auto process skipped: previous run still in flight") @@ -147,6 +149,7 @@ open class JobOrderPlanStartAutoService( SKIP, } + /** FP-MTMS Version Checklist | Functions Ref. No. 42 | v1.0.0 | 2026-08-03 */ private fun classify( jo: JobOrder, pickOrders: List, @@ -158,7 +161,8 @@ open class JobOrderPlanStartAutoService( val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0 return when { maxSubmittedLines == 0 -> Branch.HIDE - maxSubmittedLines > 0 -> Branch.RESCHEDULE + // Renumber / planStart roll-forward disabled — Production Process list carry-over covers visibility. + maxSubmittedLines > 0 -> Branch.SKIP else -> Branch.SKIP } } diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt index c7e382a..d1c39c0 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt @@ -49,7 +49,10 @@ data class AllJoPickOrderResponse( val jobOrderType: String?, val itemId: Long, val itemName: String, + val itemCode: String? = null, val bomDescription: String?, + /** BOM.type (e.g. drink / Powder_Mixture / other). */ + val bomType: String? = null, val reqQty: BigDecimal, val uomId: Long, val uomName: String, 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 6546c6f..9700536 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 @@ -254,4 +254,54 @@ fun findAllReleasedJoWorkbenchPickOrders( nativeQuery = true, ) fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 + * JO ids that have at least one stock_out_line counted as picked + * (checked / partially_completed / completed / picking, or rejected with qty > 0). + */ + @Query( + value = """ + SELECT DISTINCT po.joId + FROM pick_order po + INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0 + INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0 + WHERE po.deleted = 0 + AND po.joId IN (:joIds) + AND ( + LOWER(COALESCE(sol.status, '')) IN ('checked', 'partially_completed', 'completed', 'picking') + OR (LOWER(COALESCE(sol.status, '')) = 'rejected' AND COALESCE(sol.qty, 0) > 0) + ) + """, + nativeQuery = true, + ) + fun findPickedJobOrderIds(@Param("joIds") joIds: Collection): List + + /** Unlimited: picked + product process still pending / not started. */ + @Query( + value = """ + SELECT DISTINCT po.joId + FROM pick_order po + INNER JOIN job_order jo + ON jo.id = po.joId + AND IFNULL(jo.deleted, 0) = 0 + AND IFNULL(jo.isHidden, 0) = 0 + AND LOWER(IFNULL(jo.status, '')) NOT IN ('completed', 'planning') + INNER JOIN productprocess pp + ON pp.jobOrderId = jo.id + AND IFNULL(pp.deleted, 0) = 0 + AND LOWER(pp.status) = 'pending' + AND pp.startTime IS NULL + INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0 + INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0 + WHERE po.deleted = 0 + AND po.joId IS NOT NULL + AND ( + LOWER(COALESCE(sol.status, '')) IN ('checked', 'partially_completed', 'completed', 'picking') + OR (LOWER(COALESCE(sol.status, '')) = 'rejected' AND COALESCE(sol.qty, 0) > 0) + ) + """, + nativeQuery = true, + ) + fun findPickedNotStartedJobOrderIds(): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt index c875b91..3b48237 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt @@ -5,6 +5,7 @@ import java.util.Locale import kotlin.system.measureTimeMillis /** + * FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03 * Shared hierarchical FG payload (fgInfo + merged pickOrders with lines/lots) for both * legacy [do_pick_order_line] and workbench [delivery_order_pick_order] flows. */ @@ -60,7 +61,8 @@ fun assembleHierarchicalFgPayload( AND pol.deleted = false ), ll AS ( - SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId + SELECT spl.pickOrderLineId, + COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId FROM fpsmsdb.suggested_pick_lot spl JOIN target_pol tp ON tp.id = spl.pickOrderLineId WHERE spl.deleted = false @@ -73,11 +75,13 @@ ll AS ( WHERE sol.deleted = false ), sm AS ( - SELECT s.pickOrderLineId, s.suggestedLotLineId, MAX(s.id) AS maxId + SELECT s.pickOrderLineId, + COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) AS displayLotLineId, + MAX(s.id) AS maxId FROM fpsmsdb.suggested_pick_lot s JOIN target_pol tp ON tp.id = s.pickOrderLineId WHERE s.deleted = false - GROUP BY s.pickOrderLineId, s.suggestedLotLineId + GROUP BY s.pickOrderLineId, COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) ) SELECT po.id as pickOrderId, @@ -150,7 +154,7 @@ LEFT JOIN ll ON ll.pickOrderLineId = pol.id LEFT JOIN sm ON sm.pickOrderLineId = pol.id - AND sm.suggestedLotLineId <=> ll.lotLineId + AND sm.displayLotLineId <=> ll.lotLineId LEFT JOIN fpsmsdb.suggested_pick_lot spl ON spl.id = sm.maxId AND spl.deleted = false diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index c38d793..e9151a4 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -45,6 +45,7 @@ import com.ffii.fpsms.modules.common.CodeGenerator import com.ffii.fpsms.modules.stock.web.model.BatchStockOutResult import com.ffii.fpsms.modules.stock.entity.StockLedger import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository +import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository @@ -2369,6 +2370,7 @@ private fun updateInventoryAfterLotLineChange(lotLine: InventoryLotLine) { e.printStackTrace() } } +/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ private fun createStockLedgerForStockOut( stockOutLine: StockOutLine, ledgerType: String? = null, @@ -2394,6 +2396,9 @@ private fun createStockLedgerForStockOut( val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return + val ill = stockOutLine.inventoryLotLine + val outQtyBd = BigDecimal.valueOf(outQty) + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val stockLedger = StockLedger().apply { this.stockOutLine = stockOutLine this.inventory = inventory @@ -2403,10 +2408,19 @@ private fun createStockLedgerForStockOut( this.type = ledgerTypeToUse this.itemId = item.id this.itemCode = item.code - this.uomId = inventory.uom?.id - ?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + inventory.uom?.id, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = stockLedger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = outQtyBd.negate(), + ) stockLedgerRepository.saveAndFlush(stockLedger) @@ -2417,8 +2431,6 @@ private fun createStockLedgerForStockOut( println("===========================") } - - @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) private fun handleBadItemPackageProblem(request: PickExecutionIssueRequest, badItemQty: BigDecimal) { println("=== HANDLING BAD ITEM PACKAGE PROBLEM (DON'T REJECT LOT) ===") @@ -2863,6 +2875,7 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse } // New: Create stock ledger with explicit balance (to avoid double subtraction) +/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ private fun createStockLedgerForStockOutWithBalance( stockOutLine: StockOutLine, balance: Double, @@ -2880,6 +2893,9 @@ private fun createStockLedgerForStockOutWithBalance( val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return + val ill = stockOutLine.inventoryLotLine + val outQtyBd = BigDecimal.valueOf(outQty) + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val stockLedger = StockLedger().apply { this.stockOutLine = stockOutLine this.inventory = inventory @@ -2889,10 +2905,19 @@ private fun createStockLedgerForStockOutWithBalance( this.type = ledgerTypeToUse this.itemId = item.id this.itemCode = item.code - this.uomId = inventory.uom?.id - ?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + inventory.uom?.id, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = stockLedger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = outQtyBd.negate(), + ) stockLedgerRepository.saveAndFlush(stockLedger) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt index a8eb5b2..1856056 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt @@ -3804,8 +3804,10 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map ll.lotLineId -- lots 用(保留原逻辑) LEFT JOIN fpsmsdb.stock_out_line sol @@ -4018,6 +4022,7 @@ println("DEBUG sol polIds in linesResults: " + linesResults.mapNotNull { it["sto ) } */ +/** FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03 */ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map { val user = userService.find(userId).orElse(null) if (user == null) { @@ -4162,15 +4167,19 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map ll.lotLineId LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND ( (sol.inventoryLotLineId = ll.lotLineId) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt index 00250e1..6cc90c6 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt @@ -145,6 +145,7 @@ open class PickOrderWorkbenchService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03 * Consumable workbench hierarchical payload for Tab3. * Uses no-hold available qty semantics (inQty - outQty). */ @@ -275,6 +276,7 @@ open class PickOrderWorkbenchService( /** * Workbench line detail V2 (consumable): + * FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03 * - Returns only lot rows already linked by suggestion/stock_out_line for this pick order line. * - Uses no-hold availability semantics: available = inQty - outQty. * - Includes no-lot stock_out_line rows. @@ -306,14 +308,14 @@ open class PickOrderWorkbenchService( LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId LEFT JOIN ( SELECT - s.suggestedLotLineId AS lotLineId, + COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) AS lotLineId, MAX(s.id) AS suggestedPickLotId, SUM(COALESCE(s.qty,0)) AS requiredQty FROM fpsmsdb.suggested_pick_lot s WHERE s.pickOrderLineId = :pickOrderLineId AND s.deleted = false - AND s.suggestedLotLineId IS NOT NULL - GROUP BY s.suggestedLotLineId + AND COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) IS NOT NULL + GROUP BY COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) ) spl ON spl.lotLineId = ill.id LEFT JOIN ( SELECT @@ -328,11 +330,11 @@ open class PickOrderWorkbenchService( GROUP BY so.inventoryLotLineId ) sol ON sol.lotLineId = ill.id WHERE ill.id IN ( - SELECT DISTINCT s.suggestedLotLineId + SELECT DISTINCT COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) FROM fpsmsdb.suggested_pick_lot s WHERE s.pickOrderLineId = :pickOrderLineId AND s.deleted = false - AND s.suggestedLotLineId IS NOT NULL + AND COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) IS NOT NULL UNION SELECT DISTINCT so.inventoryLotLineId FROM fpsmsdb.stock_out_line so diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt index 46845ce..ce34319 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt @@ -55,4 +55,72 @@ interface ProductProcessRepository : JpaRepository, JpaSpe @Param("useBomIds") useBomIds: Boolean, @Param("bomIds") bomIds: Collection, ): List + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 + * Production list lookback: inclusive date range on ProductProcess.date. + */ + @Query( + """ + SELECT + p.jobOrder.id AS jobOrderId, + MAX(p.date) AS maxDate, + MIN(COALESCE(p.productionPriority, 2147483647)) AS minPriority + FROM ProductProcess p + WHERE p.deleted = false + AND p.jobOrder IS NOT NULL + AND COALESCE(p.jobOrder.isHidden, false) = false + AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.PLANNING + AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.COMPLETED + AND (:dateFrom IS NULL OR p.date >= :dateFrom) + AND (:dateTo IS NULL OR p.date <= :dateTo) + AND (:itemCode IS NULL OR LOWER(COALESCE(p.item.code, '')) LIKE LOWER(CONCAT('%', :itemCode, '%'))) + AND (:jobOrderCode IS NULL OR LOWER(COALESCE(p.jobOrder.code, '')) LIKE LOWER(CONCAT('%', :jobOrderCode, '%'))) + AND (:bomType IS NULL OR LOWER(COALESCE(p.bom.type, '')) = LOWER(:bomType)) + AND (:useBomIds = false OR p.bom.id IN :bomIds) + AND (:excludeCompletedProcess = false OR p.status <> com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus.COMPLETED) + GROUP BY p.jobOrder.id + ORDER BY MAX(p.date) DESC, MIN(COALESCE(p.productionPriority, 2147483647)) ASC + """ + ) + fun findCandidateJobOrderAggregatesByDateRange( + @Param("dateFrom") dateFrom: LocalDate?, + @Param("dateTo") dateTo: LocalDate?, + @Param("itemCode") itemCode: String?, + @Param("jobOrderCode") jobOrderCode: String?, + @Param("bomType") bomType: String?, + @Param("useBomIds") useBomIds: Boolean, + @Param("bomIds") bomIds: Collection, + @Param("excludeCompletedProcess") excludeCompletedProcess: Boolean, + ): List + + @Query( + """ + SELECT + p.jobOrder.id AS jobOrderId, + MAX(p.date) AS maxDate, + MIN(COALESCE(p.productionPriority, 2147483647)) AS minPriority + FROM ProductProcess p + WHERE p.deleted = false + AND p.jobOrder IS NOT NULL + AND p.jobOrder.id IN :jobOrderIds + AND COALESCE(p.jobOrder.isHidden, false) = false + AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.PLANNING + AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.COMPLETED + AND (:itemCode IS NULL OR LOWER(COALESCE(p.item.code, '')) LIKE LOWER(CONCAT('%', :itemCode, '%'))) + AND (:jobOrderCode IS NULL OR LOWER(COALESCE(p.jobOrder.code, '')) LIKE LOWER(CONCAT('%', :jobOrderCode, '%'))) + AND (:bomType IS NULL OR LOWER(COALESCE(p.bom.type, '')) = LOWER(:bomType)) + AND (:useBomIds = false OR p.bom.id IN :bomIds) + GROUP BY p.jobOrder.id + """ + ) + /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */ + fun findCandidateJobOrderAggregatesByJobOrderIds( + @Param("jobOrderIds") jobOrderIds: Collection, + @Param("itemCode") itemCode: String?, + @Param("jobOrderCode") jobOrderCode: String?, + @Param("bomType") bomType: String?, + @Param("useBomIds") useBomIds: Boolean, + @Param("bomIds") bomIds: Collection, + ): List } \ No newline at end of file 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 94a6b8f..f52fd6e 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 @@ -1460,6 +1460,7 @@ open class ProductProcessService( itemName = productProcesses.item?.name, itemCode = productProcesses.item?.code, bomDescription = productProcesses.bom?.description, + bomType = productProcesses.bom?.type, pickOrderId = pickOrder?.id, pickOrderStatus = pickOrder?.status?.value, jobOrderId = productProcesses.jobOrder?.id, @@ -1497,6 +1498,7 @@ open class ProductProcessService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 * * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 @@ -1511,7 +1513,9 @@ open class ProductProcessService( includePutaway: Boolean?, putawayStatus: String?, page: Int, - size: Int + size: Int, + lookbackDays: Int? = null, + bucket: String? = null, ): JobOrderProductProcessPageResponse { val safeSize = if (size <= 0) 50 else size val safePage = if (page < 0) 0 else page @@ -1522,16 +1526,82 @@ open class ProductProcessService( val normalizedBomIds = bomIds?.distinct().orEmpty() val useBomIds = normalizedBomIds.isNotEmpty() val bomIdsForQuery = if (useBomIds) normalizedBomIds else listOf(-1L) + val normalizedBucket = bucket?.trim()?.lowercase()?.takeIf { it.isNotBlank() && it != "all" } + val useLookback = lookbackDays != null && lookbackDays >= 0 && date != null + /** Production list: unfinished process tabs + picked-not-started (capped at search date). */ + val useProductionLookback = useLookback && qcReady != true + /** Waiting QC putaway: date lookback only (completed process OK), no pick buckets. */ + val usePutawayLookback = useLookback && qcReady == true // 1) DB 端先完成候選 jobOrder 篩選 + 分組排序,避免全表拉回 JVM - val candidateAggregates = productProcessRepository.findCandidateJobOrderAggregates( - date = date, - itemCode = trimmedItemCode, - jobOrderCode = trimmedJobOrderCode, - bomType = normalizedType, - useBomIds = useBomIds, - bomIds = bomIdsForQuery, - ) + val candidateAggregates = if (useProductionLookback) { + val dateFrom = date!!.minusDays(lookbackDays!!.toLong()) + val rangeAggs = productProcessRepository.findCandidateJobOrderAggregatesByDateRange( + dateFrom = dateFrom, + dateTo = date, + itemCode = trimmedItemCode, + jobOrderCode = trimmedJobOrderCode, + bomType = normalizedType, + useBomIds = useBomIds, + bomIds = bomIdsForQuery, + excludeCompletedProcess = true, + ) + // Picked-not-started: still merge outside lookback window, but never after search date + // (avoids future plan dates appearing when searching a past day). + val unlimitedPickedNotStartedIds = pickOrderRepository.findPickedNotStartedJobOrderIds() + val extraAggs = if (unlimitedPickedNotStartedIds.isEmpty()) { + emptyList() + } else { + productProcessRepository.findCandidateJobOrderAggregatesByJobOrderIds( + jobOrderIds = unlimitedPickedNotStartedIds, + itemCode = trimmedItemCode, + jobOrderCode = trimmedJobOrderCode, + bomType = normalizedType, + useBomIds = useBomIds, + bomIds = bomIdsForQuery, + ).filter { agg -> + val maxDate = agg.maxDate + maxDate == null || !maxDate.isAfter(date) + } + } + val byId = LinkedHashMap() + (rangeAggs + extraAggs).forEach { agg -> + val existing = byId[agg.jobOrderId] + if (existing == null) { + byId[agg.jobOrderId] = agg + } else { + val maxDate = listOfNotNull(existing.maxDate, agg.maxDate).maxOrNull() + val minPriority = listOfNotNull(existing.minPriority, agg.minPriority).minOrNull() + byId[agg.jobOrderId] = object : JobOrderProcessAggregate { + override val jobOrderId = agg.jobOrderId + override val maxDate = maxDate + override val minPriority = minPriority + } + } + } + byId.values.toList() + } else if (usePutawayLookback) { + val dateFrom = date!!.minusDays(lookbackDays!!.toLong()) + productProcessRepository.findCandidateJobOrderAggregatesByDateRange( + dateFrom = dateFrom, + dateTo = date, + itemCode = trimmedItemCode, + jobOrderCode = trimmedJobOrderCode, + bomType = normalizedType, + useBomIds = useBomIds, + bomIds = bomIdsForQuery, + excludeCompletedProcess = false, + ) + } else { + productProcessRepository.findCandidateJobOrderAggregates( + date = date, + itemCode = trimmedItemCode, + jobOrderCode = trimmedJobOrderCode, + bomType = normalizedType, + useBomIds = useBomIds, + bomIds = bomIdsForQuery, + ) + } val candidateJobOrderIds = candidateAggregates.map { it.jobOrderId } if (candidateJobOrderIds.isEmpty()) { @@ -1539,7 +1609,10 @@ open class ProductProcessService( content = emptyList(), totalJobOrders = 0L, page = safePage, - size = safeSize + size = safeSize, + bucketCounts = if (useProductionLookback) JobOrderProductProcessBucketCounts() else null, + searchDate = date?.toString(), + carriedOverCount = if (useLookback) 0L else null, ) } @@ -1625,19 +1698,104 @@ open class ProductProcessService( } val filteredQcKeys = qcKeys.filter { key -> qcReady == null || key.ready == qcReady } - val totalJobOrders = filteredQcKeys.size.toLong() - val sortedQcKeys = filteredQcKeys.sortedWith( - compareByDescending { it.maxDate }.thenBy { it.minPriority } + val processByJobOrderIdForBucket = if (useProductionLookback) { + productProcessRepository + .findByJobOrder_IdInAndDeletedIsFalse(filteredQcKeys.map { it.jobOrderId }) + .groupBy { it.jobOrder?.id } + .mapNotNull { (joId, list) -> + if (joId == null) null + else joId to (list.firstOrNull { it.status != ProductProcessStatus.COMPLETED } ?: list.first()) + } + .toMap() + } else emptyMap() + + val pickedJoIds = if (useProductionLookback && filteredQcKeys.isNotEmpty()) { + pickOrderRepository.findPickedJobOrderIds(filteredQcKeys.map { it.jobOrderId }).toSet() + } else emptySet() + + fun classifyBucket(jobOrderId: Long): String? { + val pp = processByJobOrderIdForBucket[jobOrderId] ?: return null + val status = pp.status + if (status == ProductProcessStatus.COMPLETED) return null + val started = + status == ProductProcessStatus.IN_PROGRESS || + status == ProductProcessStatus.STOPPED || + pp.startTime != null + val notStarted = status == ProductProcessStatus.PENDING && pp.startTime == null + val picked = pickedJoIds.contains(jobOrderId) + return when { + notStarted && !picked -> "not_picked_not_started" + notStarted && picked -> "picked_not_started" + started && picked -> "picked_started" + started && !picked -> "not_picked_started" + else -> null + } + } + + val keysWithBucket = if (useProductionLookback) { + filteredQcKeys.mapNotNull { key -> + val b = classifyBucket(key.jobOrderId) ?: return@mapNotNull null + key to b + } + } else { + filteredQcKeys.map { it to null as String? } + } + + val bucketCounts = if (useProductionLookback) { + JobOrderProductProcessBucketCounts( + notPickedNotStarted = keysWithBucket.count { it.second == "not_picked_not_started" }.toLong(), + pickedNotStarted = keysWithBucket.count { it.second == "picked_not_started" }.toLong(), + pickedStarted = keysWithBucket.count { it.second == "picked_started" }.toLong(), + notPickedStarted = keysWithBucket.count { it.second == "not_picked_started" }.toLong(), + ) + } else null + + fun matchesMergedBucket(fine: String?, merged: String): Boolean { + return when (merged) { + "pending" -> + fine == "not_picked_not_started" || fine == "picked_not_started" + "processing" -> + fine == "picked_started" || fine == "not_picked_started" + else -> fine == merged + } + } + + val afterBucketFilter = if (normalizedBucket != null && useProductionLookback) { + keysWithBucket.filter { matchesMergedBucket(it.second, normalizedBucket) } + } else { + keysWithBucket + } + + val totalJobOrders = afterBucketFilter.size.toLong() + val carriedOverCount = if (useLookback && date != null) { + afterBucketFilter.count { (key, _) -> + val d = key.maxDate + d != null && d.isBefore(date) + }.toLong() + } else null + + val sortedPairs = afterBucketFilter.sortedWith( + compareByDescending> { it.first.maxDate }.thenBy { it.first.minPriority } ) val from = safePage * safeSize - if (from >= sortedQcKeys.size) { - return JobOrderProductProcessPageResponse(emptyList(), totalJobOrders, safePage, safeSize) + if (from >= sortedPairs.size) { + return JobOrderProductProcessPageResponse( + content = emptyList(), + totalJobOrders = totalJobOrders, + page = safePage, + size = safeSize, + bucketCounts = bucketCounts, + searchDate = date?.toString(), + carriedOverCount = carriedOverCount, + ) } - val to = minOf(from + safeSize, sortedQcKeys.size) - val pagedJobOrderIds = sortedQcKeys.subList(from, to).map { it.jobOrderId } + val to = minOf(from + safeSize, sortedPairs.size) + val pagedPairs = sortedPairs.subList(from, to) + val pagedJobOrderIds = pagedPairs.map { it.first.jobOrderId } + val bucketByJobOrderId = pagedPairs.associate { it.first.jobOrderId to it.second } val pagedJobOrderIdSet = pagedJobOrderIds.toSet() // 4) 只對當前頁資料做批量關聯查詢,並且排序/資料縮小盡量下推 DB @@ -1756,6 +1914,7 @@ open class ProductProcessService( itemName = productProcess.item?.name, itemCode = productProcess.item?.code, bomDescription = productProcess.bom?.description, + bomType = productProcess.bom?.type, pickOrderId = pickOrder?.id, pickOrderStatus = pickOrder?.status?.value, jobOrderId = productProcess.jobOrder?.id, @@ -1775,7 +1934,9 @@ open class ProductProcessService( endTime = line.endTime, status = line.status ?: "" ) - } + }, + isPicked = if (useProductionLookback) pickedJoIds.contains(jobOrderId) else null, + pickProcessBucket = if (useProductionLookback) bucketByJobOrderId[jobOrderId] else null, ) } @@ -1783,7 +1944,10 @@ open class ProductProcessService( content = content, totalJobOrders = totalJobOrders, page = safePage, - size = safeSize + size = safeSize, + bucketCounts = bucketCounts, + searchDate = date?.toString(), + carriedOverCount = carriedOverCount, ) } 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 0b948c8..350ce48 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,6 +200,7 @@ class ProductProcessController( return productProcessService.getAllJoborderProductProcessInfo(bomType) } + /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */ @GetMapping("/Demo/Process/search") fun demoprocesssearch( @RequestParam(required = false) date: String?, @@ -211,6 +212,14 @@ class ProductProcessController( /** all | completed | notCompleted */ @RequestParam(required = false) putawayStatus: String?, @RequestParam(name = "type", required = false) bomType: String?, + /** Production list: unfinished from (date - lookbackDays) .. date; picked_not_started merged but capped at date. */ + @RequestParam(required = false) lookbackDays: Int?, + /** + * all | pending | processing + * (pending = not started; processing = started; also accepts fine-grained pick buckets) + * Only applied when lookbackDays is set. + */ + @RequestParam(required = false) bucket: String?, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "50") size: Int ): JobOrderProductProcessPageResponse { @@ -233,7 +242,9 @@ class ProductProcessController( putawayStatus = putawayStatus, bomType = bomType, page = page, - size = size + size = size, + lookbackDays = lookbackDays, + bucket = bucket, ) } 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 d1d1361..ff80dcd 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 @@ -170,6 +170,8 @@ data class AllJoborderProductProcessInfoResponse( val itemName: String?, val itemCode: String?, val bomDescription: String?, + /** BOM.type (e.g. drink / Powder_Mixture / other). */ + val bomType: String? = null, val matchStatus: String?, val RequiredQty: Int?, val Uom: String?, @@ -187,14 +189,32 @@ data class AllJoborderProductProcessInfoResponse( val stockInLineStatus: String?, val TimeNeedToComplete: Int?, val isDrink: Boolean?, - val lines: List + val lines: List, + /** True when JO pick has at least one counted stock_out_line progress. */ + val isPicked: Boolean? = null, + /** + * Fine-grained pick/process bucket: + * not_picked_not_started | picked_not_started | picked_started | not_picked_started + */ + val pickProcessBucket: String? = null, +) + +data class JobOrderProductProcessBucketCounts( + val notPickedNotStarted: Long = 0, + val pickedNotStarted: Long = 0, + val pickedStarted: Long = 0, + val notPickedStarted: Long = 0, ) data class JobOrderProductProcessPageResponse( val content: List, val totalJobOrders: Long, val page: Int, - val size: Int + val size: Int, + val bucketCounts: JobOrderProductProcessBucketCounts? = null, + /** Search date echoed for carry-over UI (YYYY-MM-dd). */ + val searchDate: String? = null, + val carriedOverCount: Long? = null, ) /** diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/DoUserPickAuditReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/DoUserPickAuditReportService.kt new file mode 100644 index 0000000..0d57eae --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/DoUserPickAuditReportService.kt @@ -0,0 +1,204 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Service + +@Service +class DoUserPickAuditReportService( + private val jdbcDao: JdbcDao, +) { + /** + * FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 + * DO workbench user-audit rows (Just issue only). + * issueTypes may combine: 不正常已完成+超出提料+未完全提料+不正常換批. + * 未完全提料/超出提料 use POL-level SUM(stock_out_line.qty) vs pol.qty (multi-lot split OK if total matches). + * Only completed tickets (`dop.ticketStatus = completed`) are included. + * + * Perf: filter day/completed SOL first (`day_sol`) by pickTime only (idx_sol_pickTime_deleted), + * then SUM qty only for those POL ids (avoids full-table GROUP BY / OR-date full scan). + */ + fun searchDoUserPickAudit( + dateStart: String?, + dateEnd: String?, + userId: Long?, + handler: String?, + ticketNo: String?, + itemCode: String?, + storeId: String?, + ): List> { + val args = mutableMapOf() + + // Date filter on pickTime only (uses idx_sol_pickTime_deleted). + // ~1% completed SOL with null pickTime are excluded by design (OR fallback caused full scan ~20s+/day). + val dateStartSql = if (!dateStart.isNullOrBlank()) { + args["dateStart"] = dateStart.replace("/", "-") + "AND sol.pickTime >= CONCAT(:dateStart, ' 00:00:00')" + } else "" + + val dateEndSql = if (!dateEnd.isNullOrBlank()) { + args["dateEnd"] = dateEnd.replace("/", "-") + "AND sol.pickTime < DATE_ADD(CONCAT(:dateEnd, ' 00:00:00'), INTERVAL 1 DAY)" + } else "" + + val userSql = if (userId != null && userId > 0) { + args["userId"] = userId + "AND sol.handled_by = :userId" + } else "" + + val handlerSql = if (!handler.isNullOrBlank()) { + args["handler"] = handler.trim() + "AND TRIM(COALESCE(u.name, d.handlerName, '')) = :handler" + } else "" + + val ticketSql = if (!ticketNo.isNullOrBlank()) { + args["ticketNo"] = ticketNo.trim() + "AND dop.ticketNo LIKE CONCAT('%', :ticketNo, '%')" + } else "" + + val itemSql = if (!itemCode.isNullOrBlank()) { + args["itemCode"] = itemCode.trim() + "AND i.code LIKE CONCAT('%', :itemCode, '%')" + } else "" + + val storeSql = if (!storeId.isNullOrBlank()) { + args["storeId"] = storeId.trim().uppercase().replace("/", "").replace(" ", "") + """AND REPLACE(REPLACE(UPPER(IFNULL(dop.storeId,'')), '/', ''), ' ', '') = :storeId""" + } else "" + + val sql = """ + WITH day_sol AS ( + SELECT + sol.id AS solId, + sol.pickOrderLineId AS pickOrderLineId, + sol.itemId AS itemId, + sol.qty AS actualQty, + sol.inventoryLotLineId AS inventoryLotLineId, + sol.handled_by AS handledBy, + sol.isIssueJustComplete AS isIssueJustComplete, + sol.pickTime AS pickTime, + sol.endTime AS endTime, + sol.created AS created, + pol.qty AS polQty, + dop.ticketNo AS ticketNo, + dop.handlerName AS handlerName + FROM stock_out_line sol + INNER JOIN pick_order_line pol + ON pol.id = sol.pickOrderLineId AND IFNULL(pol.deleted, 0) = 0 + INNER JOIN pick_order po + ON po.id = pol.poId AND IFNULL(po.deleted, 0) = 0 + AND po.deliveryOrderPickOrderId IS NOT NULL + INNER JOIN delivery_order_pick_order dop + ON dop.id = po.deliveryOrderPickOrderId AND IFNULL(dop.deleted, 0) = 0 + WHERE IFNULL(sol.deleted, 0) = 0 + AND dop.ticketStatus = 'completed' + $dateStartSql + $dateEndSql + $userSql + $ticketSql + $storeSql + ), + pol_pick AS ( + SELECT s2.pickOrderLineId AS pickOrderLineId, + COALESCE(SUM(s2.qty), 0) AS pickedTotal + FROM stock_out_line s2 + WHERE IFNULL(s2.deleted, 0) = 0 + AND s2.pickOrderLineId IN (SELECT DISTINCT pickOrderLineId FROM day_sol) + GROUP BY s2.pickOrderLineId + ) + SELECT + DATE_FORMAT(COALESCE(d.pickTime, d.endTime, d.created), '%Y-%m-%d %H:%i:%s') AS datetime, + IFNULL(d.ticketNo, '') AS ticketNo, + IFNULL(i.code, '') AS itemCode, + IFNULL(i.name, '') AS itemName, + CASE WHEN IFNULL(i.isEgg, 0) = 1 THEN '是雞蛋類貨品' ELSE '不是雞蛋類貨品' END AS isEgg, + IFNULL(sug_lot.lotNo, '') AS suggestLot, + IFNULL(act_lot.lotNo, '') AS actualLot, + d.polQty AS qty, + d.actualQty AS actualQty, + sl.lotQtyBefore AS lotBeforeQty, + sl.lotQtyAfter AS lotAfterQty, + IFNULL(d.isIssueJustComplete, 0) AS issueJustComplete, + COALESCE(u.name, d.handlerName, '') AS user, + TRIM(BOTH '+' FROM CONCAT_WS('+', + CASE WHEN IFNULL(d.isIssueJustComplete, 0) = 1 THEN '不正常已完成' ELSE NULL END, + CASE WHEN d.polQty IS NOT NULL + AND COALESCE(pp.pickedTotal, 0) > d.polQty THEN '超出提料' ELSE NULL END, + CASE WHEN d.polQty IS NOT NULL + AND COALESCE(pp.pickedTotal, 0) < d.polQty + AND d.inventoryLotLineId IS NOT NULL THEN '未完全提料' ELSE NULL END, + CASE WHEN sug_lot.lotNo IS NOT NULL AND act_lot.lotNo IS NOT NULL + AND sug_lot.lotNo <> act_lot.lotNo THEN '不正常換批' ELSE NULL END + )) AS issueTypes, + d.solId AS stockOutLineId + FROM day_sol d + INNER JOIN items i + ON i.id = d.itemId AND IFNULL(i.deleted, 0) = 0 + LEFT JOIN pol_pick pp + ON pp.pickOrderLineId = d.pickOrderLineId + LEFT JOIN user u + ON u.id = d.handledBy AND IFNULL(u.deleted, 0) = 0 + LEFT JOIN suggested_pick_lot spl + ON spl.id = ( + SELECT MAX(s.id) + FROM suggested_pick_lot s + WHERE s.stockOutLineId = d.solId + AND IFNULL(s.deleted, 0) = 0 + ) + LEFT JOIN inventory_lot_line ill_sug + ON ill_sug.id = spl.suggestedLotLineId + AND IFNULL(ill_sug.deleted, 0) = 0 + LEFT JOIN inventory_lot sug_lot + ON sug_lot.id = ill_sug.inventoryLotId AND IFNULL(sug_lot.deleted, 0) = 0 + LEFT JOIN inventory_lot_line ill_act + ON ill_act.id = COALESCE(d.inventoryLotLineId, spl.actualPickLotLineId) + AND IFNULL(ill_act.deleted, 0) = 0 + LEFT JOIN inventory_lot act_lot + ON act_lot.id = ill_act.inventoryLotId AND IFNULL(act_lot.deleted, 0) = 0 + LEFT JOIN stock_ledger sl + ON sl.id = ( + SELECT MAX(sl2.id) + FROM stock_ledger sl2 + WHERE sl2.stockOutLineId = d.solId + AND IFNULL(sl2.deleted, 0) = 0 + AND sl2.outQty IS NOT NULL + AND sl2.outQty > 0 + ) + WHERE ( + IFNULL(d.isIssueJustComplete, 0) = 1 + OR (d.polQty IS NOT NULL AND COALESCE(pp.pickedTotal, 0) > d.polQty) + OR (d.polQty IS NOT NULL AND COALESCE(pp.pickedTotal, 0) < d.polQty + AND d.inventoryLotLineId IS NOT NULL) + OR ( + sug_lot.lotNo IS NOT NULL AND act_lot.lotNo IS NOT NULL + AND sug_lot.lotNo <> act_lot.lotNo + ) + ) + $handlerSql + $itemSql + ORDER BY COALESCE(d.pickTime, d.endTime, d.created) DESC, d.solId DESC + LIMIT 20000 + """.trimIndent() + + @Suppress("UNCHECKED_CAST") + return jdbcDao.queryForList(sql, args) as List> + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ + fun getDistinctHandlers(): List { + val sql = """ + SELECT DISTINCT TRIM(COALESCE(u.name, dop.handlerName, '')) AS handler + FROM stock_out_line sol + INNER JOIN pick_order_line pol ON pol.id = sol.pickOrderLineId AND IFNULL(pol.deleted, 0) = 0 + INNER JOIN pick_order po ON po.id = pol.poId AND IFNULL(po.deleted, 0) = 0 + AND po.deliveryOrderPickOrderId IS NOT NULL + INNER JOIN delivery_order_pick_order dop ON dop.id = po.deliveryOrderPickOrderId AND IFNULL(dop.deleted, 0) = 0 + LEFT JOIN user u ON u.id = sol.handled_by AND IFNULL(u.deleted, 0) = 0 + WHERE IFNULL(sol.deleted, 0) = 0 + AND TRIM(COALESCE(u.name, dop.handlerName, '')) <> '' + ORDER BY handler + """.trimIndent() + return jdbcDao.queryForList(sql, emptyMap()) + .map { (it["handler"]?.toString() ?: "").trim() } + .filter { it.isNotBlank() } + } +} 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 new file mode 100644 index 0000000..7319118 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt @@ -0,0 +1,281 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Service +import java.math.BigDecimal + +/** + * Shop Orders Replenishment Records (店鋪訂單補貨記錄). + * + * Only completed replenishments with a linked ticket are included. + * Qty columns are loaded in a second pass for the filtered row ids only + * (avoids full-table stock_out_line / pick_order GROUP BY). + */ +@Service +class ShopOrderReplenishmentReportService( + private val jdbcDao: JdbcDao, +) { + fun searchShopOrderReplenishmentReport( + reorderDateStart: String?, + reorderDateEnd: String?, + shopOrderDateStart: String?, + shopOrderDateEnd: String?, + deliveredDateStart: String?, + deliveredDateEnd: String?, + shopCode: String?, + ): Map { + val args = mutableMapOf() + + val reorderDateStartSql = dateStartClause( + raw = reorderDateStart, + argName = "reorderDateStart", + columnExpr = "dr.created", + args = args, + ) + val reorderDateEndSql = dateEndClause( + raw = reorderDateEnd, + argName = "reorderDateEnd", + columnExpr = "dr.created", + args = args, + ) + + val shopOrderDateStartSql = dateStartClause( + raw = shopOrderDateStart, + argName = "shopOrderDateStart", + columnExpr = "sourceDo.estimatedArrivalDate", + args = args, + ) + val shopOrderDateEndSql = dateEndClause( + raw = shopOrderDateEnd, + argName = "shopOrderDateEnd", + columnExpr = "sourceDo.estimatedArrivalDate", + args = args, + ) + + val deliveredDateStartSql = if (!deliveredDateStart.isNullOrBlank()) { + args["deliveredDateStart"] = normalizeDate(deliveredDateStart) + "AND dop.requiredDeliveryDate >= :deliveredDateStart" + } else { + "" + } + val deliveredDateEndSql = if (!deliveredDateEnd.isNullOrBlank()) { + args["deliveredDateEnd"] = normalizeDate(deliveredDateEnd) + "AND dop.requiredDeliveryDate <= :deliveredDateEnd" + } else { + "" + } + + val shopCodeSql = if (!shopCode.isNullOrBlank()) { + args["shopCode"] = shopCode.trim() + "AND dr.shopCode LIKE CONCAT('%', :shopCode, '%')" + } else { + "" + } + + // Pass 1: filter do_replenishment only (no full-table SOL aggregation). + val baseSql = """ + SELECT + dr.id AS replenishmentId, + dr.shopCode AS shopNo, + dr.shopName AS shopName, + DATE_FORMAT(sourceDo.estimatedArrivalDate, '%Y-%m-%d') AS shopOrderDate, + sourceDo.code AS shopOrderNo, + dr.itemNo AS itemNo, + dr.itemName AS itemName, + dol.qty AS firstOrderQty, + dr.replenishQty AS reorderQty, + DATE_FORMAT(dr.created, '%Y-%m-%d') AS reorderDate, + dr.reason AS reason, + DATE_FORMAT(dop.requiredDeliveryDate, '%Y-%m-%d') AS deliveredDate, + dr.sourceDoId AS sourceDoId, + dr.itemId AS itemId, + dr.pickOrderLineId AS pickOrderLineId + FROM do_replenishment dr + INNER JOIN delivery_order sourceDo + ON sourceDo.id = dr.sourceDoId + AND IFNULL(sourceDo.deleted, 0) = 0 + LEFT JOIN delivery_order_line dol + ON dol.id = dr.sourceDoLineId + AND IFNULL(dol.deleted, 0) = 0 + INNER JOIN delivery_order_pick_order dop + ON dop.id = dr.deliveryOrderPickOrderId + AND IFNULL(dop.deleted, 0) = 0 + WHERE IFNULL(dr.deleted, 0) = 0 + AND dr.status = 'completed' + AND dr.deliveryOrderPickOrderId IS NOT NULL + AND dr.pickOrderLineId IS NOT NULL + $reorderDateStartSql + $reorderDateEndSql + $shopOrderDateStartSql + $shopOrderDateEndSql + $deliveredDateStartSql + $deliveredDateEndSql + $shopCodeSql + ORDER BY + dr.shopCode ASC, + DATE(sourceDo.estimatedArrivalDate) ASC, + sourceDo.code ASC, + dr.itemNo ASC, + dr.id ASC + """.trimIndent() + + @Suppress("UNCHECKED_CAST") + val baseRows = jdbcDao.queryForList(baseSql, args) as List> + if (baseRows.isEmpty()) { + return mapOf("rows" to emptyList>()) + } + + val pickOrderLineIds = baseRows.mapNotNull { longVal(it["pickOrderLineId"]) }.distinct() + val sourceDoIds = baseRows.mapNotNull { longVal(it["sourceDoId"]) }.distinct() + val itemIds = baseRows.mapNotNull { longVal(it["itemId"]) }.distinct() + + val actualDeliveredByPolId = loadActualDeliveredQtyByPickOrderLineId(pickOrderLineIds) + val firstOrderPickBySourceKey = loadFirstOrderActualPickQty(sourceDoIds, itemIds, pickOrderLineIds) + + val rows = baseRows.map { row -> + val polId = longVal(row["pickOrderLineId"]) + val sourceDoId = longVal(row["sourceDoId"]) + val itemId = longVal(row["itemId"]) + val sourceKey = + if (sourceDoId != null && itemId != null) sourceDoId to itemId else null + + linkedMapOf( + "shopNo" to row["shopNo"], + "shopName" to row["shopName"], + "shopOrderDate" to row["shopOrderDate"], + "shopOrderNo" to row["shopOrderNo"], + "itemNo" to row["itemNo"], + "itemName" to row["itemName"], + "firstOrderQty" to row["firstOrderQty"], + "firstOrderActualPickQty" to (sourceKey?.let { firstOrderPickBySourceKey[it] } ?: BigDecimal.ZERO), + "reorderQty" to row["reorderQty"], + "reorderDate" to row["reorderDate"], + "reason" to row["reason"], + "actualDeliveredQty" to (polId?.let { actualDeliveredByPolId[it] } ?: BigDecimal.ZERO), + "deliveredDate" to row["deliveredDate"], + ) + } + + return mapOf("rows" to rows) + } + + /** Sum stock_out_line.qty only for the given replenishment pickOrderLineIds. */ + private fun loadActualDeliveredQtyByPickOrderLineId( + pickOrderLineIds: List, + ): Map { + if (pickOrderLineIds.isEmpty()) return emptyMap() + + val args = mapOf("polIds" to pickOrderLineIds) + val sql = """ + SELECT + sol.pickOrderLineId AS pickOrderLineId, + SUM(sol.qty) AS actualDeliveredQty + FROM stock_out_line sol + WHERE IFNULL(sol.deleted, 0) = 0 + AND sol.pickOrderLineId IN (:polIds) + GROUP BY sol.pickOrderLineId + """.trimIndent() + + @Suppress("UNCHECKED_CAST") + val rows = jdbcDao.queryForList(sql, args) as List> + return rows.mapNotNull { row -> + val polId = longVal(row["pickOrderLineId"]) ?: return@mapNotNull null + polId to decimalVal(row["actualDeliveredQty"]) + }.toMap() + } + + /** + * Sum stock_out_line.qty for source DO pick order lines of the same item, + * excluding any POL that is itself a replenishment line (incl. current report POLs). + */ + private fun loadFirstOrderActualPickQty( + sourceDoIds: List, + itemIds: List, + excludePickOrderLineIds: List, + ): Map, BigDecimal> { + if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap() + + val args = mutableMapOf( + "sourceDoIds" to sourceDoIds, + "itemIds" to itemIds, + ) + val excludeSql = if (excludePickOrderLineIds.isNotEmpty()) { + args["excludePolIds"] = excludePickOrderLineIds + "AND pol.id NOT IN (:excludePolIds)" + } else { + "" + } + + val sql = """ + SELECT + po.doId AS sourceDoId, + pol.itemId AS itemId, + SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty + FROM pick_order po + INNER JOIN pick_order_line pol + ON pol.poId = po.id + AND IFNULL(pol.deleted, 0) = 0 + LEFT JOIN stock_out_line sol + ON sol.pickOrderLineId = pol.id + AND IFNULL(sol.deleted, 0) = 0 + WHERE IFNULL(po.deleted, 0) = 0 + AND po.doId IN (:sourceDoIds) + AND pol.itemId IN (:itemIds) + $excludeSql + AND NOT EXISTS ( + SELECT 1 + FROM do_replenishment r2 + WHERE r2.pickOrderLineId = pol.id + AND IFNULL(r2.deleted, 0) = 0 + ) + GROUP BY po.doId, pol.itemId + """.trimIndent() + + @Suppress("UNCHECKED_CAST") + val rows = jdbcDao.queryForList(sql, args) as List> + 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"]) + }.toMap() + } + + private fun normalizeDate(raw: String): String = raw.trim().replace("/", "-") + + private fun dateStartClause( + raw: String?, + argName: String, + columnExpr: String, + args: MutableMap, + ): String { + if (raw.isNullOrBlank()) return "" + args[argName] = normalizeDate(raw) + return "AND $columnExpr >= CONCAT(:$argName, ' 00:00:00')" + } + + private fun dateEndClause( + raw: String?, + argName: String, + columnExpr: String, + args: MutableMap, + ): String { + if (raw.isNullOrBlank()) return "" + args[argName] = normalizeDate(raw) + return "AND $columnExpr < DATE_ADD(CONCAT(:$argName, ' 00:00:00'), INTERVAL 1 DAY)" + } + + private fun longVal(value: Any?): Long? = + when (value) { + null -> null + is Number -> value.toLong() + else -> value.toString().toLongOrNull() + } + + private fun decimalVal(value: Any?): BigDecimal = + when (value) { + null -> BigDecimal.ZERO + is BigDecimal -> value + is Number -> BigDecimal(value.toString()) + else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/DoUserPickAuditReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/DoUserPickAuditReportController.kt new file mode 100644 index 0000000..df21292 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/DoUserPickAuditReportController.kt @@ -0,0 +1,140 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.DoUserPickAuditReportService +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 DoUserPickAuditReportController( + private val doUserPickAuditReportService: DoUserPickAuditReportService, +) { + /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ + @GetMapping("/do-user-pick-audit-handlers") + fun handlers(): List = doUserPickAuditReportService.getDistinctHandlers() + + /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ + @GetMapping("/print-do-user-pick-audit-excel") + fun exportExcel( + @RequestParam(required = false) dateStart: String?, + @RequestParam(required = false) dateEnd: String?, + @RequestParam(required = false) userId: Long?, + @RequestParam(required = false) handler: String?, + @RequestParam(required = false) ticketNo: String?, + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) storeId: String?, + ): ResponseEntity { + val rows = doUserPickAuditReportService.searchDoUserPickAudit( + dateStart = dateStart, + dateEnd = dateEnd, + userId = userId, + handler = handler, + ticketNo = ticketNo, + itemCode = itemCode, + storeId = storeId, + ) + val bytes = buildExcel(rows) + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + setContentDispositionFormData("attachment", "DoUserPickAudit.xlsx") + set("filename", "DoUserPickAudit.xlsx") + } + return ResponseEntity(bytes, headers, HttpStatus.OK) + } + + private fun buildExcel(rows: List>): ByteArray { + val workbook = XSSFWorkbook() + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName("DO揀貨人員稽核")) + 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 + } + + // key = SQL alias; label = Chinese Excel header + val columns = listOf( + "datetime" to "完成時間", + "ticketNo" to "提票號碼", + "itemCode" to "貨品編號", + "itemName" to "貨品名稱", + "isEgg" to "是否雞蛋類", + "suggestLot" to "建議批號", + "actualLot" to "實際批號", + "qty" to "所需數量", + "actualQty" to "實際提料數量", + "lotBeforeQty" to "提料前庫存", + "lotAfterQty" to "提料後庫存", + "issueJustComplete" to "不正常已完成", + "user" to "提料人", + "issueTypes" 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 -> { + if (key == "issueJustComplete") { + cell.setCellValue(if (v.toDouble() != 0.0) "是" else "否") + } else { + cell.setCellValue(v.toDouble()) + } + } + is Boolean -> cell.setCellValue(if (v) "是" else "否") + else -> cell.setCellValue(v.toString()) + } + } + } + + // Fixed widths (avoid slow autoSize on large sheets) + val widths = intArrayOf(18, 14, 14, 28, 16, 16, 16, 12, 12, 12, 12, 14, 12, 20) + 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/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index 9fbd9bc..fc24362 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -24,12 +24,14 @@ import java.time.format.DateTimeFormatter import com.ffii.fpsms.modules.common.SecurityUtils import com.ffii.fpsms.modules.report.service.M18BomShopSyncReportService import com.ffii.fpsms.modules.report.service.ReportService +import com.ffii.fpsms.modules.report.service.ShopOrderReplenishmentReportService @RestController @RequestMapping("/report") class ReportController( private val reportService: ReportService, private val m18BomShopSyncReportService: M18BomShopSyncReportService, + private val shopOrderReplenishmentReportService: ShopOrderReplenishmentReportService, ) { private data class ExcelStyles( val title: XSSFCellStyle, @@ -1001,6 +1003,32 @@ class ReportController( syncStatus = syncStatus, ) + /** + * Shop Orders Replenishment Records (店鋪訂單補貨記錄). + * JSON for Excel export: completed replenishments with linked ticket only. + * + * Example: `/report/shop-order-replenishment?reorderDateStart=2026-08-01&shopCode=S001` + */ + @GetMapping("/shop-order-replenishment") + fun getShopOrderReplenishmentReport( + @RequestParam(required = false) reorderDateStart: String?, + @RequestParam(required = false) reorderDateEnd: String?, + @RequestParam(required = false) shopOrderDateStart: String?, + @RequestParam(required = false) shopOrderDateEnd: String?, + @RequestParam(required = false) deliveredDateStart: String?, + @RequestParam(required = false) deliveredDateEnd: String?, + @RequestParam(required = false) shopCode: String?, + ): Map = + shopOrderReplenishmentReportService.searchShopOrderReplenishmentReport( + reorderDateStart = reorderDateStart, + reorderDateEnd = reorderDateEnd, + shopOrderDateStart = shopOrderDateStart, + shopOrderDateEnd = shopOrderDateEnd, + deliveredDateStart = deliveredDateStart, + deliveredDateEnd = deliveredDateEnd, + shopCode = shopCode, + ) + companion object { /** GRN report fields only users with ADMIN authority may see */ private val GRN_FINANCIAL_KEYS = setOf("unitPrice", "lineAmount", "currencyCode") diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedger.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedger.kt index 5f868a8..16995c0 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedger.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedger.kt @@ -4,9 +4,11 @@ import com.ffii.core.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne import jakarta.persistence.OneToOne import jakarta.persistence.Table import jakarta.validation.constraints.NotNull +import java.math.BigDecimal import java.time.LocalDate @Entity @Table(name = "stock_ledger") @@ -19,6 +21,11 @@ open class StockLedger: BaseEntity() { @JoinColumn(name = "stockOutLineId") open var stockOutLine: StockOutLine? = null + /** Lot line for this movement; prefer this over resolving via stock in/out line. */ + @ManyToOne + @JoinColumn(name = "inventoryLotLineId") + open var inventoryLotLine: InventoryLotLine? = null + @NotNull @OneToOne @JoinColumn(name = "inventoryId") @@ -33,7 +40,15 @@ open class StockLedger: BaseEntity() { @Column(name = "balance") open var balance: Double? = null - /** 物料庫存單位對應之 uom_conversion.id(與 item_uom / inventory 之 stock UOM 一致) */ + /** Lot available qty (in−out) before this movement; null for pre-V2 rows. */ + @Column(name = "lotQtyBefore", precision = 14, scale = 2) + open var lotQtyBefore: BigDecimal? = null + + /** Lot available qty (in−out) after this movement; null for pre-V2 rows. */ + @Column(name = "lotQtyAfter", precision = 14, scale = 2) + open var lotQtyAfter: BigDecimal? = null + + /** Lot line stock UOM → uom.id(優先);無 lot 時才 fallback item/inventory stock UOM */ @Column(name = "uomId") open var uomId: Long? = null diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLine.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLine.kt index 8eda10d..25ec293 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLine.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLine.kt @@ -41,6 +41,13 @@ open class StockOutLine: BaseEntity() { @Column(name = "handled_by") open var handledBy: Long? = null + /** + * DO user-audit: Just Complete that should be reported because pickable stock still existed + * for the item at complete time. Null/false = not an issue JC (e.g. lot exhausted remainder). + */ + @Column(name = "isIssueJustComplete") + open var isIssueJustComplete: Boolean? = null + @JsonBackReference @ManyToOne @JoinColumn(name = "pickOrderLineId") diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestedPickLot.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestedPickLot.kt index dbaac21..7cb48d8 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestedPickLot.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestedPickLot.kt @@ -29,6 +29,11 @@ open class SuggestedPickLot: BaseEntity() { @JoinColumn(name = "suggestedLotLineId") open var suggestedLotLine: InventoryLotLine? = null + /** Actual picked lot line; switch/scan updates this without changing [suggestedLotLine]. */ + @ManyToOne + @JoinColumn(name = "actualPickLotLineId") + open var actualPickLotLine: InventoryLotLine? = null + @JsonBackReference @ManyToOne @JoinColumn(name = "pickOrderLineId") diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index b3fd967..fa4c1ab 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -1378,6 +1378,7 @@ open class StockInLineService( } @Transactional + /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ private fun createStockLedgerForStockIn(stockInLine: StockInLine, inQty: Double) { val _t0 = System.nanoTime() fun _msSince(t: Long): Double = (System.nanoTime() - t) / 1_000_000.0 @@ -1403,7 +1404,11 @@ open class StockInLineService( val newBalance = previousBalance + inQty val _tUom = System.nanoTime() - val stockUomId = itemUomService.findStockUnitByItemId(item.id!!)?.uom?.id ?: inventory.uom?.id + val stockUomId = StockLedgerLotSnapshot.resolveLedgerUomId( + stockInLine.inventoryLotLine, + itemUomService.findStockUnitByItemId(item.id!!)?.uom?.id, + inventory.uom?.id, + ) _logStep("itemUomService.findStockUnitByItemId", _tUom) val stockLedger = StockLedger().apply { @@ -1418,6 +1423,14 @@ open class StockInLineService( this.uomId = stockUomId this.date = LocalDate.now() } + val ill = stockInLine.inventoryLotLine + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } + StockLedgerLotSnapshot.apply( + ledger = stockLedger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = BigDecimal.valueOf(inQty), + ) val _tSave = System.nanoTime() stockLedgerRepository.saveAndFlush(stockLedger) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockLedgerLotSnapshot.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockLedgerLotSnapshot.kt new file mode 100644 index 0000000..88768a6 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockLedgerLotSnapshot.kt @@ -0,0 +1,50 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.stock.entity.InventoryLotLine +import com.ffii.fpsms.modules.stock.entity.StockLedger +import java.math.BigDecimal + +/** + * FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 + * Fills [StockLedger.inventoryLotLine], [lotQtyBefore], [lotQtyAfter]. + * Prefer explicit [inventoryLotLine]; else fall back to stockOutLine / stockInLine related lot. + * + * [lotAvailableAfterMove] = lot (in−out) **after** this movement has already been applied on the lot line. + * [signedDelta] = +inQty or −outQty for this ledger row (stock-in positive, stock-out negative of posted qty). + */ +object StockLedgerLotSnapshot { + /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ + fun apply( + ledger: StockLedger, + inventoryLotLine: InventoryLotLine? = null, + lotAvailableAfterMove: BigDecimal? = null, + signedDelta: BigDecimal? = null, + ) { + val ill = inventoryLotLine + ?: ledger.inventoryLotLine + ?: ledger.stockOutLine?.inventoryLotLine + ?: ledger.stockInLine?.inventoryLotLine + ledger.inventoryLotLine = ill + + if (ill == null || lotAvailableAfterMove == null || signedDelta == null) { + // Leave before/after null when we cannot snapshot (caller may set later). + return + } + ledger.lotQtyAfter = lotAvailableAfterMove + ledger.lotQtyBefore = lotAvailableAfterMove.subtract(signedDelta) + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ + fun availableQty(ill: InventoryLotLine): BigDecimal = + (ill.inQty ?: BigDecimal.ZERO).subtract(ill.outQty ?: BigDecimal.ZERO) + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 + * Ledger UOM must follow the lot line's stock ItemUom → uom.id + * (same item may have lots with different UOMs; item stockUnit flag can change later). + */ + fun resolveLedgerUomId(ill: InventoryLotLine?, vararg fallbacks: Long?): Long? { + ill?.stockUom?.uom?.id?.let { return it } + return fallbacks.firstOrNull { it != null } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index 03b76df..eea245b 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -1509,6 +1509,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { } } @Transactional + /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) { val item = stockOutLine.item ?: return val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return @@ -1519,6 +1520,9 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val newBalance = previousBalance - outQty + val ill = stockOutLine.inventoryLotLine + val outQtyBd = BigDecimal.valueOf(outQty) + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val stockLedger = StockLedger().apply { this.stockOutLine = stockOutLine this.inventory = inventory @@ -1528,10 +1532,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { this.type = stockOutLine.type this.itemId = item.id this.itemCode = item.code - this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id - ?: inventory.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, + inventory.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = stockLedger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = outQtyBd.negate(), + ) stockLedgerRepository.saveAndFlush(stockLedger) } @@ -1744,6 +1757,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { } + /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ private fun createStockLedgerForPickDelta( stockOutLine: StockOutLine, deltaQty: BigDecimal, @@ -1791,6 +1805,8 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { } } + val ill = stockOutLine.inventoryLotLine + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val ledger = StockLedger().apply { this.stockOutLine = stockOutLine this.inventory = inventory @@ -1800,10 +1816,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { this.type = "NOR" this.itemId = item.id this.itemCode = item.code - this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id - ?: inventory.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, + inventory.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = ledger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = deltaQty.negate(), + ) if (flushAfterSave) { stockLedgerRepository.saveAndFlush(ledger) @@ -2197,6 +2222,9 @@ fun applyStockOutLineDelta( val outQty = deltaQty.toDouble() val newBalance = previousBalance - outQty + val ill = savedSol.inventoryLotLine + val outQtyBd = BigDecimal.valueOf(outQty) + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val ledger = StockLedger().apply { this.stockOutLine = savedSol this.inventory = inventory @@ -2207,14 +2235,23 @@ fun applyStockOutLineDelta( this.itemId = item.id this.itemCode = item.code this.uomId = - itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id - ?: inventory.uom?.id + StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, + inventory.uom?.id, + ) this.date = eventTime.toLocalDate() if (!operator.isNullOrBlank()) { this.createdBy = operator this.modifiedBy = operator } } + StockLedgerLotSnapshot.apply( + ledger = ledger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = outQtyBd.negate(), + ) if (deferPersistenceFlush) stockLedgerRepository.save(ledger) else stockLedgerRepository.saveAndFlush(ledger) } @@ -2330,7 +2367,9 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult } val newBalance = prevBalance - delta.toDouble() - ledgersToInsert += StockLedger().apply { + val ill = sol.inventoryLotLine + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } + val ledger = StockLedger().apply { this.stockOutLine = sol this.inventory = inv this.inQty = null @@ -2339,10 +2378,20 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult this.type = request.type this.itemId = itemId this.itemCode = item.code - this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id - ?: inv.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id, + inv.uom?.id, + ) this.date = today } + StockLedgerLotSnapshot.apply( + ledger = ledger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = delta.negate(), + ) + ledgersToInsert += ledger runningLedgerBalanceByItemId[itemId] = newBalance } stockLedgerRepository.saveAll(ledgersToInsert) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt index 752022a..efcf4c3 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt @@ -198,7 +198,7 @@ open class StockOutLineWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 33 | v1.0.1 | 2026-07-22 + * FP-MTMS Version Checklist | Functions Ref. No. 33 | v1.0.2 | 2026-08-03 */ @Transactional(rollbackFor = [Exception::class]) open fun scanPick(request: WorkbenchScanPickRequest): MessageResponse { @@ -517,6 +517,9 @@ if (updated == 0) { val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val previousBalance = onHandAfter + deltaQty.toDouble() val newBalance = previousBalance - deltaQty.toDouble() + val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } + ?: sol.inventoryLotLine + val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val ledger = StockLedger().apply { this.stockOutLine = sol this.inventory = inventory @@ -526,10 +529,19 @@ if (updated == 0) { this.type = "NOR" this.itemId = solItem.id this.itemCode = solItem.code - this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id - ?: inventory.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + ill, + itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id, + inventory.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = ledger, + inventoryLotLine = ill, + lotAvailableAfterMove = lotAfter, + signedDelta = deltaQty.negate(), + ) stockLedgerRepository.save(ledger) } 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 635c8cc..c8b4f7f 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 @@ -2488,6 +2488,10 @@ private fun applyVarianceAdjustment( } val newBalance = previousBalance - qtyToRemove.toDouble() + // FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 + // Snapshot from live lot line (in−out) at approve time — not stock_take_record.bookQty. + val lotAfterAvail = StockLedgerLotSnapshot.availableQty(latestLine).subtract(qtyToRemove) + val stockLedger = StockLedger().apply { this.inventory = inventory this.itemId = inventoryLot.item?.id @@ -2497,9 +2501,18 @@ private fun applyVarianceAdjustment( this.stockOutLine = stockOutLine this.balance = newBalance this.type = "TKE" - this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + latestLine, + inventory.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = stockLedger, + inventoryLotLine = latestLine, + lotAvailableAfterMove = lotAfterAvail, + signedDelta = qtyToRemove.negate(), + ) if (context != null) { context.stockLedgers.add(stockLedger) @@ -2613,6 +2626,9 @@ private fun applyVarianceAdjustment( } val newBalance = previousBalance + plusQty.toDouble() + // latestLine.inQty already includes plusQty above — snapshot live lot line after move. + val lotAfterAvail = StockLedgerLotSnapshot.availableQty(latestLine) + val stockLedger = StockLedger().apply { this.inventory = inventory this.itemId = inventoryLot.item?.id @@ -2622,9 +2638,18 @@ private fun applyVarianceAdjustment( this.stockInLine = stockInLine this.balance = newBalance this.type = "TKE" - this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id + this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( + latestLine, + inventory.uom?.id, + ) this.date = LocalDate.now() } + StockLedgerLotSnapshot.apply( + ledger = stockLedger, + inventoryLotLine = latestLine, + lotAvailableAfterMove = lotAfterAvail, + signedDelta = plusQty, + ) if (context != null) { context.stockLedgers.add(stockLedger) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt index f5de253..50f02a6 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt @@ -70,6 +70,7 @@ open class SuggestedPickLotWorkbenchService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 * After workbench scan-pick updates [StockOutLine] qty/status, bind SPL and set **qty** to match SOL (已揀 + 進行中). */ @Transactional(rollbackFor = [Exception::class]) @@ -97,7 +98,14 @@ open class SuggestedPickLotWorkbenchService( } if (spl != null) { spl.stockOutLine = sol - spl.suggestedLotLine = ill + // Keep suggestedLotLine as the system suggestion; only record what was actually picked. + if (spl.suggestedLotLine == null && ill != null) { + spl.suggestedLotLine = ill + } + spl.actualPickLotLine = ill + if (spl.actualPickLotLine == null && spl.suggestedLotLine != null) { + spl.actualPickLotLine = spl.suggestedLotLine + } spl.pickOrderLine = pol spl.qty = qtyBd spl.type = SuggestedPickLotType.PICK_ORDER @@ -109,6 +117,7 @@ open class SuggestedPickLotWorkbenchService( type = SuggestedPickLotType.PICK_ORDER stockOutLine = sol suggestedLotLine = ill + actualPickLotLine = ill pickOrderLine = pol qty = qtyBd }, @@ -150,7 +159,7 @@ open class SuggestedPickLotWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 * Lot stock UOM must match pick_order_line.uom (UomConversion id). */ private fun matchesPolUom(lot: InventoryLotLine, line: PickOrderLine): Boolean { @@ -159,7 +168,7 @@ open class SuggestedPickLotWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 * Build suggestions with no-hold rule: * available = inQty - outQty (ignore holdQty). * When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id). @@ -292,7 +301,7 @@ open class SuggestedPickLotWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 * Same no-hold allocation as [rebuildNoHoldSuggestionsForPickOrder], but only for one [pickOrderLineId]. * Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order. */ @@ -384,7 +393,7 @@ open class SuggestedPickLotWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 * Workbench explicit-qty remainder support (single-next-lot): * Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot. * When [storeId] is provided, the next lot must be within the same store (warehouse.store_id). @@ -445,7 +454,7 @@ open class SuggestedPickLotWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 * Workbench explicit-qty split support: * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). * - If [targetQty] <= 0: soft-delete all active suggestions for the line. @@ -561,6 +570,7 @@ open class SuggestedPickLotWorkbenchService( } /** + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 * Align DB rows with [desired]. Returns count of newly inserted SPL rows. */ private fun syncSuggestedPickLotsForLine( @@ -579,6 +589,14 @@ open class SuggestedPickLotWorkbenchService( val reuse = keepers.maxByOrNull { it.id ?: 0L } if (reuse != null) { reuse.suggestedLotLine = ill + // Pending suggestion: keep actual in sync with suggested until user picks/switches. + if (reuse.actualPickLotLine == null || + reuse.actualPickLotLine?.id == null || + reuse.stockOutLine == null || + !isSplFrozen(reuse) + ) { + reuse.actualPickLotLine = ill + } reuse.pickOrderLine = line reuse.type = SuggestedPickLotType.PICK_ORDER reuse.qty = qty @@ -595,6 +613,7 @@ open class SuggestedPickLotWorkbenchService( SuggestedPickLot().apply { type = SuggestedPickLotType.PICK_ORDER suggestedLotLine = ill + actualPickLotLine = ill pickOrderLine = line this.qty = qty }, @@ -616,6 +635,12 @@ open class SuggestedPickLotWorkbenchService( val spl = byIllKey[k] if (spl != null) { spl.suggestedLotLine = ill + if (spl.actualPickLotLine == null || + spl.stockOutLine == null || + !isSplFrozen(spl) + ) { + spl.actualPickLotLine = ill + } spl.pickOrderLine = line spl.type = SuggestedPickLotType.PICK_ORDER spl.qty = qty @@ -629,6 +654,7 @@ open class SuggestedPickLotWorkbenchService( SuggestedPickLot().apply { type = SuggestedPickLotType.PICK_ORDER suggestedLotLine = ill + actualPickLotLine = ill pickOrderLine = line this.qty = qty }, diff --git a/src/main/resources/db/changelog/changes/20260723_do_user_pick_audit/01_add_audit_columns.sql b/src/main/resources/db/changelog/changes/20260723_do_user_pick_audit/01_add_audit_columns.sql new file mode 100644 index 0000000..9799f26 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260723_do_user_pick_audit/01_add_audit_columns.sql @@ -0,0 +1,21 @@ +--liquibase formatted sql + +--changeset fpsms:do_user_pick_audit_columns +--comment: DO user pick audit V2 — SPL actual lot, SOL just-complete flag, ledger lot qty + inventoryLotLineId + +ALTER TABLE `suggested_pick_lot` + ADD COLUMN `actualPickLotLineId` BIGINT NULL AFTER `suggestedLotLineId`; + +ALTER TABLE `stock_out_line` + ADD COLUMN `isIssueJustComplete` TINYINT(1) NULL DEFAULT NULL AFTER `handled_by`; + +ALTER TABLE `stock_ledger` + ADD COLUMN `inventoryLotLineId` BIGINT NULL AFTER `stockOutLineId`, + ADD COLUMN `lotQtyBefore` DECIMAL(14,2) NULL AFTER `balance`, + ADD COLUMN `lotQtyAfter` DECIMAL(14,2) NULL AFTER `lotQtyBefore`; + +--optional indexes for report +CREATE INDEX `idx_sol_pickTime_deleted` ON `stock_out_line` (`pickTime`, `deleted`); +CREATE INDEX `idx_sol_isIssueJustComplete` ON `stock_out_line` (`isIssueJustComplete`); +CREATE INDEX `idx_spl_actualPickLotLineId` ON `suggested_pick_lot` (`actualPickLotLineId`); +CREATE INDEX `idx_ledger_inventoryLotLineId` ON `stock_ledger` (`inventoryLotLineId`); diff --git a/src/main/resources/db/changelog/changes/20260724_do_user_pick_audit_perf/01_ledger_sol_index.sql b/src/main/resources/db/changelog/changes/20260724_do_user_pick_audit_perf/01_ledger_sol_index.sql new file mode 100644 index 0000000..7e7d86d --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260724_do_user_pick_audit_perf/01_ledger_sol_index.sql @@ -0,0 +1,10 @@ +--liquibase formatted sql + +--changeset fpsms:do_user_pick_audit_ledger_sol_index +--comment: Speed DO user-pick audit report — correlated MAX(ledger.id) by stockOutLineId + +CREATE INDEX `idx_ledger_stockOutLineId_deleted_id` + ON `stock_ledger` (`stockOutLineId`, `deleted`, `id`); + +CREATE INDEX `idx_spl_stockOutLineId_deleted_id` + ON `suggested_pick_lot` (`stockOutLineId`, `deleted`, `id`);