ledger lotQtyBefore/After + inventoryLotLineId 成品出倉員工問題報告 rep-016 Truck X 2F/4F suggestion Just Complete / isIssueJustComplete Workbench 略過 classic confirmLotSubstitution 停 JO planStart/renumber 生產流程 lookback + tabs/QC chip/Release→Cancel FP-MTMS code comments Report 店鋪訂單補貨記錄 工單生產流程 UI 再重構 工單提料列表顯示加強production
| @@ -69,6 +69,28 @@ open class DoFloorSupplierSettingsService( | |||||
| return null | 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?>): 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( | data class SqlPreferredFloorCases( | ||||
| /** 例如 `CASE WHEN s.code IN (...) THEN '4F' ... END`(單行,可嵌入原生 SQL) */ | /** 例如 `CASE WHEN s.code IN (...) THEN '4F' ... END`(單行,可嵌入原生 SQL) */ | ||||
| val floorStringCase: String, | val floorStringCase: String, | ||||
| @@ -23,6 +23,7 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| private val pickOrderRepository: PickOrderRepository, | private val pickOrderRepository: PickOrderRepository, | ||||
| private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | ||||
| private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, | private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, | ||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||||
| ) { | ) { | ||||
| @Transactional | @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 | * Same UX as [DoPickOrderAssignmentService.assignByLane] but candidates come from | ||||
| * [delivery_order_pick_order] (+ unassigned [pick_order]), not [do_pick_order]. | * [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). | * Legacy lane assign (V1): old FG-style (no atomic conflict guard). | ||||
| */ | */ | ||||
| @Transactional | @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( | val storeIdRow = jdbcDao.queryForList( | ||||
| "SELECT storeId FROM fpsmsdb.delivery_order_pick_order WHERE id = :id AND deleted = 0", | "SELECT storeId FROM fpsmsdb.delivery_order_pick_order WHERE id = :id AND deleted = 0", | ||||
| mapOf("id" to dopoId), | mapOf("id" to dopoId), | ||||
| ).firstOrNull() | ).firstOrNull() | ||||
| val storeIdKey = storeIdRow?.keys?.find { it.equals("storeId", true) } | 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) { | for (poId in poIds) { | ||||
| suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( | suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( | ||||
| pickOrderId = poId, | pickOrderId = poId, | ||||
| storeId = storeId, | |||||
| storeId = suggestionStoreId, | |||||
| excludeWarehouseCodes = null, | excludeWarehouseCodes = null, | ||||
| ) | ) | ||||
| stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(poId, userId) | 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) | |||||
| } | |||||
| } | } | ||||
| @@ -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.entity.projection.StockOutLineInfo | ||||
| import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService | import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService | ||||
| import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService | 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.service.WorkbenchStockOutLinePickProgress | ||||
| import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus | import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus | ||||
| import org.springframework.stereotype.Service | 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): | * Workbench scan-pick (DO FG): | ||||
| * 1) Post outbound on scanned inventory lot line first; on failure return a clear message. | * 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], | * 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 | sol.handledBy = request.userId | ||||
| if (sol.startTime == null) sol.startTime = LocalDateTime.now() | if (sol.startTime == null) sol.startTime = LocalDateTime.now() | ||||
| sol.endTime = LocalDateTime.now() | sol.endTime = LocalDateTime.now() | ||||
| maybeMarkIssueJustComplete(sol, request.justComplete == true) | |||||
| stockOutLIneRepository.saveAndFlush(sol) | stockOutLIneRepository.saveAndFlush(sol) | ||||
| val polZero = sol.pickOrderLine | val polZero = sol.pickOrderLine | ||||
| updateJoPickOrderHandledByIfJobOrder( | updateJoPickOrderHandledByIfJobOrder( | ||||
| @@ -618,6 +621,7 @@ if (sol.startTime == null) sol.startTime = LocalDateTime.now() | |||||
| if (solEndStatus.equals(StockOutLineStatus.COMPLETE.status, ignoreCase = true)) { | if (solEndStatus.equals(StockOutLineStatus.COMPLETE.status, ignoreCase = true)) { | ||||
| sol.endTime = LocalDateTime.now() | sol.endTime = LocalDateTime.now() | ||||
| } | } | ||||
| maybeMarkIssueJustComplete(sol, request.justComplete == true) | |||||
| stockOutLIneRepository.save(sol) | stockOutLIneRepository.save(sol) | ||||
| stockOutLIneRepository.flush() | stockOutLIneRepository.flush() | ||||
| sol.id?.let { suggestedPickLotWorkbenchService.linkSplToStockOutLineAfterWorkbenchPick(it) } | 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, | * Workbench Etra view: `isExtra` / `isExtrabatch` / `isExtrasingle` tickets for one day, | ||||
| * grouped by shop then truck / time / loading sequence. | * grouped by shop then truck / time / loading sequence. | ||||
| * Truck X (`車線-X`, DB `storeId` null) is included and split by supplier preferred floor | * 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). | * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today). | ||||
| * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day). | * 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. | * @param floor optional `2F`/`4F`: for Truck X (null storeId) filter by DO supplier preferred floor. | ||||
| */ | */ | ||||
| open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | 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<String, Any?>, dopoId: Long): Map<String, Any?> { | |||||
| 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] | * 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. | * (`2F` / `4F`), and **no** linked DO on the other floor. | ||||
| @@ -1772,7 +1870,7 @@ return MessageResponse( | |||||
| """.trimIndent() | """.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( | private fun queryWorkbenchReleasedDopoList( | ||||
| shopName: String?, | shopName: String?, | ||||
| storeId: String?, | storeId: String?, | ||||
| @@ -2021,7 +2119,7 @@ return MessageResponse( | |||||
| payload = assembleHierarchicalFgPayload( | payload = assembleHierarchicalFgPayload( | ||||
| jdbcDao = jdbcDao, | jdbcDao = jdbcDao, | ||||
| doPickOrderId = doPickOrderId, | doPickOrderId = doPickOrderId, | ||||
| doPickOrderInfo = doPickOrderInfoSafe, | |||||
| doPickOrderInfo = enrichDopoInfoStoreIdForUi(doPickOrderInfoSafe, doPickOrderId), | |||||
| pickOrdersInfo = pickOrdersInfo, | pickOrdersInfo = pickOrdersInfo, | ||||
| availableQtyInOutOnly = true, | availableQtyInOutOnly = true, | ||||
| timingSink = ::mark, | timingSink = ::mark, | ||||
| @@ -2086,13 +2184,12 @@ return MessageResponse( | |||||
| return mapOf("fgInfo" to null, "pickOrders" to emptyList<Any>()) | return mapOf("fgInfo" to null, "pickOrders" to emptyList<Any>()) | ||||
| } | } | ||||
| // 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 -> | val perPoPayloads = pickOrdersInfo.map { poInfo -> | ||||
| assembleHierarchicalFgPayload( | assembleHierarchicalFgPayload( | ||||
| jdbcDao = jdbcDao, | jdbcDao = jdbcDao, | ||||
| doPickOrderId = deliveryOrderPickOrderId, | doPickOrderId = deliveryOrderPickOrderId, | ||||
| doPickOrderInfo = doPickOrderInfo, | |||||
| doPickOrderInfo = doPickOrderInfoForUi, | |||||
| pickOrdersInfo = listOf(poInfo), | pickOrdersInfo = listOf(poInfo), | ||||
| availableQtyInOutOnly = true, | availableQtyInOutOnly = true, | ||||
| ) | ) | ||||
| @@ -2176,6 +2273,13 @@ return MessageResponse( | |||||
| } else { | } else { | ||||
| emptyList() | 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( | mapOf( | ||||
| "doPickOrderId" to (row["doPickOrderId"] ?: 0L), | "doPickOrderId" to (row["doPickOrderId"] ?: 0L), | ||||
| @@ -2200,7 +2304,7 @@ return MessageResponse( | |||||
| "truckLanceCode" to (row["truckLanceCode"] ?: ""), | "truckLanceCode" to (row["truckLanceCode"] ?: ""), | ||||
| "DepartureTime" to (row["DepartureTime"]?.toString() ?: ""), | "DepartureTime" to (row["DepartureTime"]?.toString() ?: ""), | ||||
| "ticketNo" to (row["ticketNo"] ?: ""), | "ticketNo" to (row["ticketNo"] ?: ""), | ||||
| "storeId" to (row["storeId"] ?: ""), | |||||
| "storeId" to displayStoreId, | |||||
| "qrCodeData" to (row["doPickOrderId"] ?: 0L), | "qrCodeData" to (row["doPickOrderId"] ?: 0L), | ||||
| ) | ) | ||||
| } | } | ||||
| @@ -2282,6 +2386,7 @@ return MessageResponse( | |||||
| var postMs = 0L | var postMs = 0L | ||||
| try { | try { | ||||
| if (pickOrderId != null) { | if (pickOrderId != null) { | ||||
| val suggestionStoreId = resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) | |||||
| val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes( | val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes( | ||||
| pickOrderId = pickOrderId, | pickOrderId = pickOrderId, | ||||
| poType = null, | poType = null, | ||||
| @@ -2294,7 +2399,7 @@ return MessageResponse( | |||||
| suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineNextSingleLot( | suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineNextSingleLot( | ||||
| pickOrderLineId = polId, | pickOrderLineId = polId, | ||||
| targetQty = explicitRemainder, | targetQty = explicitRemainder, | ||||
| storeId = requestStoreId, | |||||
| storeId = suggestionStoreId, | |||||
| excludeInventoryLotLineId = scannedIllId, | excludeInventoryLotLineId = scannedIllId, | ||||
| excludeWarehouseCodes = resuggestExcludeWarehouseCodes, | excludeWarehouseCodes = resuggestExcludeWarehouseCodes, | ||||
| ) | ) | ||||
| @@ -2328,7 +2433,7 @@ return MessageResponse( | |||||
| rebuildMs = measureTimeMillis { | rebuildMs = measureTimeMillis { | ||||
| suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine( | suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine( | ||||
| pickOrderLineId = polId, | pickOrderLineId = polId, | ||||
| storeId = requestStoreId, | |||||
| storeId = suggestionStoreId, | |||||
| excludeWarehouseCodes = resuggestExcludeWarehouseCodes, | excludeWarehouseCodes = resuggestExcludeWarehouseCodes, | ||||
| ) | ) | ||||
| } | } | ||||
| @@ -2485,6 +2590,9 @@ return MessageResponse( | |||||
| val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| val previousBalance = onHandAfter + deltaQty.toDouble() | val previousBalance = onHandAfter + deltaQty.toDouble() | ||||
| val newBalance = previousBalance - 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 { | val ledger = StockLedger().apply { | ||||
| this.stockOutLine = sol | this.stockOutLine = sol | ||||
| this.inventory = inventory | this.inventory = inventory | ||||
| @@ -2494,14 +2602,54 @@ return MessageResponse( | |||||
| this.type = "NOR" | this.type = "NOR" | ||||
| this.itemId = solItem.id | this.itemId = solItem.id | ||||
| this.itemCode = solItem.code | 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() | 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. | // Do not flush per pick; transaction flush is sufficient and avoids slow roundtrips/locks. | ||||
| stockLedgerRepository.save(ledger) | 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( | private fun resolveWorkbenchPreviousBalance( | ||||
| itemId: Long, | itemId: Long, | ||||
| inventory: Inventory, | inventory: Inventory, | ||||
| @@ -15,7 +15,7 @@ object WorkbenchReleaseTypeSupport { | |||||
| fun singleFamilyTypes(): List<String> = listOf(SINGLE, IS_EXTRA_SINGLE) | fun singleFamilyTypes(): List<String> = 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 = | fun summaryFilterSql(releaseType: String, column: String = "dop.releaseType"): String = | ||||
| when (releaseType.trim().lowercase()) { | when (releaseType.trim().lowercase()) { | ||||
| "batch" -> batchFamilySql(column) | "batch" -> batchFamilySql(column) | ||||
| @@ -25,7 +25,7 @@ object WorkbenchReleaseTypeSupport { | |||||
| else -> "" | 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 { | fun assignFilterSql(releaseType: String?, column: String = "dop.releaseType"): String { | ||||
| val n = releaseType?.trim()?.lowercase().orEmpty() | val n = releaseType?.trim()?.lowercase().orEmpty() | ||||
| return when (n) { | return when (n) { | ||||
| @@ -46,7 +46,7 @@ object WorkbenchReleaseTypeSupport { | |||||
| " AND LOWER(COALESCE($column, '')) = 'isextra' " | " 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). | * Standalone `isExtra` plus merged `isExtrabatch` / `isExtrasingle` (Etra workbench). | ||||
| */ | */ | ||||
| fun etraFamilySql(column: String = "dop.releaseType"): String = | fun etraFamilySql(column: String = "dop.releaseType"): String = | ||||
| @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.deliveryOrder.web.models | |||||
| import java.math.BigDecimal | 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). | * 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 | * [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). | * 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 inventoryLotLineId: Long? = null, | ||||
| val qty: BigDecimal? = 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, | val userId: Long = 1L, | ||||
| ) | ) | ||||
| @@ -231,6 +231,7 @@ open class JoPickOrderService( | |||||
| } | } | ||||
| return joPickOrderRecordRepository.saveAll(joPickOrderRecords) | return joPickOrderRecordRepository.saveAll(joPickOrderRecords) | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */ | |||||
| open fun getAllJobOrderLotsWithDetailsHierarchical(userId: Long): JobOrderLotsHierarchicalResponse { | open fun getAllJobOrderLotsWithDetailsHierarchical(userId: Long): JobOrderLotsHierarchicalResponse { | ||||
| println("=== Debug: getAllJobOrderLotsWithDetailsHierarchical ===") | println("=== Debug: getAllJobOrderLotsWithDetailsHierarchical ===") | ||||
| println("today: ${LocalDate.now()}") | println("today: ${LocalDate.now()}") | ||||
| @@ -557,6 +558,7 @@ open class JoPickOrderService( | |||||
| } | } | ||||
| // Get completed job order pick orders (for second tab) | // Get completed job order pick orders (for second tab) | ||||
| // Fix the getCompletedJobOrderLotsHierarchical method | // Fix the getCompletedJobOrderLotsHierarchical method | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */ | |||||
| open fun getCompletedJobOrderLotsHierarchical(userId: Long): Map<String, Any?> { | open fun getCompletedJobOrderLotsHierarchical(userId: Long): Map<String, Any?> { | ||||
| println("=== Debug: getCompletedJobOrderLotsHierarchical (repo version) ===") | println("=== Debug: getCompletedJobOrderLotsHierarchical (repo version) ===") | ||||
| println("today: ${LocalDate.now()}") | println("today: ${LocalDate.now()}") | ||||
| @@ -625,13 +627,20 @@ open class JoPickOrderService( | |||||
| .filter { it.deleted == false } | .filter { it.deleted == false } | ||||
| } else emptyList() | } 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()) { | val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { | ||||
| inventoryLotLineRepository | inventoryLotLineRepository | ||||
| .findAllByIdIn(inventoryLotLineIds) | .findAllByIdIn(inventoryLotLineIds) | ||||
| .filter { it.deleted == false } | .filter { it.deleted == false } | ||||
| } else emptyList() | } else emptyList() | ||||
| val inventoryLotLineById = inventoryLotLines | |||||
| .filter { it.id != null } | |||||
| .associateBy { it.id!! } | |||||
| val inventoryLotIds = inventoryLotLines.mapNotNull { it.inventoryLot?.id }.distinct() | val inventoryLotIds = inventoryLotLines.mapNotNull { it.inventoryLot?.id }.distinct() | ||||
| val inventoryLots = if (inventoryLotIds.isNotEmpty()) { | val inventoryLots = if (inventoryLotIds.isNotEmpty()) { | ||||
| @@ -664,16 +673,19 @@ open class JoPickOrderService( | |||||
| } | } | ||||
| val lots = lineSuggestedLots.mapNotNull { spl -> | 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 | val il = ill.inventoryLot ?: return@mapNotNull null | ||||
| if (il.deleted == true) return@mapNotNull null | if (il.deleted == true) return@mapNotNull null | ||||
| val warehouse = ill.warehouse | 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") { | val availableQty = if (sol?.status == "rejected") { | ||||
| null | null | ||||
| @@ -1140,7 +1152,7 @@ open fun recordSecondScanIssue(request: SecondScanIssueRequest): MessageResponse | |||||
| il.lotNo as lotNo, | il.lotNo as lotNo, | ||||
| w.name as storeLocation | w.name as storeLocation | ||||
| FROM fpsmsdb.suggested_pick_lot spl | 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 | JOIN fpsmsdb.inventory_lot il ON ill.inventoryLotId = il.id | ||||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | ||||
| WHERE spl.pickOrderLineId = :pickOrderLineId | WHERE spl.pickOrderLineId = :pickOrderLineId | ||||
| @@ -1506,7 +1518,7 @@ open fun getCompletedJobOrderPickOrderLotDetails(pickOrderId: Long): List<Map<St | |||||
| JOIN fpsmsdb.items i ON i.id = pol.itemId | JOIN fpsmsdb.items i ON i.id = pol.itemId | ||||
| LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | ||||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId | LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId | ||||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id | |||||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) | |||||
| LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId | LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId | ||||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | ||||
| @@ -1932,7 +1944,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| JOIN fpsmsdb.items i ON i.id = pol.itemId | JOIN fpsmsdb.items i ON i.id = pol.itemId | ||||
| LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | ||||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId | LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId | ||||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id | |||||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) | |||||
| LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId | LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId | ||||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | ||||
| -- no-lot:ill.id 為 null 時,匹配 sol.inventoryLotLineId IS NULL | -- no-lot:ill.id 為 null 時,匹配 sol.inventoryLotLineId IS NULL | ||||
| @@ -2088,7 +2100,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| JOIN fpsmsdb.items i ON i.id = pol.itemId | JOIN fpsmsdb.items i ON i.id = pol.itemId | ||||
| LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | ||||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId | LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId | ||||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id | |||||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) | |||||
| LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId | LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId | ||||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | ||||
| @@ -2187,7 +2199,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| FROM fpsmsdb.pick_order po | FROM fpsmsdb.pick_order po | ||||
| JOIN fpsmsdb.pick_order_line pol ON pol.poId = po.id AND pol.deleted = false | JOIN fpsmsdb.pick_order_line pol ON pol.poId = po.id AND pol.deleted = false | ||||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId AND spl.deleted = false | LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId AND spl.deleted = false | ||||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id AND ill.deleted = false | |||||
| LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AND ill.deleted = false | |||||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | ||||
| LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = false | LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = false | ||||
| WHERE po.deleted = false | WHERE po.deleted = false | ||||
| @@ -2382,7 +2394,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| jobOrderType = jobOrderType?.name, | jobOrderType = jobOrderType?.name, | ||||
| itemId = item.id ?: 0L, | itemId = item.id ?: 0L, | ||||
| itemName = item.name ?: "", | itemName = item.name ?: "", | ||||
| itemCode = item.code, | |||||
| bomDescription = bom?.description, | bomDescription = bom?.description, | ||||
| bomType = bom?.type, | |||||
| reqQty = jobOrder.reqQty ?: BigDecimal.ZERO, | reqQty = jobOrder.reqQty ?: BigDecimal.ZERO, | ||||
| //uomId = bom.outputQtyUom?.id : 0L, | //uomId = bom.outputQtyUom?.id : 0L, | ||||
| uomId = 0, | uomId = 0, | ||||
| @@ -2438,6 +2452,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| } | } | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */ | |||||
| open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLotsHierarchicalResponse { | open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLotsHierarchicalResponse { | ||||
| println("=== getJobOrderLotsHierarchicalByPickOrderId ===") | println("=== getJobOrderLotsHierarchicalByPickOrderId ===") | ||||
| println("pickOrderId: $pickOrderId") | println("pickOrderId: $pickOrderId") | ||||
| @@ -2518,7 +2533,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| } | } | ||||
| // 获取所有 inventory lot line IDs | // 获取所有 inventory lot line IDs | ||||
| val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id } | |||||
| val inventoryLotLineIds = suggestedPickLots | |||||
| .mapNotNull { it.actualPickLotLine?.id ?: it.suggestedLotLine?.id } | |||||
| .distinct() | |||||
| // 获取 inventory lot lines | // 获取 inventory lot lines | ||||
| val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { | val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { | ||||
| @@ -2571,18 +2588,13 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| .associateBy { it.id!! } | .associateBy { it.id!! } | ||||
| // 获取 stock in lines 通过 inventoryLotLineId(用于填充 stockInLineId) | // 获取 stock in lines 通过 inventoryLotLineId(用于填充 stockInLineId) | ||||
| val stockInLinesByInventoryLotLineId = if (inventoryLotLineIds.isNotEmpty()) { | |||||
| // ✅ 修复:直接使用已加载的 inventoryLotLines 实体获取 stockInLineId | |||||
| inventoryLotLines.associateBy { it.id!! } | |||||
| .mapNotNull { (illId, ill) -> | |||||
| // 通过关系链: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 记录 | // 获取 jo_pick_order 记录 | ||||
| @@ -2617,22 +2629,22 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| userService.find(uid).orElse(null)?.name | userService.find(uid).orElse(null)?.name | ||||
| } | } | ||||
| // 构建 lots 数据 | // 构建 lots 数据 | ||||
| // COALESCE(actualPickLotLine, suggestedLotLine) for display after switch | |||||
| val lots = lineSuggestedLots.mapNotNull { spl -> | val lots = lineSuggestedLots.mapNotNull { spl -> | ||||
| 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 | val il = ill.inventoryLot | ||||
| if (il == null || il.deleted == true) return@mapNotNull null | if (il == null || il.deleted == true) return@mapNotNull null | ||||
| val warehouse = ill.warehouse | 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") { | val availableQty = if (sol?.status == "rejected") { | ||||
| null | null | ||||
| } else { | } else { | ||||
| @@ -2655,7 +2667,6 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| else -> "pending" | else -> "pending" | ||||
| } | } | ||||
| // 获取 stockInLineId | |||||
| val stockInLineId = ill.id?.let { illId -> | val stockInLineId = ill.id?.let { illId -> | ||||
| stockInLinesByInventoryLotLineId[illId] | stockInLinesByInventoryLotLineId[illId] | ||||
| } | } | ||||
| @@ -2685,11 +2696,11 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| routerArea = warehouse?.code, | routerArea = warehouse?.code, | ||||
| routerRoute = warehouse?.code, | routerRoute = warehouse?.code, | ||||
| uomShortDesc = uom?.udfShortDesc, | uomShortDesc = uom?.udfShortDesc, | ||||
| matchStatus = jpo?.matchStatus?.value, | |||||
| matchBy = jpo?.matchBy, | |||||
| matchQty = jpo?.matchQty?.toDouble() | |||||
| matchStatus = jpoInner?.matchStatus?.value, | |||||
| matchBy = jpoInner?.matchBy, | |||||
| matchQty = jpoInner?.matchQty?.toDouble() | |||||
| ) | ) | ||||
| } | |||||
| }.distinctBy { it.lotId } | |||||
| // 构建 stockouts 数据:用于无 suggested lot / noLot 场景也能显示并闭环(submit 0) | // 构建 stockouts 数据:用于无 suggested lot / noLot 场景也能显示并闭环(submit 0) | ||||
| val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol -> | val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol -> | ||||
| @@ -32,7 +32,8 @@ import java.util.Locale | |||||
| /** | /** | ||||
| * Job Order Workbench–only flows (assign prime, hierarchical payload for scan-pick UI). | * 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). | * Non-workbench hierarchical stays in [JoPickOrderService.getJobOrderLotsHierarchicalByPickOrderId] (in − out − hold). | ||||
| */ | */ | ||||
| @Service | @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 { | open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { | ||||
| println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") | println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") | ||||
| @@ -239,7 +242,10 @@ open class JoWorkbenchMainService( | |||||
| emptyList() | 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()) { | val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { | ||||
| inventoryLotLineRepository.findAllByIdIn(inventoryLotLineIds) | inventoryLotLineRepository.findAllByIdIn(inventoryLotLineIds) | ||||
| @@ -253,16 +259,6 @@ open class JoWorkbenchMainService( | |||||
| inventoryLotRepository.findAllByIdIn(inventoryLotIds).filter { it.deleted == false } | 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 -> | val stockOutLinesByPickOrderLine = pickOrderLineIds.associateWith { polId -> | ||||
| stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) | stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) | ||||
| } | } | ||||
| @@ -322,27 +318,33 @@ open class JoWorkbenchMainService( | |||||
| val uom = pol.uom | val uom = pol.uom | ||||
| val lineId = pol.id!! | val lineId = pol.id!! | ||||
| val lineSuggestedLots = suggestedPickLots.filter { it.pickOrderLine?.id == 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 jpo = joPickOrders.firstOrNull { it.itemId == item?.id } | ||||
| val handlerName = jpo?.handledBy?.let { uid -> | val handlerName = jpo?.handledBy?.let { uid -> | ||||
| userService.find(uid).orElse(null)?.name | userService.find(uid).orElse(null)?.name | ||||
| } | } | ||||
| val lots = lineSuggestedLots.mapNotNull { spl -> | 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 | val il = ill.inventoryLot | ||||
| if (il == null || il.deleted == true) return@mapNotNull null | if (il == null || il.deleted == true) return@mapNotNull null | ||||
| val warehouse = ill.warehouse | 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 jpoInner = joPickOrders.firstOrNull { it.itemId == item?.id } | ||||
| val handlerNameInner = jpoInner?.handledBy?.let { uid -> | val handlerNameInner = jpoInner?.handledBy?.let { uid -> | ||||
| userService.find(uid).orElse(null)?.name | userService.find(uid).orElse(null)?.name | ||||
| } | } | ||||
| //println("handlerName: $handlerNameInner") | |||||
| val availableQty = if (sol?.status == "rejected") { | val availableQty = if (sol?.status == "rejected") { | ||||
| null | null | ||||
| } else { | } else { | ||||
| @@ -377,7 +379,7 @@ open class JoWorkbenchMainService( | |||||
| location = warehouse?.code, | location = warehouse?.code, | ||||
| availableQty = availableQty?.toDouble(), | availableQty = availableQty?.toDouble(), | ||||
| requiredQty = spl.qty?.toDouble() ?: 0.0, | requiredQty = spl.qty?.toDouble() ?: 0.0, | ||||
| actualPickQty = sol?.qty ?: 0.0, | |||||
| actualPickQty = sol?.qty?.toDouble() ?: 0.0, | |||||
| processingStatus = processingStatus, | processingStatus = processingStatus, | ||||
| lotAvailability = lotAvailability, | lotAvailability = lotAvailability, | ||||
| pickOrderId = pickOrder.id, | pickOrderId = pickOrder.id, | ||||
| @@ -387,7 +389,7 @@ open class JoWorkbenchMainService( | |||||
| stockOutLineId = sol?.id, | stockOutLineId = sol?.id, | ||||
| stockInLineId = stockInLineId, | stockInLineId = stockInLineId, | ||||
| suggestedPickLotId = spl.id, | suggestedPickLotId = spl.id, | ||||
| stockOutLineQty = sol?.qty ?: 0.0, | |||||
| stockOutLineQty = sol?.qty?.toDouble() ?: 0.0, | |||||
| stockOutLineStatus = sol?.status, | stockOutLineStatus = sol?.status, | ||||
| routerIndex = warehouse?.order?.toString(), | routerIndex = warehouse?.order?.toString(), | ||||
| routerArea = warehouse?.code, | routerArea = warehouse?.code, | ||||
| @@ -397,9 +399,9 @@ open class JoWorkbenchMainService( | |||||
| matchBy = jpoInner?.matchBy, | matchBy = jpoInner?.matchBy, | ||||
| matchQty = jpoInner?.matchQty?.toDouble() | matchQty = jpoInner?.matchQty?.toDouble() | ||||
| ) | ) | ||||
| } | |||||
| }.distinctBy { it.lotId } | |||||
| val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol -> | |||||
| val stockouts = lineSols.map { sol -> | |||||
| val illId = sol.inventoryLotLineId | val illId = sol.inventoryLotLineId | ||||
| val ill = if (illId != null) inventoryLotLineById[illId] else null | val ill = if (illId != null) inventoryLotLineById[illId] else null | ||||
| val lot = ill?.inventoryLot | val lot = ill?.inventoryLot | ||||
| @@ -412,7 +414,10 @@ open class JoWorkbenchMainService( | |||||
| illAvailableQtyWorkbench(ill) | illAvailableQtyWorkbench(ill) | ||||
| } | } | ||||
| val splForSol = lineSuggestedLots.firstOrNull { sp -> | 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( | 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 { | private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalWorkbenchResponse { | ||||
| println("❌ $message") | println("❌ $message") | ||||
| return JobOrderLotsHierarchicalWorkbenchResponse( | return JobOrderLotsHierarchicalWorkbenchResponse( | ||||
| @@ -21,7 +21,8 @@ import java.util.concurrent.atomic.AtomicBoolean | |||||
| * [JobOrder.planStart] fell on the previous calendar day. | * [JobOrder.planStart] fell on the previous calendar day. | ||||
| * | * | ||||
| * - Branch A: no pick submitted, product process pending → hide job order. | * - 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 | @Service | ||||
| open class JobOrderPlanStartAutoService( | open class JobOrderPlanStartAutoService( | ||||
| @@ -47,6 +48,7 @@ open class JobOrderPlanStartAutoService( | |||||
| val errors: Int = 0, | 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 { | open fun runAutoProcess(runAt: LocalDateTime = LocalDateTime.now()): JobOrderPlanStartAutoReport { | ||||
| if (!inFlight.compareAndSet(false, true)) { | if (!inFlight.compareAndSet(false, true)) { | ||||
| logger.warn("Job order plan-start auto process skipped: previous run still in flight") | logger.warn("Job order plan-start auto process skipped: previous run still in flight") | ||||
| @@ -147,6 +149,7 @@ open class JobOrderPlanStartAutoService( | |||||
| SKIP, | SKIP, | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 42 | v1.0.0 | 2026-08-03 */ | |||||
| private fun classify( | private fun classify( | ||||
| jo: JobOrder, | jo: JobOrder, | ||||
| pickOrders: List<PickOrder>, | pickOrders: List<PickOrder>, | ||||
| @@ -158,7 +161,8 @@ open class JobOrderPlanStartAutoService( | |||||
| val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0 | val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0 | ||||
| return when { | return when { | ||||
| maxSubmittedLines == 0 -> Branch.HIDE | 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 | else -> Branch.SKIP | ||||
| } | } | ||||
| } | } | ||||
| @@ -49,7 +49,10 @@ data class AllJoPickOrderResponse( | |||||
| val jobOrderType: String?, | val jobOrderType: String?, | ||||
| val itemId: Long, | val itemId: Long, | ||||
| val itemName: String, | val itemName: String, | ||||
| val itemCode: String? = null, | |||||
| val bomDescription: String?, | val bomDescription: String?, | ||||
| /** BOM.type (e.g. drink / Powder_Mixture / other). */ | |||||
| val bomType: String? = null, | |||||
| val reqQty: BigDecimal, | val reqQty: BigDecimal, | ||||
| val uomId: Long, | val uomId: Long, | ||||
| val uomName: String, | val uomName: String, | ||||
| @@ -254,4 +254,54 @@ fun findAllReleasedJoWorkbenchPickOrders( | |||||
| nativeQuery = true, | nativeQuery = true, | ||||
| ) | ) | ||||
| fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int | 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<Long>): List<Long> | |||||
| /** 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<Long> | |||||
| } | } | ||||
| @@ -5,6 +5,7 @@ import java.util.Locale | |||||
| import kotlin.system.measureTimeMillis | 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 | * Shared hierarchical FG payload (fgInfo + merged pickOrders with lines/lots) for both | ||||
| * legacy [do_pick_order_line] and workbench [delivery_order_pick_order] flows. | * legacy [do_pick_order_line] and workbench [delivery_order_pick_order] flows. | ||||
| */ | */ | ||||
| @@ -60,7 +61,8 @@ fun assembleHierarchicalFgPayload( | |||||
| AND pol.deleted = false | AND pol.deleted = false | ||||
| ), | ), | ||||
| ll AS ( | 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 | FROM fpsmsdb.suggested_pick_lot spl | ||||
| JOIN target_pol tp ON tp.id = spl.pickOrderLineId | JOIN target_pol tp ON tp.id = spl.pickOrderLineId | ||||
| WHERE spl.deleted = false | WHERE spl.deleted = false | ||||
| @@ -73,11 +75,13 @@ ll AS ( | |||||
| WHERE sol.deleted = false | WHERE sol.deleted = false | ||||
| ), | ), | ||||
| sm AS ( | 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 | FROM fpsmsdb.suggested_pick_lot s | ||||
| JOIN target_pol tp ON tp.id = s.pickOrderLineId | JOIN target_pol tp ON tp.id = s.pickOrderLineId | ||||
| WHERE s.deleted = false | WHERE s.deleted = false | ||||
| GROUP BY s.pickOrderLineId, s.suggestedLotLineId | |||||
| GROUP BY s.pickOrderLineId, COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) | |||||
| ) | ) | ||||
| SELECT | SELECT | ||||
| po.id as pickOrderId, | po.id as pickOrderId, | ||||
| @@ -150,7 +154,7 @@ LEFT JOIN ll ON ll.pickOrderLineId = pol.id | |||||
| LEFT JOIN sm | LEFT JOIN sm | ||||
| ON sm.pickOrderLineId = pol.id | ON sm.pickOrderLineId = pol.id | ||||
| AND sm.suggestedLotLineId <=> ll.lotLineId | |||||
| AND sm.displayLotLineId <=> ll.lotLineId | |||||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl | LEFT JOIN fpsmsdb.suggested_pick_lot spl | ||||
| ON spl.id = sm.maxId | ON spl.id = sm.maxId | ||||
| AND spl.deleted = false | AND spl.deleted = false | ||||
| @@ -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.web.model.BatchStockOutResult | ||||
| import com.ffii.fpsms.modules.stock.entity.StockLedger | import com.ffii.fpsms.modules.stock.entity.StockLedger | ||||
| import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | 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.entity.DeliveryOrderRepository | ||||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | ||||
| import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | ||||
| @@ -2369,6 +2370,7 @@ private fun updateInventoryAfterLotLineChange(lotLine: InventoryLotLine) { | |||||
| e.printStackTrace() | e.printStackTrace() | ||||
| } | } | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ | |||||
| private fun createStockLedgerForStockOut( | private fun createStockLedgerForStockOut( | ||||
| stockOutLine: StockOutLine, | stockOutLine: StockOutLine, | ||||
| ledgerType: String? = null, | ledgerType: String? = null, | ||||
| @@ -2394,6 +2396,9 @@ private fun createStockLedgerForStockOut( | |||||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | 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 { | val stockLedger = StockLedger().apply { | ||||
| this.stockOutLine = stockOutLine | this.stockOutLine = stockOutLine | ||||
| this.inventory = inventory | this.inventory = inventory | ||||
| @@ -2403,10 +2408,19 @@ private fun createStockLedgerForStockOut( | |||||
| this.type = ledgerTypeToUse | this.type = ledgerTypeToUse | ||||
| this.itemId = item.id | this.itemId = item.id | ||||
| this.itemCode = item.code | 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() | this.date = LocalDate.now() | ||||
| } | } | ||||
| StockLedgerLotSnapshot.apply( | |||||
| ledger = stockLedger, | |||||
| inventoryLotLine = ill, | |||||
| lotAvailableAfterMove = lotAfter, | |||||
| signedDelta = outQtyBd.negate(), | |||||
| ) | |||||
| stockLedgerRepository.saveAndFlush(stockLedger) | stockLedgerRepository.saveAndFlush(stockLedger) | ||||
| @@ -2417,8 +2431,6 @@ private fun createStockLedgerForStockOut( | |||||
| println("===========================") | println("===========================") | ||||
| } | } | ||||
| @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) | @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) | ||||
| private fun handleBadItemPackageProblem(request: PickExecutionIssueRequest, badItemQty: BigDecimal) { | private fun handleBadItemPackageProblem(request: PickExecutionIssueRequest, badItemQty: BigDecimal) { | ||||
| println("=== HANDLING BAD ITEM PACKAGE PROBLEM (DON'T REJECT LOT) ===") | 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) | // 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( | private fun createStockLedgerForStockOutWithBalance( | ||||
| stockOutLine: StockOutLine, | stockOutLine: StockOutLine, | ||||
| balance: Double, | balance: Double, | ||||
| @@ -2880,6 +2893,9 @@ private fun createStockLedgerForStockOutWithBalance( | |||||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | 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 { | val stockLedger = StockLedger().apply { | ||||
| this.stockOutLine = stockOutLine | this.stockOutLine = stockOutLine | ||||
| this.inventory = inventory | this.inventory = inventory | ||||
| @@ -2889,10 +2905,19 @@ private fun createStockLedgerForStockOutWithBalance( | |||||
| this.type = ledgerTypeToUse | this.type = ledgerTypeToUse | ||||
| this.itemId = item.id | this.itemId = item.id | ||||
| this.itemCode = item.code | 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() | this.date = LocalDate.now() | ||||
| } | } | ||||
| StockLedgerLotSnapshot.apply( | |||||
| ledger = stockLedger, | |||||
| inventoryLotLine = ill, | |||||
| lotAvailableAfterMove = lotAfter, | |||||
| signedDelta = outQtyBd.negate(), | |||||
| ) | |||||
| stockLedgerRepository.saveAndFlush(stockLedger) | stockLedgerRepository.saveAndFlush(stockLedger) | ||||
| @@ -3804,8 +3804,10 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A | |||||
| LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | ||||
| LEFT JOIN ( | LEFT JOIN ( | ||||
| SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId | |||||
| SELECT spl.pickOrderLineId, | |||||
| COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId | |||||
| FROM fpsmsdb.suggested_pick_lot spl | FROM fpsmsdb.suggested_pick_lot spl | ||||
| WHERE spl.deleted = false | |||||
| UNION | UNION | ||||
| SELECT sol.pickOrderLineId, sol.inventoryLotLineId | SELECT sol.pickOrderLineId, sol.inventoryLotLineId | ||||
| FROM fpsmsdb.stock_out_line sol | FROM fpsmsdb.stock_out_line sol | ||||
| @@ -3813,7 +3815,9 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A | |||||
| ) ll ON ll.pickOrderLineId = pol.id | ) ll ON ll.pickOrderLineId = pol.id | ||||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl | LEFT JOIN fpsmsdb.suggested_pick_lot spl | ||||
| ON spl.pickOrderLineId = pol.id AND spl.suggestedLotLineId = ll.lotLineId | |||||
| ON spl.pickOrderLineId = pol.id | |||||
| AND spl.deleted = false | |||||
| AND COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) <=> ll.lotLineId | |||||
| -- lots 用(保留原逻辑) | -- lots 用(保留原逻辑) | ||||
| LEFT JOIN fpsmsdb.stock_out_line sol | 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<String, Any?> { | open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, Any?> { | ||||
| val user = userService.find(userId).orElse(null) | val user = userService.find(userId).orElse(null) | ||||
| if (user == null) { | if (user == null) { | ||||
| @@ -4162,15 +4167,19 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A | |||||
| JOIN fpsmsdb.items i ON i.id = pol.itemId | JOIN fpsmsdb.items i ON i.id = pol.itemId | ||||
| LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | ||||
| LEFT JOIN ( | LEFT JOIN ( | ||||
| SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId | |||||
| SELECT spl.pickOrderLineId, | |||||
| COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId | |||||
| FROM fpsmsdb.suggested_pick_lot spl | FROM fpsmsdb.suggested_pick_lot spl | ||||
| WHERE spl.deleted = false | |||||
| UNION | UNION | ||||
| SELECT sol.pickOrderLineId, sol.inventoryLotLineId | SELECT sol.pickOrderLineId, sol.inventoryLotLineId | ||||
| FROM fpsmsdb.stock_out_line sol | FROM fpsmsdb.stock_out_line sol | ||||
| WHERE sol.deleted = false | WHERE sol.deleted = false | ||||
| ) ll ON ll.pickOrderLineId = pol.id | ) ll ON ll.pickOrderLineId = pol.id | ||||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl | LEFT JOIN fpsmsdb.suggested_pick_lot spl | ||||
| ON spl.pickOrderLineId = pol.id AND spl.suggestedLotLineId = ll.lotLineId | |||||
| ON spl.pickOrderLineId = pol.id | |||||
| AND spl.deleted = false | |||||
| AND COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) <=> ll.lotLineId | |||||
| LEFT JOIN fpsmsdb.stock_out_line sol | LEFT JOIN fpsmsdb.stock_out_line sol | ||||
| ON sol.pickOrderLineId = pol.id | ON sol.pickOrderLineId = pol.id | ||||
| AND ( (sol.inventoryLotLineId = ll.lotLineId) | AND ( (sol.inventoryLotLineId = ll.lotLineId) | ||||
| @@ -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. | * Consumable workbench hierarchical payload for Tab3. | ||||
| * Uses no-hold available qty semantics (inQty - outQty). | * Uses no-hold available qty semantics (inQty - outQty). | ||||
| */ | */ | ||||
| @@ -275,6 +276,7 @@ open class PickOrderWorkbenchService( | |||||
| /** | /** | ||||
| * Workbench line detail V2 (consumable): | * 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. | * - Returns only lot rows already linked by suggestion/stock_out_line for this pick order line. | ||||
| * - Uses no-hold availability semantics: available = inQty - outQty. | * - Uses no-hold availability semantics: available = inQty - outQty. | ||||
| * - Includes no-lot stock_out_line rows. | * - 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 fpsmsdb.warehouse w ON w.id = ill.warehouseId | ||||
| LEFT JOIN ( | LEFT JOIN ( | ||||
| SELECT | SELECT | ||||
| s.suggestedLotLineId AS lotLineId, | |||||
| COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) AS lotLineId, | |||||
| MAX(s.id) AS suggestedPickLotId, | MAX(s.id) AS suggestedPickLotId, | ||||
| SUM(COALESCE(s.qty,0)) AS requiredQty | SUM(COALESCE(s.qty,0)) AS requiredQty | ||||
| FROM fpsmsdb.suggested_pick_lot s | FROM fpsmsdb.suggested_pick_lot s | ||||
| WHERE s.pickOrderLineId = :pickOrderLineId | WHERE s.pickOrderLineId = :pickOrderLineId | ||||
| AND s.deleted = false | 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 | ) spl ON spl.lotLineId = ill.id | ||||
| LEFT JOIN ( | LEFT JOIN ( | ||||
| SELECT | SELECT | ||||
| @@ -328,11 +330,11 @@ open class PickOrderWorkbenchService( | |||||
| GROUP BY so.inventoryLotLineId | GROUP BY so.inventoryLotLineId | ||||
| ) sol ON sol.lotLineId = ill.id | ) sol ON sol.lotLineId = ill.id | ||||
| WHERE ill.id IN ( | WHERE ill.id IN ( | ||||
| SELECT DISTINCT s.suggestedLotLineId | |||||
| SELECT DISTINCT COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) | |||||
| FROM fpsmsdb.suggested_pick_lot s | FROM fpsmsdb.suggested_pick_lot s | ||||
| WHERE s.pickOrderLineId = :pickOrderLineId | WHERE s.pickOrderLineId = :pickOrderLineId | ||||
| AND s.deleted = false | AND s.deleted = false | ||||
| AND s.suggestedLotLineId IS NOT NULL | |||||
| AND COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) IS NOT NULL | |||||
| UNION | UNION | ||||
| SELECT DISTINCT so.inventoryLotLineId | SELECT DISTINCT so.inventoryLotLineId | ||||
| FROM fpsmsdb.stock_out_line so | FROM fpsmsdb.stock_out_line so | ||||
| @@ -55,4 +55,72 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe | |||||
| @Param("useBomIds") useBomIds: Boolean, | @Param("useBomIds") useBomIds: Boolean, | ||||
| @Param("bomIds") bomIds: Collection<Long>, | @Param("bomIds") bomIds: Collection<Long>, | ||||
| ): List<JobOrderProcessAggregate> | ): List<JobOrderProcessAggregate> | ||||
| /** | |||||
| * 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<Long>, | |||||
| @Param("excludeCompletedProcess") excludeCompletedProcess: Boolean, | |||||
| ): List<JobOrderProcessAggregate> | |||||
| @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<Long>, | |||||
| @Param("itemCode") itemCode: String?, | |||||
| @Param("jobOrderCode") jobOrderCode: String?, | |||||
| @Param("bomType") bomType: String?, | |||||
| @Param("useBomIds") useBomIds: Boolean, | |||||
| @Param("bomIds") bomIds: Collection<Long>, | |||||
| ): List<JobOrderProcessAggregate> | |||||
| } | } | ||||
| @@ -1460,6 +1460,7 @@ open class ProductProcessService( | |||||
| itemName = productProcesses.item?.name, | itemName = productProcesses.item?.name, | ||||
| itemCode = productProcesses.item?.code, | itemCode = productProcesses.item?.code, | ||||
| bomDescription = productProcesses.bom?.description, | bomDescription = productProcesses.bom?.description, | ||||
| bomType = productProcesses.bom?.type, | |||||
| pickOrderId = pickOrder?.id, | pickOrderId = pickOrder?.id, | ||||
| pickOrderStatus = pickOrder?.status?.value, | pickOrderStatus = pickOrder?.status?.value, | ||||
| jobOrderId = productProcesses.jobOrder?.id, | 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 决定。 | * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 | ||||
| * | * | ||||
| * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 | * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 | ||||
| @@ -1511,7 +1513,9 @@ open class ProductProcessService( | |||||
| includePutaway: Boolean?, | includePutaway: Boolean?, | ||||
| putawayStatus: String?, | putawayStatus: String?, | ||||
| page: Int, | page: Int, | ||||
| size: Int | |||||
| size: Int, | |||||
| lookbackDays: Int? = null, | |||||
| bucket: String? = null, | |||||
| ): JobOrderProductProcessPageResponse { | ): JobOrderProductProcessPageResponse { | ||||
| val safeSize = if (size <= 0) 50 else size | val safeSize = if (size <= 0) 50 else size | ||||
| val safePage = if (page < 0) 0 else page | val safePage = if (page < 0) 0 else page | ||||
| @@ -1522,16 +1526,82 @@ open class ProductProcessService( | |||||
| val normalizedBomIds = bomIds?.distinct().orEmpty() | val normalizedBomIds = bomIds?.distinct().orEmpty() | ||||
| val useBomIds = normalizedBomIds.isNotEmpty() | val useBomIds = normalizedBomIds.isNotEmpty() | ||||
| val bomIdsForQuery = if (useBomIds) normalizedBomIds else listOf(-1L) | 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 | // 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<Long, JobOrderProcessAggregate>() | |||||
| (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 } | val candidateJobOrderIds = candidateAggregates.map { it.jobOrderId } | ||||
| if (candidateJobOrderIds.isEmpty()) { | if (candidateJobOrderIds.isEmpty()) { | ||||
| @@ -1539,7 +1609,10 @@ open class ProductProcessService( | |||||
| content = emptyList(), | content = emptyList(), | ||||
| totalJobOrders = 0L, | totalJobOrders = 0L, | ||||
| page = safePage, | 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 filteredQcKeys = qcKeys.filter { key -> qcReady == null || key.ready == qcReady } | ||||
| val totalJobOrders = filteredQcKeys.size.toLong() | |||||
| val sortedQcKeys = filteredQcKeys.sortedWith( | |||||
| compareByDescending<JobOrderQcKey> { 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<Pair<JobOrderQcKey, String?>> { it.first.maxDate }.thenBy { it.first.minPriority } | |||||
| ) | ) | ||||
| val from = safePage * safeSize | 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() | val pagedJobOrderIdSet = pagedJobOrderIds.toSet() | ||||
| // 4) 只對當前頁資料做批量關聯查詢,並且排序/資料縮小盡量下推 DB | // 4) 只對當前頁資料做批量關聯查詢,並且排序/資料縮小盡量下推 DB | ||||
| @@ -1756,6 +1914,7 @@ open class ProductProcessService( | |||||
| itemName = productProcess.item?.name, | itemName = productProcess.item?.name, | ||||
| itemCode = productProcess.item?.code, | itemCode = productProcess.item?.code, | ||||
| bomDescription = productProcess.bom?.description, | bomDescription = productProcess.bom?.description, | ||||
| bomType = productProcess.bom?.type, | |||||
| pickOrderId = pickOrder?.id, | pickOrderId = pickOrder?.id, | ||||
| pickOrderStatus = pickOrder?.status?.value, | pickOrderStatus = pickOrder?.status?.value, | ||||
| jobOrderId = productProcess.jobOrder?.id, | jobOrderId = productProcess.jobOrder?.id, | ||||
| @@ -1775,7 +1934,9 @@ open class ProductProcessService( | |||||
| endTime = line.endTime, | endTime = line.endTime, | ||||
| status = line.status ?: "" | 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, | content = content, | ||||
| totalJobOrders = totalJobOrders, | totalJobOrders = totalJobOrders, | ||||
| page = safePage, | page = safePage, | ||||
| size = safeSize | |||||
| size = safeSize, | |||||
| bucketCounts = bucketCounts, | |||||
| searchDate = date?.toString(), | |||||
| carriedOverCount = carriedOverCount, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -200,6 +200,7 @@ class ProductProcessController( | |||||
| return productProcessService.getAllJoborderProductProcessInfo(bomType) | return productProcessService.getAllJoborderProductProcessInfo(bomType) | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */ | |||||
| @GetMapping("/Demo/Process/search") | @GetMapping("/Demo/Process/search") | ||||
| fun demoprocesssearch( | fun demoprocesssearch( | ||||
| @RequestParam(required = false) date: String?, | @RequestParam(required = false) date: String?, | ||||
| @@ -211,6 +212,14 @@ class ProductProcessController( | |||||
| /** all | completed | notCompleted */ | /** all | completed | notCompleted */ | ||||
| @RequestParam(required = false) putawayStatus: String?, | @RequestParam(required = false) putawayStatus: String?, | ||||
| @RequestParam(name = "type", required = false) bomType: 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 = "0") page: Int, | ||||
| @RequestParam(defaultValue = "50") size: Int | @RequestParam(defaultValue = "50") size: Int | ||||
| ): JobOrderProductProcessPageResponse { | ): JobOrderProductProcessPageResponse { | ||||
| @@ -233,7 +242,9 @@ class ProductProcessController( | |||||
| putawayStatus = putawayStatus, | putawayStatus = putawayStatus, | ||||
| bomType = bomType, | bomType = bomType, | ||||
| page = page, | page = page, | ||||
| size = size | |||||
| size = size, | |||||
| lookbackDays = lookbackDays, | |||||
| bucket = bucket, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -170,6 +170,8 @@ data class AllJoborderProductProcessInfoResponse( | |||||
| val itemName: String?, | val itemName: String?, | ||||
| val itemCode: String?, | val itemCode: String?, | ||||
| val bomDescription: String?, | val bomDescription: String?, | ||||
| /** BOM.type (e.g. drink / Powder_Mixture / other). */ | |||||
| val bomType: String? = null, | |||||
| val matchStatus: String?, | val matchStatus: String?, | ||||
| val RequiredQty: Int?, | val RequiredQty: Int?, | ||||
| val Uom: String?, | val Uom: String?, | ||||
| @@ -187,14 +189,32 @@ data class AllJoborderProductProcessInfoResponse( | |||||
| val stockInLineStatus: String?, | val stockInLineStatus: String?, | ||||
| val TimeNeedToComplete: Int?, | val TimeNeedToComplete: Int?, | ||||
| val isDrink: Boolean?, | val isDrink: Boolean?, | ||||
| val lines: List<ProductProcessInfoResponse> | |||||
| val lines: List<ProductProcessInfoResponse>, | |||||
| /** 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( | data class JobOrderProductProcessPageResponse( | ||||
| val content: List<AllJoborderProductProcessInfoResponse>, | val content: List<AllJoborderProductProcessInfoResponse>, | ||||
| val totalJobOrders: Long, | val totalJobOrders: Long, | ||||
| val page: Int, | 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, | |||||
| ) | ) | ||||
| /** | /** | ||||
| @@ -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<Map<String, Any?>> { | |||||
| val args = mutableMapOf<String, Any>() | |||||
| // 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<Map<String, Any?>> | |||||
| } | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ | |||||
| fun getDistinctHandlers(): List<String> { | |||||
| 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<String, Any>()) | |||||
| .map { (it["handler"]?.toString() ?: "").trim() } | |||||
| .filter { it.isNotBlank() } | |||||
| } | |||||
| } | |||||
| @@ -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<String, Any> { | |||||
| val args = mutableMapOf<String, Any>() | |||||
| 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<Map<String, Any?>> | |||||
| if (baseRows.isEmpty()) { | |||||
| return mapOf("rows" to emptyList<Map<String, Any?>>()) | |||||
| } | |||||
| 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<String, Any?>( | |||||
| "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<Long>, | |||||
| ): Map<Long, BigDecimal> { | |||||
| 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<Map<String, Any?>> | |||||
| 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<Long>, | |||||
| itemIds: List<Long>, | |||||
| excludePickOrderLineIds: List<Long>, | |||||
| ): Map<Pair<Long, Long>, BigDecimal> { | |||||
| if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap() | |||||
| val args = mutableMapOf<String, Any>( | |||||
| "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<Map<String, Any?>> | |||||
| 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, Any>, | |||||
| ): 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, Any>, | |||||
| ): 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 | |||||
| } | |||||
| } | |||||
| @@ -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<String> = 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<ByteArray> { | |||||
| 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<Map<String, Any?>>): 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() | |||||
| } | |||||
| } | |||||
| @@ -24,12 +24,14 @@ import java.time.format.DateTimeFormatter | |||||
| import com.ffii.fpsms.modules.common.SecurityUtils | import com.ffii.fpsms.modules.common.SecurityUtils | ||||
| import com.ffii.fpsms.modules.report.service.M18BomShopSyncReportService | import com.ffii.fpsms.modules.report.service.M18BomShopSyncReportService | ||||
| import com.ffii.fpsms.modules.report.service.ReportService | import com.ffii.fpsms.modules.report.service.ReportService | ||||
| import com.ffii.fpsms.modules.report.service.ShopOrderReplenishmentReportService | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/report") | @RequestMapping("/report") | ||||
| class ReportController( | class ReportController( | ||||
| private val reportService: ReportService, | private val reportService: ReportService, | ||||
| private val m18BomShopSyncReportService: M18BomShopSyncReportService, | private val m18BomShopSyncReportService: M18BomShopSyncReportService, | ||||
| private val shopOrderReplenishmentReportService: ShopOrderReplenishmentReportService, | |||||
| ) { | ) { | ||||
| private data class ExcelStyles( | private data class ExcelStyles( | ||||
| val title: XSSFCellStyle, | val title: XSSFCellStyle, | ||||
| @@ -1001,6 +1003,32 @@ class ReportController( | |||||
| syncStatus = syncStatus, | 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<String, Any> = | |||||
| shopOrderReplenishmentReportService.searchShopOrderReplenishmentReport( | |||||
| reorderDateStart = reorderDateStart, | |||||
| reorderDateEnd = reorderDateEnd, | |||||
| shopOrderDateStart = shopOrderDateStart, | |||||
| shopOrderDateEnd = shopOrderDateEnd, | |||||
| deliveredDateStart = deliveredDateStart, | |||||
| deliveredDateEnd = deliveredDateEnd, | |||||
| shopCode = shopCode, | |||||
| ) | |||||
| companion object { | companion object { | ||||
| /** GRN report fields only users with ADMIN authority may see */ | /** GRN report fields only users with ADMIN authority may see */ | ||||
| private val GRN_FINANCIAL_KEYS = setOf("unitPrice", "lineAmount", "currencyCode") | private val GRN_FINANCIAL_KEYS = setOf("unitPrice", "lineAmount", "currencyCode") | ||||
| @@ -4,9 +4,11 @@ import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.Column | import jakarta.persistence.Column | ||||
| import jakarta.persistence.Entity | import jakarta.persistence.Entity | ||||
| import jakarta.persistence.JoinColumn | import jakarta.persistence.JoinColumn | ||||
| import jakarta.persistence.ManyToOne | |||||
| import jakarta.persistence.OneToOne | import jakarta.persistence.OneToOne | ||||
| import jakarta.persistence.Table | import jakarta.persistence.Table | ||||
| import jakarta.validation.constraints.NotNull | import jakarta.validation.constraints.NotNull | ||||
| import java.math.BigDecimal | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| @Entity | @Entity | ||||
| @Table(name = "stock_ledger") | @Table(name = "stock_ledger") | ||||
| @@ -19,6 +21,11 @@ open class StockLedger: BaseEntity<Long>() { | |||||
| @JoinColumn(name = "stockOutLineId") | @JoinColumn(name = "stockOutLineId") | ||||
| open var stockOutLine: StockOutLine? = null | 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 | @NotNull | ||||
| @OneToOne | @OneToOne | ||||
| @JoinColumn(name = "inventoryId") | @JoinColumn(name = "inventoryId") | ||||
| @@ -33,7 +40,15 @@ open class StockLedger: BaseEntity<Long>() { | |||||
| @Column(name = "balance") | @Column(name = "balance") | ||||
| open var balance: Double? = null | 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") | @Column(name = "uomId") | ||||
| open var uomId: Long? = null | open var uomId: Long? = null | ||||
| @@ -41,6 +41,13 @@ open class StockOutLine: BaseEntity<Long>() { | |||||
| @Column(name = "handled_by") | @Column(name = "handled_by") | ||||
| open var handledBy: Long? = null | 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 | @JsonBackReference | ||||
| @ManyToOne | @ManyToOne | ||||
| @JoinColumn(name = "pickOrderLineId") | @JoinColumn(name = "pickOrderLineId") | ||||
| @@ -29,6 +29,11 @@ open class SuggestedPickLot: BaseEntity<Long>() { | |||||
| @JoinColumn(name = "suggestedLotLineId") | @JoinColumn(name = "suggestedLotLineId") | ||||
| open var suggestedLotLine: InventoryLotLine? = null | 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 | @JsonBackReference | ||||
| @ManyToOne | @ManyToOne | ||||
| @JoinColumn(name = "pickOrderLineId") | @JoinColumn(name = "pickOrderLineId") | ||||
| @@ -1378,6 +1378,7 @@ open class StockInLineService( | |||||
| } | } | ||||
| @Transactional | @Transactional | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ | |||||
| private fun createStockLedgerForStockIn(stockInLine: StockInLine, inQty: Double) { | private fun createStockLedgerForStockIn(stockInLine: StockInLine, inQty: Double) { | ||||
| val _t0 = System.nanoTime() | val _t0 = System.nanoTime() | ||||
| fun _msSince(t: Long): Double = (System.nanoTime() - t) / 1_000_000.0 | fun _msSince(t: Long): Double = (System.nanoTime() - t) / 1_000_000.0 | ||||
| @@ -1403,7 +1404,11 @@ open class StockInLineService( | |||||
| val newBalance = previousBalance + inQty | val newBalance = previousBalance + inQty | ||||
| val _tUom = System.nanoTime() | 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) | _logStep("itemUomService.findStockUnitByItemId", _tUom) | ||||
| val stockLedger = StockLedger().apply { | val stockLedger = StockLedger().apply { | ||||
| @@ -1418,6 +1423,14 @@ open class StockInLineService( | |||||
| this.uomId = stockUomId | this.uomId = stockUomId | ||||
| this.date = LocalDate.now() | 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() | val _tSave = System.nanoTime() | ||||
| stockLedgerRepository.saveAndFlush(stockLedger) | stockLedgerRepository.saveAndFlush(stockLedger) | ||||
| @@ -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 } | |||||
| } | |||||
| } | |||||
| @@ -1509,6 +1509,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| } | } | ||||
| } | } | ||||
| @Transactional | @Transactional | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ | |||||
| private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) { | private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) { | ||||
| val item = stockOutLine.item ?: return | val item = stockOutLine.item ?: return | ||||
| val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: 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 previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| val newBalance = previousBalance - outQty | val newBalance = previousBalance - outQty | ||||
| val ill = stockOutLine.inventoryLotLine | |||||
| val outQtyBd = BigDecimal.valueOf(outQty) | |||||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||||
| val stockLedger = StockLedger().apply { | val stockLedger = StockLedger().apply { | ||||
| this.stockOutLine = stockOutLine | this.stockOutLine = stockOutLine | ||||
| this.inventory = inventory | this.inventory = inventory | ||||
| @@ -1528,10 +1532,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| this.type = stockOutLine.type | this.type = stockOutLine.type | ||||
| this.itemId = item.id | this.itemId = item.id | ||||
| this.itemCode = item.code | 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() | this.date = LocalDate.now() | ||||
| } | } | ||||
| StockLedgerLotSnapshot.apply( | |||||
| ledger = stockLedger, | |||||
| inventoryLotLine = ill, | |||||
| lotAvailableAfterMove = lotAfter, | |||||
| signedDelta = outQtyBd.negate(), | |||||
| ) | |||||
| stockLedgerRepository.saveAndFlush(stockLedger) | 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( | private fun createStockLedgerForPickDelta( | ||||
| stockOutLine: StockOutLine, | stockOutLine: StockOutLine, | ||||
| deltaQty: BigDecimal, | 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 { | val ledger = StockLedger().apply { | ||||
| this.stockOutLine = stockOutLine | this.stockOutLine = stockOutLine | ||||
| this.inventory = inventory | this.inventory = inventory | ||||
| @@ -1800,10 +1816,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| this.type = "NOR" | this.type = "NOR" | ||||
| this.itemId = item.id | this.itemId = item.id | ||||
| this.itemCode = item.code | 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() | this.date = LocalDate.now() | ||||
| } | } | ||||
| StockLedgerLotSnapshot.apply( | |||||
| ledger = ledger, | |||||
| inventoryLotLine = ill, | |||||
| lotAvailableAfterMove = lotAfter, | |||||
| signedDelta = deltaQty.negate(), | |||||
| ) | |||||
| if (flushAfterSave) { | if (flushAfterSave) { | ||||
| stockLedgerRepository.saveAndFlush(ledger) | stockLedgerRepository.saveAndFlush(ledger) | ||||
| @@ -2197,6 +2222,9 @@ fun applyStockOutLineDelta( | |||||
| val outQty = deltaQty.toDouble() | val outQty = deltaQty.toDouble() | ||||
| val newBalance = previousBalance - outQty | val newBalance = previousBalance - outQty | ||||
| val ill = savedSol.inventoryLotLine | |||||
| val outQtyBd = BigDecimal.valueOf(outQty) | |||||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||||
| val ledger = StockLedger().apply { | val ledger = StockLedger().apply { | ||||
| this.stockOutLine = savedSol | this.stockOutLine = savedSol | ||||
| this.inventory = inventory | this.inventory = inventory | ||||
| @@ -2207,14 +2235,23 @@ fun applyStockOutLineDelta( | |||||
| this.itemId = item.id | this.itemId = item.id | ||||
| this.itemCode = item.code | this.itemCode = item.code | ||||
| this.uomId = | 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() | this.date = eventTime.toLocalDate() | ||||
| if (!operator.isNullOrBlank()) { | if (!operator.isNullOrBlank()) { | ||||
| this.createdBy = operator | this.createdBy = operator | ||||
| this.modifiedBy = operator | this.modifiedBy = operator | ||||
| } | } | ||||
| } | } | ||||
| StockLedgerLotSnapshot.apply( | |||||
| ledger = ledger, | |||||
| inventoryLotLine = ill, | |||||
| lotAvailableAfterMove = lotAfter, | |||||
| signedDelta = outQtyBd.negate(), | |||||
| ) | |||||
| if (deferPersistenceFlush) stockLedgerRepository.save(ledger) | if (deferPersistenceFlush) stockLedgerRepository.save(ledger) | ||||
| else stockLedgerRepository.saveAndFlush(ledger) | else stockLedgerRepository.saveAndFlush(ledger) | ||||
| } | } | ||||
| @@ -2330,7 +2367,9 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult | |||||
| } | } | ||||
| val newBalance = prevBalance - delta.toDouble() | 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.stockOutLine = sol | ||||
| this.inventory = inv | this.inventory = inv | ||||
| this.inQty = null | this.inQty = null | ||||
| @@ -2339,10 +2378,20 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult | |||||
| this.type = request.type | this.type = request.type | ||||
| this.itemId = itemId | this.itemId = itemId | ||||
| this.itemCode = item.code | 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 | this.date = today | ||||
| } | } | ||||
| StockLedgerLotSnapshot.apply( | |||||
| ledger = ledger, | |||||
| inventoryLotLine = ill, | |||||
| lotAvailableAfterMove = lotAfter, | |||||
| signedDelta = delta.negate(), | |||||
| ) | |||||
| ledgersToInsert += ledger | |||||
| runningLedgerBalanceByItemId[itemId] = newBalance | runningLedgerBalanceByItemId[itemId] = newBalance | ||||
| } | } | ||||
| stockLedgerRepository.saveAll(ledgersToInsert) | stockLedgerRepository.saveAll(ledgersToInsert) | ||||
| @@ -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]) | @Transactional(rollbackFor = [Exception::class]) | ||||
| open fun scanPick(request: WorkbenchScanPickRequest): MessageResponse { | open fun scanPick(request: WorkbenchScanPickRequest): MessageResponse { | ||||
| @@ -517,6 +517,9 @@ if (updated == 0) { | |||||
| val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| val previousBalance = onHandAfter + deltaQty.toDouble() | val previousBalance = onHandAfter + deltaQty.toDouble() | ||||
| val newBalance = previousBalance - 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 { | val ledger = StockLedger().apply { | ||||
| this.stockOutLine = sol | this.stockOutLine = sol | ||||
| this.inventory = inventory | this.inventory = inventory | ||||
| @@ -526,10 +529,19 @@ if (updated == 0) { | |||||
| this.type = "NOR" | this.type = "NOR" | ||||
| this.itemId = solItem.id | this.itemId = solItem.id | ||||
| this.itemCode = solItem.code | 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() | this.date = LocalDate.now() | ||||
| } | } | ||||
| StockLedgerLotSnapshot.apply( | |||||
| ledger = ledger, | |||||
| inventoryLotLine = ill, | |||||
| lotAvailableAfterMove = lotAfter, | |||||
| signedDelta = deltaQty.negate(), | |||||
| ) | |||||
| stockLedgerRepository.save(ledger) | stockLedgerRepository.save(ledger) | ||||
| } | } | ||||
| @@ -2488,6 +2488,10 @@ private fun applyVarianceAdjustment( | |||||
| } | } | ||||
| val newBalance = previousBalance - qtyToRemove.toDouble() | 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 { | val stockLedger = StockLedger().apply { | ||||
| this.inventory = inventory | this.inventory = inventory | ||||
| this.itemId = inventoryLot.item?.id | this.itemId = inventoryLot.item?.id | ||||
| @@ -2497,9 +2501,18 @@ private fun applyVarianceAdjustment( | |||||
| this.stockOutLine = stockOutLine | this.stockOutLine = stockOutLine | ||||
| this.balance = newBalance | this.balance = newBalance | ||||
| this.type = "TKE" | this.type = "TKE" | ||||
| this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id | |||||
| this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( | |||||
| latestLine, | |||||
| inventory.uom?.id, | |||||
| ) | |||||
| this.date = LocalDate.now() | this.date = LocalDate.now() | ||||
| } | } | ||||
| StockLedgerLotSnapshot.apply( | |||||
| ledger = stockLedger, | |||||
| inventoryLotLine = latestLine, | |||||
| lotAvailableAfterMove = lotAfterAvail, | |||||
| signedDelta = qtyToRemove.negate(), | |||||
| ) | |||||
| if (context != null) { | if (context != null) { | ||||
| context.stockLedgers.add(stockLedger) | context.stockLedgers.add(stockLedger) | ||||
| @@ -2613,6 +2626,9 @@ private fun applyVarianceAdjustment( | |||||
| } | } | ||||
| val newBalance = previousBalance + plusQty.toDouble() | 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 { | val stockLedger = StockLedger().apply { | ||||
| this.inventory = inventory | this.inventory = inventory | ||||
| this.itemId = inventoryLot.item?.id | this.itemId = inventoryLot.item?.id | ||||
| @@ -2622,9 +2638,18 @@ private fun applyVarianceAdjustment( | |||||
| this.stockInLine = stockInLine | this.stockInLine = stockInLine | ||||
| this.balance = newBalance | this.balance = newBalance | ||||
| this.type = "TKE" | this.type = "TKE" | ||||
| this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id | |||||
| this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( | |||||
| latestLine, | |||||
| inventory.uom?.id, | |||||
| ) | |||||
| this.date = LocalDate.now() | this.date = LocalDate.now() | ||||
| } | } | ||||
| StockLedgerLotSnapshot.apply( | |||||
| ledger = stockLedger, | |||||
| inventoryLotLine = latestLine, | |||||
| lotAvailableAfterMove = lotAfterAvail, | |||||
| signedDelta = plusQty, | |||||
| ) | |||||
| if (context != null) { | if (context != null) { | ||||
| context.stockLedgers.add(stockLedger) | context.stockLedgers.add(stockLedger) | ||||
| @@ -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 (已揀 + 進行中). | * After workbench scan-pick updates [StockOutLine] qty/status, bind SPL and set **qty** to match SOL (已揀 + 進行中). | ||||
| */ | */ | ||||
| @Transactional(rollbackFor = [Exception::class]) | @Transactional(rollbackFor = [Exception::class]) | ||||
| @@ -97,7 +98,14 @@ open class SuggestedPickLotWorkbenchService( | |||||
| } | } | ||||
| if (spl != null) { | if (spl != null) { | ||||
| spl.stockOutLine = sol | 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.pickOrderLine = pol | ||||
| spl.qty = qtyBd | spl.qty = qtyBd | ||||
| spl.type = SuggestedPickLotType.PICK_ORDER | spl.type = SuggestedPickLotType.PICK_ORDER | ||||
| @@ -109,6 +117,7 @@ open class SuggestedPickLotWorkbenchService( | |||||
| type = SuggestedPickLotType.PICK_ORDER | type = SuggestedPickLotType.PICK_ORDER | ||||
| stockOutLine = sol | stockOutLine = sol | ||||
| suggestedLotLine = ill | suggestedLotLine = ill | ||||
| actualPickLotLine = ill | |||||
| pickOrderLine = pol | pickOrderLine = pol | ||||
| qty = qtyBd | 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). | * Lot stock UOM must match pick_order_line.uom (UomConversion id). | ||||
| */ | */ | ||||
| private fun matchesPolUom(lot: InventoryLotLine, line: PickOrderLine): Boolean { | 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: | * Build suggestions with no-hold rule: | ||||
| * available = inQty - outQty (ignore holdQty). | * 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). | * 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]. | * 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. | * 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): | * 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. | * 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). | * 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: | * Workbench explicit-qty split support: | ||||
| * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). | * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). | ||||
| * - If [targetQty] <= 0: soft-delete all active suggestions for the line. | * - 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. | * Align DB rows with [desired]. Returns count of newly inserted SPL rows. | ||||
| */ | */ | ||||
| private fun syncSuggestedPickLotsForLine( | private fun syncSuggestedPickLotsForLine( | ||||
| @@ -579,6 +589,14 @@ open class SuggestedPickLotWorkbenchService( | |||||
| val reuse = keepers.maxByOrNull { it.id ?: 0L } | val reuse = keepers.maxByOrNull { it.id ?: 0L } | ||||
| if (reuse != null) { | if (reuse != null) { | ||||
| reuse.suggestedLotLine = ill | 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.pickOrderLine = line | ||||
| reuse.type = SuggestedPickLotType.PICK_ORDER | reuse.type = SuggestedPickLotType.PICK_ORDER | ||||
| reuse.qty = qty | reuse.qty = qty | ||||
| @@ -595,6 +613,7 @@ open class SuggestedPickLotWorkbenchService( | |||||
| SuggestedPickLot().apply { | SuggestedPickLot().apply { | ||||
| type = SuggestedPickLotType.PICK_ORDER | type = SuggestedPickLotType.PICK_ORDER | ||||
| suggestedLotLine = ill | suggestedLotLine = ill | ||||
| actualPickLotLine = ill | |||||
| pickOrderLine = line | pickOrderLine = line | ||||
| this.qty = qty | this.qty = qty | ||||
| }, | }, | ||||
| @@ -616,6 +635,12 @@ open class SuggestedPickLotWorkbenchService( | |||||
| val spl = byIllKey[k] | val spl = byIllKey[k] | ||||
| if (spl != null) { | if (spl != null) { | ||||
| spl.suggestedLotLine = ill | spl.suggestedLotLine = ill | ||||
| if (spl.actualPickLotLine == null || | |||||
| spl.stockOutLine == null || | |||||
| !isSplFrozen(spl) | |||||
| ) { | |||||
| spl.actualPickLotLine = ill | |||||
| } | |||||
| spl.pickOrderLine = line | spl.pickOrderLine = line | ||||
| spl.type = SuggestedPickLotType.PICK_ORDER | spl.type = SuggestedPickLotType.PICK_ORDER | ||||
| spl.qty = qty | spl.qty = qty | ||||
| @@ -629,6 +654,7 @@ open class SuggestedPickLotWorkbenchService( | |||||
| SuggestedPickLot().apply { | SuggestedPickLot().apply { | ||||
| type = SuggestedPickLotType.PICK_ORDER | type = SuggestedPickLotType.PICK_ORDER | ||||
| suggestedLotLine = ill | suggestedLotLine = ill | ||||
| actualPickLotLine = ill | |||||
| pickOrderLine = line | pickOrderLine = line | ||||
| this.qty = qty | this.qty = qty | ||||
| }, | }, | ||||
| @@ -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`); | |||||
| @@ -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`); | |||||