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 | |||
| } | |||
| /** | |||
| * 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( | |||
| /** 例如 `CASE WHEN s.code IN (...) THEN '4F' ... END`(單行,可嵌入原生 SQL) */ | |||
| val floorStringCase: String, | |||
| @@ -23,6 +23,7 @@ open class DoWorkbenchDopoAssignmentService( | |||
| private val pickOrderRepository: PickOrderRepository, | |||
| private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | |||
| private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, | |||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||
| ) { | |||
| @Transactional | |||
| @@ -95,7 +96,7 @@ open class DoWorkbenchDopoAssignmentService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 | |||
| * Same UX as [DoPickOrderAssignmentService.assignByLane] but candidates come from | |||
| * [delivery_order_pick_order] (+ unassigned [pick_order]), not [do_pick_order]. | |||
| */ | |||
| @@ -208,7 +209,7 @@ open class DoWorkbenchDopoAssignmentService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 | |||
| * Legacy lane assign (V1): old FG-style (no atomic conflict guard). | |||
| */ | |||
| @Transactional | |||
| @@ -399,18 +400,19 @@ open class DoWorkbenchDopoAssignmentService( | |||
| ) | |||
| } | |||
| // 下方逻辑先保持不变(你后续再做第二阶段批次化) | |||
| // Suggestion store scope: use DOPO.storeId when set; Truck X (null) → 2/F or 4/F from DO suppliers. | |||
| val storeIdRow = jdbcDao.queryForList( | |||
| "SELECT storeId FROM fpsmsdb.delivery_order_pick_order WHERE id = :id AND deleted = 0", | |||
| mapOf("id" to dopoId), | |||
| ).firstOrNull() | |||
| val storeIdKey = storeIdRow?.keys?.find { it.equals("storeId", true) } | |||
| val storeId = storeIdKey?.let { storeIdRow[it]?.toString() }?.trim()?.takeIf { it.isNotEmpty() } | |||
| val dbStoreId = storeIdKey?.let { storeIdRow[it]?.toString() }?.trim()?.takeIf { it.isNotEmpty() } | |||
| val suggestionStoreId = resolveSuggestionStoreIdForDopo(dopoId, dbStoreId) | |||
| for (poId in poIds) { | |||
| suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( | |||
| pickOrderId = poId, | |||
| storeId = storeId, | |||
| storeId = suggestionStoreId, | |||
| excludeWarehouseCodes = null, | |||
| ) | |||
| stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(poId, userId) | |||
| @@ -438,4 +440,33 @@ open class DoWorkbenchDopoAssignmentService( | |||
| ) | |||
| ) | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 | |||
| * Truck X: derive 2/F|4/F from linked DO suppliers when DOPO.storeId is null. | |||
| */ | |||
| private fun resolveSuggestionStoreIdForDopo(dopoId: Long, dbStoreId: String?): String { | |||
| if (!dbStoreId.isNullOrBlank()) { | |||
| return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList()) | |||
| } | |||
| val rows = try { | |||
| jdbcDao.queryForList( | |||
| """ | |||
| SELECT DISTINCT s.code AS supplierCode | |||
| FROM fpsmsdb.pick_order po | |||
| INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 | |||
| LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 | |||
| WHERE po.deliveryOrderPickOrderId = :dopoId AND po.deleted = 0 | |||
| """.trimIndent(), | |||
| mapOf("dopoId" to dopoId), | |||
| ) | |||
| } catch (_: Exception) { | |||
| emptyList() | |||
| } | |||
| val codes = rows.mapNotNull { row -> | |||
| val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null | |||
| row[k]?.toString() | |||
| } | |||
| return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes) | |||
| } | |||
| } | |||
| @@ -45,6 +45,7 @@ import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus | |||
| import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo | |||
| import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService | |||
| import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService | |||
| import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot | |||
| import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress | |||
| import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus | |||
| import org.springframework.stereotype.Service | |||
| @@ -266,6 +267,7 @@ open class DoWorkbenchMainService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 | |||
| * Workbench scan-pick (DO FG): | |||
| * 1) Post outbound on scanned inventory lot line first; on failure return a clear message. | |||
| * 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty], | |||
| @@ -324,6 +326,7 @@ open class DoWorkbenchMainService( | |||
| sol.handledBy = request.userId | |||
| if (sol.startTime == null) sol.startTime = LocalDateTime.now() | |||
| sol.endTime = LocalDateTime.now() | |||
| maybeMarkIssueJustComplete(sol, request.justComplete == true) | |||
| stockOutLIneRepository.saveAndFlush(sol) | |||
| val polZero = sol.pickOrderLine | |||
| updateJoPickOrderHandledByIfJobOrder( | |||
| @@ -618,6 +621,7 @@ if (sol.startTime == null) sol.startTime = LocalDateTime.now() | |||
| if (solEndStatus.equals(StockOutLineStatus.COMPLETE.status, ignoreCase = true)) { | |||
| sol.endTime = LocalDateTime.now() | |||
| } | |||
| maybeMarkIssueJustComplete(sol, request.justComplete == true) | |||
| stockOutLIneRepository.save(sol) | |||
| stockOutLIneRepository.flush() | |||
| sol.id?.let { suggestedPickLotWorkbenchService.linkSplToStockOutLineAfterWorkbenchPick(it) } | |||
| @@ -887,7 +891,7 @@ return MessageResponse( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 | |||
| * Workbench Etra view: `isExtra` / `isExtrabatch` / `isExtrasingle` tickets for one day, | |||
| * grouped by shop then truck / time / loading sequence. | |||
| * Truck X (`車線-X`, DB `storeId` null) is included and split by supplier preferred floor | |||
| @@ -1127,7 +1131,7 @@ return MessageResponse( | |||
| /** | |||
| * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today). | |||
| * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day). | |||
| * @param releaseTypeFilter when `isExtra`/`etra`/`etraFamily`, filter Etra family (`isExtra`/`isExtrabatch`/`isExtrasingle`). | |||
| * @param releaseTypeFilter when `isExtra`/`etra`, filters Etra family (`isExtra` / `isExtrabatch` / `isExtrasingle`). | |||
| * @param floor optional `2F`/`4F`: for Truck X (null storeId) filter by DO supplier preferred floor. | |||
| */ | |||
| open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | |||
| @@ -1739,6 +1743,100 @@ return MessageResponse( | |||
| ) | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 | |||
| * Store scope for workbench lot (re)suggestions. | |||
| * Prefer [requestStoreId]; else DOPO.storeId; Truck X (both null) → 2/F|4/F from DO suppliers. | |||
| */ | |||
| private fun resolveWorkbenchSuggestionStoreId(pickOrderId: Long, requestStoreId: String?): String { | |||
| if (!requestStoreId.isNullOrBlank()) { | |||
| return doFloorSupplierSettingsService.resolveSuggestionStoreId(requestStoreId, emptyList()) | |||
| } | |||
| val header = try { | |||
| jdbcDao.queryForList( | |||
| """ | |||
| SELECT dop.storeId AS storeId | |||
| FROM fpsmsdb.pick_order po | |||
| INNER JOIN fpsmsdb.delivery_order_pick_order dop | |||
| ON dop.id = po.deliveryOrderPickOrderId AND dop.deleted = 0 | |||
| WHERE po.id = :poId AND po.deleted = 0 | |||
| LIMIT 1 | |||
| """.trimIndent(), | |||
| mapOf("poId" to pickOrderId), | |||
| ).firstOrNull() | |||
| } catch (_: Exception) { | |||
| null | |||
| } | |||
| val dbStoreId = header?.let { row -> | |||
| val k = row.keys.find { it.equals("storeId", true) } ?: return@let null | |||
| row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() } | |||
| } | |||
| if (!dbStoreId.isNullOrBlank()) { | |||
| return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList()) | |||
| } | |||
| val supplierRows = try { | |||
| jdbcDao.queryForList( | |||
| """ | |||
| SELECT DISTINCT s.code AS supplierCode | |||
| FROM fpsmsdb.pick_order po | |||
| INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 | |||
| LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 | |||
| WHERE po.id = :poId AND po.deleted = 0 | |||
| """.trimIndent(), | |||
| mapOf("poId" to pickOrderId), | |||
| ) | |||
| } catch (_: Exception) { | |||
| emptyList() | |||
| } | |||
| val codes = supplierRows.mapNotNull { row -> | |||
| val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null | |||
| row[k]?.toString() | |||
| } | |||
| return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes) | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 | |||
| * UI / lot-print / scan-pick store scope for a DOPO. | |||
| * Truck X keeps DB storeId null; response may expose derived 2/F|4/F (not written to DB). | |||
| */ | |||
| private fun resolveDisplayStoreIdForDopo(dopoId: Long, dbStoreId: String?): String { | |||
| if (!dbStoreId.isNullOrBlank()) { | |||
| return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList()) | |||
| } | |||
| val supplierRows = try { | |||
| jdbcDao.queryForList( | |||
| """ | |||
| SELECT DISTINCT s.code AS supplierCode | |||
| FROM fpsmsdb.pick_order po | |||
| INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 | |||
| LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 | |||
| WHERE po.deliveryOrderPickOrderId = :dopoId AND po.deleted = 0 | |||
| """.trimIndent(), | |||
| mapOf("dopoId" to dopoId), | |||
| ) | |||
| } catch (_: Exception) { | |||
| emptyList() | |||
| } | |||
| val codes = supplierRows.mapNotNull { row -> | |||
| val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null | |||
| row[k]?.toString() | |||
| } | |||
| return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes) | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 | |||
| * Copy DOPO header map with Truck X display storeId filled for hierarchical fgInfo. | |||
| */ | |||
| private fun enrichDopoInfoStoreIdForUi(doPickOrderInfo: Map<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] | |||
| * (`2F` / `4F`), and **no** linked DO on the other floor. | |||
| @@ -1772,7 +1870,7 @@ return MessageResponse( | |||
| """.trimIndent() | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */ | |||
| private fun queryWorkbenchReleasedDopoList( | |||
| shopName: String?, | |||
| storeId: String?, | |||
| @@ -2021,7 +2119,7 @@ return MessageResponse( | |||
| payload = assembleHierarchicalFgPayload( | |||
| jdbcDao = jdbcDao, | |||
| doPickOrderId = doPickOrderId, | |||
| doPickOrderInfo = doPickOrderInfoSafe, | |||
| doPickOrderInfo = enrichDopoInfoStoreIdForUi(doPickOrderInfoSafe, doPickOrderId), | |||
| pickOrdersInfo = pickOrdersInfo, | |||
| availableQtyInOutOnly = true, | |||
| timingSink = ::mark, | |||
| @@ -2086,13 +2184,12 @@ return MessageResponse( | |||
| return mapOf("fgInfo" to null, "pickOrders" to emptyList<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 -> | |||
| assembleHierarchicalFgPayload( | |||
| jdbcDao = jdbcDao, | |||
| doPickOrderId = deliveryOrderPickOrderId, | |||
| doPickOrderInfo = doPickOrderInfo, | |||
| doPickOrderInfo = doPickOrderInfoForUi, | |||
| pickOrdersInfo = listOf(poInfo), | |||
| availableQtyInOutOnly = true, | |||
| ) | |||
| @@ -2176,6 +2273,13 @@ return MessageResponse( | |||
| } else { | |||
| emptyList() | |||
| } | |||
| val dopoId = (row["doPickOrderId"] as? Number)?.toLong() ?: 0L | |||
| val dbStoreId = row["storeId"]?.toString()?.trim()?.takeIf { it.isNotEmpty() } | |||
| val displayStoreId = if (dopoId > 0L) { | |||
| resolveDisplayStoreIdForDopo(dopoId, dbStoreId) | |||
| } else { | |||
| dbStoreId.orEmpty() | |||
| } | |||
| mapOf( | |||
| "doPickOrderId" to (row["doPickOrderId"] ?: 0L), | |||
| @@ -2200,7 +2304,7 @@ return MessageResponse( | |||
| "truckLanceCode" to (row["truckLanceCode"] ?: ""), | |||
| "DepartureTime" to (row["DepartureTime"]?.toString() ?: ""), | |||
| "ticketNo" to (row["ticketNo"] ?: ""), | |||
| "storeId" to (row["storeId"] ?: ""), | |||
| "storeId" to displayStoreId, | |||
| "qrCodeData" to (row["doPickOrderId"] ?: 0L), | |||
| ) | |||
| } | |||
| @@ -2282,6 +2386,7 @@ return MessageResponse( | |||
| var postMs = 0L | |||
| try { | |||
| if (pickOrderId != null) { | |||
| val suggestionStoreId = resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) | |||
| val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes( | |||
| pickOrderId = pickOrderId, | |||
| poType = null, | |||
| @@ -2294,7 +2399,7 @@ return MessageResponse( | |||
| suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineNextSingleLot( | |||
| pickOrderLineId = polId, | |||
| targetQty = explicitRemainder, | |||
| storeId = requestStoreId, | |||
| storeId = suggestionStoreId, | |||
| excludeInventoryLotLineId = scannedIllId, | |||
| excludeWarehouseCodes = resuggestExcludeWarehouseCodes, | |||
| ) | |||
| @@ -2328,7 +2433,7 @@ return MessageResponse( | |||
| rebuildMs = measureTimeMillis { | |||
| suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine( | |||
| pickOrderLineId = polId, | |||
| storeId = requestStoreId, | |||
| storeId = suggestionStoreId, | |||
| excludeWarehouseCodes = resuggestExcludeWarehouseCodes, | |||
| ) | |||
| } | |||
| @@ -2485,6 +2590,9 @@ return MessageResponse( | |||
| val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val previousBalance = onHandAfter + deltaQty.toDouble() | |||
| val newBalance = previousBalance - deltaQty.toDouble() | |||
| val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } | |||
| ?: sol.inventoryLotLine | |||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||
| val ledger = StockLedger().apply { | |||
| this.stockOutLine = sol | |||
| this.inventory = inventory | |||
| @@ -2494,14 +2602,54 @@ return MessageResponse( | |||
| this.type = "NOR" | |||
| this.itemId = solItem.id | |||
| this.itemCode = solItem.code | |||
| this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id | |||
| ?: inventory.uom?.id | |||
| this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( | |||
| ill, | |||
| itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id, | |||
| inventory.uom?.id, | |||
| ) | |||
| this.date = LocalDate.now() | |||
| } | |||
| StockLedgerLotSnapshot.apply( | |||
| ledger = ledger, | |||
| inventoryLotLine = ill, | |||
| lotAvailableAfterMove = lotAfter, | |||
| signedDelta = deltaQty.negate(), | |||
| ) | |||
| // Do not flush per pick; transaction flush is sufficient and avoids slow roundtrips/locks. | |||
| stockLedgerRepository.save(ledger) | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 | |||
| * DO user-audit: Just Complete is reportable only when the item still has pickable stock | |||
| * at complete time (AVAILABLE, not expired, in−out > 0). | |||
| * | |||
| * Legitimate (do not flag): suggested lot exhausted → system splits remainder SOL → JC qty=0 | |||
| * with no other available lots. | |||
| * Issue (flag): between split and JC a new/other available lot exists but user still JC. | |||
| */ | |||
| private fun maybeMarkIssueJustComplete(sol: StockOutLine, justComplete: Boolean) { | |||
| if (!justComplete) return | |||
| val pol = sol.pickOrderLine ?: return | |||
| val po = pol.pickOrder | |||
| val isDo = po?.type == PickOrderType.DELIVERY_ORDER | |||
| if (!isDo) return | |||
| val itemId = pol.item?.id ?: sol.item?.id ?: return | |||
| val today = LocalDate.now() | |||
| val excludeIllId = sol.inventoryLotLine?.id | |||
| val hasPickableLot = inventoryLotLineRepository.findAllByItemIdIn(listOf(itemId)) | |||
| .asSequence() | |||
| .filter { !it.deleted } | |||
| .filter { excludeIllId == null || it.id != excludeIllId } | |||
| .filter { it.status == InventoryLotLineStatus.AVAILABLE } | |||
| .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | |||
| .any { StockLedgerLotSnapshot.availableQty(it) > BigDecimal.ZERO } | |||
| if (hasPickableLot) { | |||
| sol.isIssueJustComplete = true | |||
| } | |||
| } | |||
| private fun resolveWorkbenchPreviousBalance( | |||
| itemId: Long, | |||
| inventory: Inventory, | |||
| @@ -15,7 +15,7 @@ object WorkbenchReleaseTypeSupport { | |||
| 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 = | |||
| when (releaseType.trim().lowercase()) { | |||
| "batch" -> batchFamilySql(column) | |||
| @@ -25,7 +25,7 @@ object WorkbenchReleaseTypeSupport { | |||
| else -> "" | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */ | |||
| fun assignFilterSql(releaseType: String?, column: String = "dop.releaseType"): String { | |||
| val n = releaseType?.trim()?.lowercase().orEmpty() | |||
| return when (n) { | |||
| @@ -46,7 +46,7 @@ object WorkbenchReleaseTypeSupport { | |||
| " AND LOWER(COALESCE($column, '')) = 'isextra' " | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 | |||
| * Standalone `isExtra` plus merged `isExtrabatch` / `isExtrasingle` (Etra workbench). | |||
| */ | |||
| fun etraFamilySql(column: String = "dop.releaseType"): String = | |||
| @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.deliveryOrder.web.models | |||
| import java.math.BigDecimal | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 | |||
| * Workbench v1: scan lot and post pick immediately (no separate submit step). | |||
| * [qty] optional: when null, posts up to remaining quantity for this stock-out line chunk; when set, may exceed that | |||
| * chunk and is capped only by available quantity on the scanned inventory lot line (overscan / UI edit). | |||
| @@ -25,6 +26,11 @@ data class WorkbenchScanPickRequest( | |||
| */ | |||
| val inventoryLotLineId: Long? = null, | |||
| val qty: BigDecimal? = null, | |||
| /** | |||
| * When true, UI used Just Complete (no QR). Backend may set [StockOutLine.isIssueJustComplete] | |||
| * for DO user-audit reporting. | |||
| */ | |||
| val justComplete: Boolean? = false, | |||
| val userId: Long = 1L, | |||
| ) | |||
| @@ -231,6 +231,7 @@ open class JoPickOrderService( | |||
| } | |||
| return joPickOrderRecordRepository.saveAll(joPickOrderRecords) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */ | |||
| open fun getAllJobOrderLotsWithDetailsHierarchical(userId: Long): JobOrderLotsHierarchicalResponse { | |||
| println("=== Debug: getAllJobOrderLotsWithDetailsHierarchical ===") | |||
| println("today: ${LocalDate.now()}") | |||
| @@ -557,6 +558,7 @@ open class JoPickOrderService( | |||
| } | |||
| // Get completed job order pick orders (for second tab) | |||
| // Fix the getCompletedJobOrderLotsHierarchical method | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */ | |||
| open fun getCompletedJobOrderLotsHierarchical(userId: Long): Map<String, Any?> { | |||
| println("=== Debug: getCompletedJobOrderLotsHierarchical (repo version) ===") | |||
| println("today: ${LocalDate.now()}") | |||
| @@ -625,13 +627,20 @@ open class JoPickOrderService( | |||
| .filter { it.deleted == false } | |||
| } else emptyList() | |||
| val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id } | |||
| // Prefer actualPickLotLine for display (after scan/switch); fallback suggested | |||
| val inventoryLotLineIds = suggestedPickLots | |||
| .mapNotNull { it.actualPickLotLine?.id ?: it.suggestedLotLine?.id } | |||
| .distinct() | |||
| val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { | |||
| inventoryLotLineRepository | |||
| .findAllByIdIn(inventoryLotLineIds) | |||
| .filter { it.deleted == false } | |||
| } else emptyList() | |||
| val inventoryLotLineById = inventoryLotLines | |||
| .filter { it.id != null } | |||
| .associateBy { it.id!! } | |||
| val inventoryLotIds = inventoryLotLines.mapNotNull { it.inventoryLot?.id }.distinct() | |||
| val inventoryLots = if (inventoryLotIds.isNotEmpty()) { | |||
| @@ -664,16 +673,19 @@ open class JoPickOrderService( | |||
| } | |||
| val lots = lineSuggestedLots.mapNotNull { spl -> | |||
| val ill = spl.suggestedLotLine ?: return@mapNotNull null | |||
| if (ill.deleted == true) return@mapNotNull null | |||
| val displayIllId = (spl.actualPickLotLine?.takeIf { it.deleted != true }?.id) | |||
| ?: (spl.suggestedLotLine?.takeIf { it.deleted != true }?.id) | |||
| ?: return@mapNotNull null | |||
| val ill = inventoryLotLineById[displayIllId] ?: return@mapNotNull null | |||
| val il = ill.inventoryLot ?: return@mapNotNull null | |||
| if (il.deleted == true) return@mapNotNull null | |||
| val warehouse = ill.warehouse | |||
| val sol = stockOutLines.firstOrNull { | |||
| it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id | |||
| } | |||
| val sol = spl.stockOutLine?.takeIf { it.deleted != true && it.pickOrderLine?.id == pol.id } | |||
| ?: stockOutLines.firstOrNull { | |||
| it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id | |||
| } | |||
| val availableQty = if (sol?.status == "rejected") { | |||
| null | |||
| @@ -1140,7 +1152,7 @@ open fun recordSecondScanIssue(request: SecondScanIssueRequest): MessageResponse | |||
| il.lotNo as lotNo, | |||
| w.name as storeLocation | |||
| FROM fpsmsdb.suggested_pick_lot spl | |||
| JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id | |||
| JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) | |||
| JOIN fpsmsdb.inventory_lot il ON ill.inventoryLotId = il.id | |||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | |||
| WHERE spl.pickOrderLineId = :pickOrderLineId | |||
| @@ -1506,7 +1518,7 @@ open fun getCompletedJobOrderPickOrderLotDetails(pickOrderId: Long): List<Map<St | |||
| JOIN fpsmsdb.items i ON i.id = pol.itemId | |||
| 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.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.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 | |||
| 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.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.warehouse w ON w.id = ill.warehouseId | |||
| -- 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 | |||
| 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.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.warehouse w ON w.id = ill.warehouseId | |||
| @@ -2187,7 +2199,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| FROM fpsmsdb.pick_order po | |||
| 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.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.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = false | |||
| WHERE po.deleted = false | |||
| @@ -2382,7 +2394,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| jobOrderType = jobOrderType?.name, | |||
| itemId = item.id ?: 0L, | |||
| itemName = item.name ?: "", | |||
| itemCode = item.code, | |||
| bomDescription = bom?.description, | |||
| bomType = bom?.type, | |||
| reqQty = jobOrder.reqQty ?: BigDecimal.ZERO, | |||
| //uomId = bom.outputQtyUom?.id : 0L, | |||
| 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 { | |||
| println("=== getJobOrderLotsHierarchicalByPickOrderId ===") | |||
| println("pickOrderId: $pickOrderId") | |||
| @@ -2518,7 +2533,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| } | |||
| // 获取所有 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 | |||
| val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { | |||
| @@ -2571,18 +2588,13 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| .associateBy { it.id!! } | |||
| // 获取 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 记录 | |||
| @@ -2617,22 +2629,22 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| userService.find(uid).orElse(null)?.name | |||
| } | |||
| // 构建 lots 数据 | |||
| // COALESCE(actualPickLotLine, suggestedLotLine) for display after switch | |||
| 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 | |||
| if (il == null || il.deleted == true) return@mapNotNull null | |||
| val warehouse = ill.warehouse | |||
| val sol = stockOutLines.firstOrNull { | |||
| it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id | |||
| } | |||
| val jpo = joPickOrders.firstOrNull { it.itemId == item?.id } | |||
| val handlerName = jpo?.handledBy?.let { uid -> | |||
| userService.find(uid).orElse(null)?.name | |||
| } | |||
| println("handlerName: $handlerName") | |||
| val sol = spl.stockOutLine?.takeIf { it.deleted != true && it.pickOrderLine?.id == pol.id } | |||
| ?: stockOutLines.firstOrNull { | |||
| it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id | |||
| } | |||
| val jpoInner = joPickOrders.firstOrNull { it.itemId == item?.id } | |||
| val availableQty = if (sol?.status == "rejected") { | |||
| null | |||
| } else { | |||
| @@ -2655,7 +2667,6 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| else -> "pending" | |||
| } | |||
| // 获取 stockInLineId | |||
| val stockInLineId = ill.id?.let { illId -> | |||
| stockInLinesByInventoryLotLineId[illId] | |||
| } | |||
| @@ -2685,11 +2696,11 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| routerArea = warehouse?.code, | |||
| routerRoute = warehouse?.code, | |||
| 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) | |||
| 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). | |||
| * [getJobOrderLotsHierarchicalByPickOrderIdWorkbench] uses **in − out** for available qty (matches workbench scan-pick). | |||
| * [getJobOrderLotsHierarchicalByPickOrderIdWorkbench] uses **in − out** for available qty (matches workbench scan-pick) | |||
| * and displays COALESCE(actualPickLotLine, suggestedLotLine). | |||
| * Non-workbench hierarchical stays in [JoPickOrderService.getJobOrderLotsHierarchicalByPickOrderId] (in − out − hold). | |||
| */ | |||
| @Service | |||
| @@ -190,7 +191,9 @@ open class JoWorkbenchMainService( | |||
| } | |||
| /** | |||
| * Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line. | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 | |||
| * Hierarchical pick UI for JO Workbench: available qty **in − out**; | |||
| * lot display uses COALESCE(actualPickLotLine, suggestedLotLine) (same as DO workbench). | |||
| */ | |||
| open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { | |||
| println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") | |||
| @@ -239,7 +242,10 @@ open class JoWorkbenchMainService( | |||
| emptyList() | |||
| } | |||
| val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id } | |||
| // Prefer actualPickLotLine for display (after scan/switch); fallback suggested (old / not yet picked). | |||
| val inventoryLotLineIds = suggestedPickLots | |||
| .mapNotNull { displayIllForWorkbench(it)?.id } | |||
| .distinct() | |||
| val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { | |||
| inventoryLotLineRepository.findAllByIdIn(inventoryLotLineIds) | |||
| @@ -253,16 +259,6 @@ open class JoWorkbenchMainService( | |||
| inventoryLotRepository.findAllByIdIn(inventoryLotIds).filter { it.deleted == false } | |||
| } | |||
| val stockOutLines = if (pickOrderLineIds.isNotEmpty() && inventoryLotLineIds.isNotEmpty()) { | |||
| pickOrderLineIds.flatMap { polId -> | |||
| inventoryLotLineIds.flatMap { illId -> | |||
| stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse(polId, illId) | |||
| } | |||
| } | |||
| } else { | |||
| emptyList() | |||
| } | |||
| val stockOutLinesByPickOrderLine = pickOrderLineIds.associateWith { polId -> | |||
| stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) | |||
| } | |||
| @@ -322,27 +318,33 @@ open class JoWorkbenchMainService( | |||
| val uom = pol.uom | |||
| val lineId = pol.id!! | |||
| val lineSuggestedLots = suggestedPickLots.filter { it.pickOrderLine?.id == pol.id } | |||
| val lineSols = stockOutLinesByPickOrderLine[lineId] ?: emptyList() | |||
| val jpo = joPickOrders.firstOrNull { it.itemId == item?.id } | |||
| val handlerName = jpo?.handledBy?.let { uid -> | |||
| userService.find(uid).orElse(null)?.name | |||
| } | |||
| val lots = lineSuggestedLots.mapNotNull { spl -> | |||
| val ill = spl.suggestedLotLine | |||
| if (ill == null || ill.deleted == true) return@mapNotNull null | |||
| val displayIllId = displayIllForWorkbench(spl)?.id ?: return@mapNotNull null | |||
| // Prefer freshly loaded ILL (actual or suggested) so lotNo/location match display id. | |||
| val ill = inventoryLotLineById[displayIllId] | |||
| ?: displayIllForWorkbench(spl) | |||
| ?: return@mapNotNull null | |||
| if (ill.deleted == true) return@mapNotNull null | |||
| val il = ill.inventoryLot | |||
| if (il == null || il.deleted == true) return@mapNotNull null | |||
| val warehouse = ill.warehouse | |||
| val sol = stockOutLines.firstOrNull { | |||
| it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id | |||
| val linkedSolId = spl.stockOutLine?.takeIf { it.deleted != true }?.id | |||
| val sol = when { | |||
| linkedSolId != null -> lineSols.firstOrNull { it.id == linkedSolId } | |||
| else -> lineSols.firstOrNull { it.inventoryLotLineId == ill.id } | |||
| } | |||
| val jpoInner = joPickOrders.firstOrNull { it.itemId == item?.id } | |||
| val handlerNameInner = jpoInner?.handledBy?.let { uid -> | |||
| userService.find(uid).orElse(null)?.name | |||
| } | |||
| //println("handlerName: $handlerNameInner") | |||
| val availableQty = if (sol?.status == "rejected") { | |||
| null | |||
| } else { | |||
| @@ -377,7 +379,7 @@ open class JoWorkbenchMainService( | |||
| location = warehouse?.code, | |||
| availableQty = availableQty?.toDouble(), | |||
| requiredQty = spl.qty?.toDouble() ?: 0.0, | |||
| actualPickQty = sol?.qty ?: 0.0, | |||
| actualPickQty = sol?.qty?.toDouble() ?: 0.0, | |||
| processingStatus = processingStatus, | |||
| lotAvailability = lotAvailability, | |||
| pickOrderId = pickOrder.id, | |||
| @@ -387,7 +389,7 @@ open class JoWorkbenchMainService( | |||
| stockOutLineId = sol?.id, | |||
| stockInLineId = stockInLineId, | |||
| suggestedPickLotId = spl.id, | |||
| stockOutLineQty = sol?.qty ?: 0.0, | |||
| stockOutLineQty = sol?.qty?.toDouble() ?: 0.0, | |||
| stockOutLineStatus = sol?.status, | |||
| routerIndex = warehouse?.order?.toString(), | |||
| routerArea = warehouse?.code, | |||
| @@ -397,9 +399,9 @@ open class JoWorkbenchMainService( | |||
| matchBy = jpoInner?.matchBy, | |||
| matchQty = jpoInner?.matchQty?.toDouble() | |||
| ) | |||
| } | |||
| }.distinctBy { it.lotId } | |||
| val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol -> | |||
| val stockouts = lineSols.map { sol -> | |||
| val illId = sol.inventoryLotLineId | |||
| val ill = if (illId != null) inventoryLotLineById[illId] else null | |||
| val lot = ill?.inventoryLot | |||
| @@ -412,7 +414,10 @@ open class JoWorkbenchMainService( | |||
| illAvailableQtyWorkbench(ill) | |||
| } | |||
| val splForSol = lineSuggestedLots.firstOrNull { sp -> | |||
| sp.suggestedLotLine?.id != null && sp.suggestedLotLine?.id == illId | |||
| sp.stockOutLine?.id != null && sp.stockOutLine?.id == sol.id | |||
| } ?: lineSuggestedLots.firstOrNull { sp -> | |||
| val displayId = displayIllForWorkbench(sp)?.id | |||
| displayId != null && displayId == illId | |||
| } | |||
| StockOutLineDetailResponse( | |||
| @@ -456,6 +461,18 @@ open class JoWorkbenchMainService( | |||
| } | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 | |||
| * Workbench UI display lot: prefer actual pick (after scan/switch), fallback suggested (pending / old data). | |||
| */ | |||
| private fun displayIllForWorkbench(spl: com.ffii.fpsms.modules.stock.entity.SuggestedPickLot): InventoryLotLine? { | |||
| val actual = spl.actualPickLotLine | |||
| if (actual != null && actual.deleted != true) return actual | |||
| val suggested = spl.suggestedLotLine | |||
| if (suggested != null && suggested.deleted != true) return suggested | |||
| return null | |||
| } | |||
| private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalWorkbenchResponse { | |||
| println("❌ $message") | |||
| return JobOrderLotsHierarchicalWorkbenchResponse( | |||
| @@ -21,7 +21,8 @@ import java.util.concurrent.atomic.AtomicBoolean | |||
| * [JobOrder.planStart] fell on the previous calendar day. | |||
| * | |||
| * - Branch A: no pick submitted, product process pending → hide job order. | |||
| * - Branch B: pick submitted, product process still pending → reschedule to today 00:00:00 and renumber. | |||
| * - Branch B (disabled): pick submitted, process pending — previously rescheduled + renumbered; | |||
| * now SKIP so unfinished JOs stay visible via Production Process list lookback instead. | |||
| */ | |||
| @Service | |||
| open class JobOrderPlanStartAutoService( | |||
| @@ -47,6 +48,7 @@ open class JobOrderPlanStartAutoService( | |||
| val errors: Int = 0, | |||
| ) | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 42 | v1.0.0 | 2026-08-03 */ | |||
| open fun runAutoProcess(runAt: LocalDateTime = LocalDateTime.now()): JobOrderPlanStartAutoReport { | |||
| if (!inFlight.compareAndSet(false, true)) { | |||
| logger.warn("Job order plan-start auto process skipped: previous run still in flight") | |||
| @@ -147,6 +149,7 @@ open class JobOrderPlanStartAutoService( | |||
| SKIP, | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 42 | v1.0.0 | 2026-08-03 */ | |||
| private fun classify( | |||
| jo: JobOrder, | |||
| pickOrders: List<PickOrder>, | |||
| @@ -158,7 +161,8 @@ open class JobOrderPlanStartAutoService( | |||
| val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0 | |||
| return when { | |||
| maxSubmittedLines == 0 -> Branch.HIDE | |||
| maxSubmittedLines > 0 -> Branch.RESCHEDULE | |||
| // Renumber / planStart roll-forward disabled — Production Process list carry-over covers visibility. | |||
| maxSubmittedLines > 0 -> Branch.SKIP | |||
| else -> Branch.SKIP | |||
| } | |||
| } | |||
| @@ -49,7 +49,10 @@ data class AllJoPickOrderResponse( | |||
| val jobOrderType: String?, | |||
| val itemId: Long, | |||
| val itemName: String, | |||
| val itemCode: String? = null, | |||
| val bomDescription: String?, | |||
| /** BOM.type (e.g. drink / Powder_Mixture / other). */ | |||
| val bomType: String? = null, | |||
| val reqQty: BigDecimal, | |||
| val uomId: Long, | |||
| val uomName: String, | |||
| @@ -254,4 +254,54 @@ fun findAllReleasedJoWorkbenchPickOrders( | |||
| nativeQuery = true, | |||
| ) | |||
| fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 | |||
| * JO ids that have at least one stock_out_line counted as picked | |||
| * (checked / partially_completed / completed / picking, or rejected with qty > 0). | |||
| */ | |||
| @Query( | |||
| value = """ | |||
| SELECT DISTINCT po.joId | |||
| FROM pick_order po | |||
| INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0 | |||
| INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0 | |||
| WHERE po.deleted = 0 | |||
| AND po.joId IN (:joIds) | |||
| AND ( | |||
| LOWER(COALESCE(sol.status, '')) IN ('checked', 'partially_completed', 'completed', 'picking') | |||
| OR (LOWER(COALESCE(sol.status, '')) = 'rejected' AND COALESCE(sol.qty, 0) > 0) | |||
| ) | |||
| """, | |||
| nativeQuery = true, | |||
| ) | |||
| fun findPickedJobOrderIds(@Param("joIds") joIds: Collection<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 | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03 | |||
| * Shared hierarchical FG payload (fgInfo + merged pickOrders with lines/lots) for both | |||
| * legacy [do_pick_order_line] and workbench [delivery_order_pick_order] flows. | |||
| */ | |||
| @@ -60,7 +61,8 @@ fun assembleHierarchicalFgPayload( | |||
| AND pol.deleted = false | |||
| ), | |||
| ll AS ( | |||
| SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId | |||
| SELECT spl.pickOrderLineId, | |||
| COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId | |||
| FROM fpsmsdb.suggested_pick_lot spl | |||
| JOIN target_pol tp ON tp.id = spl.pickOrderLineId | |||
| WHERE spl.deleted = false | |||
| @@ -73,11 +75,13 @@ ll AS ( | |||
| WHERE sol.deleted = false | |||
| ), | |||
| sm AS ( | |||
| SELECT s.pickOrderLineId, s.suggestedLotLineId, MAX(s.id) AS maxId | |||
| SELECT s.pickOrderLineId, | |||
| COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) AS displayLotLineId, | |||
| MAX(s.id) AS maxId | |||
| FROM fpsmsdb.suggested_pick_lot s | |||
| JOIN target_pol tp ON tp.id = s.pickOrderLineId | |||
| WHERE s.deleted = false | |||
| GROUP BY s.pickOrderLineId, s.suggestedLotLineId | |||
| GROUP BY s.pickOrderLineId, COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) | |||
| ) | |||
| SELECT | |||
| po.id as pickOrderId, | |||
| @@ -150,7 +154,7 @@ LEFT JOIN ll ON ll.pickOrderLineId = pol.id | |||
| LEFT JOIN sm | |||
| ON sm.pickOrderLineId = pol.id | |||
| AND sm.suggestedLotLineId <=> ll.lotLineId | |||
| AND sm.displayLotLineId <=> ll.lotLineId | |||
| LEFT JOIN fpsmsdb.suggested_pick_lot spl | |||
| ON spl.id = sm.maxId | |||
| AND spl.deleted = false | |||
| @@ -45,6 +45,7 @@ import com.ffii.fpsms.modules.common.CodeGenerator | |||
| import com.ffii.fpsms.modules.stock.web.model.BatchStockOutResult | |||
| import com.ffii.fpsms.modules.stock.entity.StockLedger | |||
| import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | |||
| import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot | |||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository | |||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | |||
| import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | |||
| @@ -2369,6 +2370,7 @@ private fun updateInventoryAfterLotLineChange(lotLine: InventoryLotLine) { | |||
| e.printStackTrace() | |||
| } | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ | |||
| private fun createStockLedgerForStockOut( | |||
| stockOutLine: StockOutLine, | |||
| ledgerType: String? = null, | |||
| @@ -2394,6 +2396,9 @@ private fun createStockLedgerForStockOut( | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||
| val ill = stockOutLine.inventoryLotLine | |||
| val outQtyBd = BigDecimal.valueOf(outQty) | |||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||
| val stockLedger = StockLedger().apply { | |||
| this.stockOutLine = stockOutLine | |||
| this.inventory = inventory | |||
| @@ -2403,10 +2408,19 @@ private fun createStockLedgerForStockOut( | |||
| this.type = ledgerTypeToUse | |||
| this.itemId = item.id | |||
| this.itemCode = item.code | |||
| this.uomId = inventory.uom?.id | |||
| ?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id | |||
| this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( | |||
| ill, | |||
| inventory.uom?.id, | |||
| itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, | |||
| ) | |||
| this.date = LocalDate.now() | |||
| } | |||
| StockLedgerLotSnapshot.apply( | |||
| ledger = stockLedger, | |||
| inventoryLotLine = ill, | |||
| lotAvailableAfterMove = lotAfter, | |||
| signedDelta = outQtyBd.negate(), | |||
| ) | |||
| stockLedgerRepository.saveAndFlush(stockLedger) | |||
| @@ -2417,8 +2431,6 @@ private fun createStockLedgerForStockOut( | |||
| println("===========================") | |||
| } | |||
| @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) | |||
| private fun handleBadItemPackageProblem(request: PickExecutionIssueRequest, badItemQty: BigDecimal) { | |||
| println("=== HANDLING BAD ITEM PACKAGE PROBLEM (DON'T REJECT LOT) ===") | |||
| @@ -2863,6 +2875,7 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse | |||
| } | |||
| // New: Create stock ledger with explicit balance (to avoid double subtraction) | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ | |||
| private fun createStockLedgerForStockOutWithBalance( | |||
| stockOutLine: StockOutLine, | |||
| balance: Double, | |||
| @@ -2880,6 +2893,9 @@ private fun createStockLedgerForStockOutWithBalance( | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||
| val ill = stockOutLine.inventoryLotLine | |||
| val outQtyBd = BigDecimal.valueOf(outQty) | |||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||
| val stockLedger = StockLedger().apply { | |||
| this.stockOutLine = stockOutLine | |||
| this.inventory = inventory | |||
| @@ -2889,10 +2905,19 @@ private fun createStockLedgerForStockOutWithBalance( | |||
| this.type = ledgerTypeToUse | |||
| this.itemId = item.id | |||
| this.itemCode = item.code | |||
| this.uomId = inventory.uom?.id | |||
| ?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id | |||
| this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( | |||
| ill, | |||
| inventory.uom?.id, | |||
| itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, | |||
| ) | |||
| this.date = LocalDate.now() | |||
| } | |||
| StockLedgerLotSnapshot.apply( | |||
| ledger = stockLedger, | |||
| inventoryLotLine = ill, | |||
| lotAvailableAfterMove = lotAfter, | |||
| signedDelta = outQtyBd.negate(), | |||
| ) | |||
| stockLedgerRepository.saveAndFlush(stockLedger) | |||
| @@ -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 ( | |||
| SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId | |||
| SELECT spl.pickOrderLineId, | |||
| COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId | |||
| FROM fpsmsdb.suggested_pick_lot spl | |||
| WHERE spl.deleted = false | |||
| UNION | |||
| SELECT sol.pickOrderLineId, sol.inventoryLotLineId | |||
| FROM fpsmsdb.stock_out_line sol | |||
| @@ -3813,7 +3815,9 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A | |||
| ) ll ON ll.pickOrderLineId = pol.id | |||
| 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 用(保留原逻辑) | |||
| 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?> { | |||
| val user = userService.find(userId).orElse(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 | |||
| LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId | |||
| 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 | |||
| WHERE spl.deleted = false | |||
| UNION | |||
| SELECT sol.pickOrderLineId, sol.inventoryLotLineId | |||
| FROM fpsmsdb.stock_out_line sol | |||
| WHERE sol.deleted = false | |||
| ) ll ON ll.pickOrderLineId = pol.id | |||
| 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 | |||
| ON sol.pickOrderLineId = pol.id | |||
| 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. | |||
| * Uses no-hold available qty semantics (inQty - outQty). | |||
| */ | |||
| @@ -275,6 +276,7 @@ open class PickOrderWorkbenchService( | |||
| /** | |||
| * Workbench line detail V2 (consumable): | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03 | |||
| * - Returns only lot rows already linked by suggestion/stock_out_line for this pick order line. | |||
| * - Uses no-hold availability semantics: available = inQty - outQty. | |||
| * - Includes no-lot stock_out_line rows. | |||
| @@ -306,14 +308,14 @@ open class PickOrderWorkbenchService( | |||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | |||
| LEFT JOIN ( | |||
| SELECT | |||
| s.suggestedLotLineId AS lotLineId, | |||
| COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) AS lotLineId, | |||
| MAX(s.id) AS suggestedPickLotId, | |||
| SUM(COALESCE(s.qty,0)) AS requiredQty | |||
| FROM fpsmsdb.suggested_pick_lot s | |||
| WHERE s.pickOrderLineId = :pickOrderLineId | |||
| AND s.deleted = false | |||
| AND s.suggestedLotLineId IS NOT NULL | |||
| GROUP BY s.suggestedLotLineId | |||
| AND COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) IS NOT NULL | |||
| GROUP BY COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) | |||
| ) spl ON spl.lotLineId = ill.id | |||
| LEFT JOIN ( | |||
| SELECT | |||
| @@ -328,11 +330,11 @@ open class PickOrderWorkbenchService( | |||
| GROUP BY so.inventoryLotLineId | |||
| ) sol ON sol.lotLineId = ill.id | |||
| WHERE ill.id IN ( | |||
| SELECT DISTINCT s.suggestedLotLineId | |||
| SELECT DISTINCT COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) | |||
| FROM fpsmsdb.suggested_pick_lot s | |||
| WHERE s.pickOrderLineId = :pickOrderLineId | |||
| AND s.deleted = false | |||
| AND s.suggestedLotLineId IS NOT NULL | |||
| AND COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) IS NOT NULL | |||
| UNION | |||
| SELECT DISTINCT so.inventoryLotLineId | |||
| FROM fpsmsdb.stock_out_line so | |||
| @@ -55,4 +55,72 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe | |||
| @Param("useBomIds") useBomIds: Boolean, | |||
| @Param("bomIds") bomIds: Collection<Long>, | |||
| ): 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, | |||
| itemCode = productProcesses.item?.code, | |||
| bomDescription = productProcesses.bom?.description, | |||
| bomType = productProcesses.bom?.type, | |||
| pickOrderId = pickOrder?.id, | |||
| pickOrderStatus = pickOrder?.status?.value, | |||
| jobOrderId = productProcesses.jobOrder?.id, | |||
| @@ -1497,6 +1498,7 @@ open class ProductProcessService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 | |||
| * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 | |||
| * | |||
| * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 | |||
| @@ -1511,7 +1513,9 @@ open class ProductProcessService( | |||
| includePutaway: Boolean?, | |||
| putawayStatus: String?, | |||
| page: Int, | |||
| size: Int | |||
| size: Int, | |||
| lookbackDays: Int? = null, | |||
| bucket: String? = null, | |||
| ): JobOrderProductProcessPageResponse { | |||
| val safeSize = if (size <= 0) 50 else size | |||
| val safePage = if (page < 0) 0 else page | |||
| @@ -1522,16 +1526,82 @@ open class ProductProcessService( | |||
| val normalizedBomIds = bomIds?.distinct().orEmpty() | |||
| val useBomIds = normalizedBomIds.isNotEmpty() | |||
| val bomIdsForQuery = if (useBomIds) normalizedBomIds else listOf(-1L) | |||
| val normalizedBucket = bucket?.trim()?.lowercase()?.takeIf { it.isNotBlank() && it != "all" } | |||
| val useLookback = lookbackDays != null && lookbackDays >= 0 && date != null | |||
| /** Production list: unfinished process tabs + picked-not-started (capped at search date). */ | |||
| val useProductionLookback = useLookback && qcReady != true | |||
| /** Waiting QC putaway: date lookback only (completed process OK), no pick buckets. */ | |||
| val usePutawayLookback = useLookback && qcReady == true | |||
| // 1) DB 端先完成候選 jobOrder 篩選 + 分組排序,避免全表拉回 JVM | |||
| val candidateAggregates = productProcessRepository.findCandidateJobOrderAggregates( | |||
| date = date, | |||
| itemCode = trimmedItemCode, | |||
| jobOrderCode = trimmedJobOrderCode, | |||
| bomType = normalizedType, | |||
| useBomIds = useBomIds, | |||
| bomIds = bomIdsForQuery, | |||
| ) | |||
| val candidateAggregates = if (useProductionLookback) { | |||
| val dateFrom = date!!.minusDays(lookbackDays!!.toLong()) | |||
| val rangeAggs = productProcessRepository.findCandidateJobOrderAggregatesByDateRange( | |||
| dateFrom = dateFrom, | |||
| dateTo = date, | |||
| itemCode = trimmedItemCode, | |||
| jobOrderCode = trimmedJobOrderCode, | |||
| bomType = normalizedType, | |||
| useBomIds = useBomIds, | |||
| bomIds = bomIdsForQuery, | |||
| excludeCompletedProcess = true, | |||
| ) | |||
| // Picked-not-started: still merge outside lookback window, but never after search date | |||
| // (avoids future plan dates appearing when searching a past day). | |||
| val unlimitedPickedNotStartedIds = pickOrderRepository.findPickedNotStartedJobOrderIds() | |||
| val extraAggs = if (unlimitedPickedNotStartedIds.isEmpty()) { | |||
| emptyList() | |||
| } else { | |||
| productProcessRepository.findCandidateJobOrderAggregatesByJobOrderIds( | |||
| jobOrderIds = unlimitedPickedNotStartedIds, | |||
| itemCode = trimmedItemCode, | |||
| jobOrderCode = trimmedJobOrderCode, | |||
| bomType = normalizedType, | |||
| useBomIds = useBomIds, | |||
| bomIds = bomIdsForQuery, | |||
| ).filter { agg -> | |||
| val maxDate = agg.maxDate | |||
| maxDate == null || !maxDate.isAfter(date) | |||
| } | |||
| } | |||
| val byId = LinkedHashMap<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 } | |||
| if (candidateJobOrderIds.isEmpty()) { | |||
| @@ -1539,7 +1609,10 @@ open class ProductProcessService( | |||
| content = emptyList(), | |||
| totalJobOrders = 0L, | |||
| page = safePage, | |||
| size = safeSize | |||
| size = safeSize, | |||
| bucketCounts = if (useProductionLookback) JobOrderProductProcessBucketCounts() else null, | |||
| searchDate = date?.toString(), | |||
| carriedOverCount = if (useLookback) 0L else null, | |||
| ) | |||
| } | |||
| @@ -1625,19 +1698,104 @@ open class ProductProcessService( | |||
| } | |||
| val filteredQcKeys = qcKeys.filter { key -> qcReady == null || key.ready == qcReady } | |||
| val totalJobOrders = filteredQcKeys.size.toLong() | |||
| val sortedQcKeys = filteredQcKeys.sortedWith( | |||
| compareByDescending<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 | |||
| if (from >= sortedQcKeys.size) { | |||
| return JobOrderProductProcessPageResponse(emptyList(), totalJobOrders, safePage, safeSize) | |||
| if (from >= sortedPairs.size) { | |||
| return JobOrderProductProcessPageResponse( | |||
| content = emptyList(), | |||
| totalJobOrders = totalJobOrders, | |||
| page = safePage, | |||
| size = safeSize, | |||
| bucketCounts = bucketCounts, | |||
| searchDate = date?.toString(), | |||
| carriedOverCount = carriedOverCount, | |||
| ) | |||
| } | |||
| val to = minOf(from + safeSize, sortedQcKeys.size) | |||
| val pagedJobOrderIds = sortedQcKeys.subList(from, to).map { it.jobOrderId } | |||
| val to = minOf(from + safeSize, sortedPairs.size) | |||
| val pagedPairs = sortedPairs.subList(from, to) | |||
| val pagedJobOrderIds = pagedPairs.map { it.first.jobOrderId } | |||
| val bucketByJobOrderId = pagedPairs.associate { it.first.jobOrderId to it.second } | |||
| val pagedJobOrderIdSet = pagedJobOrderIds.toSet() | |||
| // 4) 只對當前頁資料做批量關聯查詢,並且排序/資料縮小盡量下推 DB | |||
| @@ -1756,6 +1914,7 @@ open class ProductProcessService( | |||
| itemName = productProcess.item?.name, | |||
| itemCode = productProcess.item?.code, | |||
| bomDescription = productProcess.bom?.description, | |||
| bomType = productProcess.bom?.type, | |||
| pickOrderId = pickOrder?.id, | |||
| pickOrderStatus = pickOrder?.status?.value, | |||
| jobOrderId = productProcess.jobOrder?.id, | |||
| @@ -1775,7 +1934,9 @@ open class ProductProcessService( | |||
| endTime = line.endTime, | |||
| status = line.status ?: "" | |||
| ) | |||
| } | |||
| }, | |||
| isPicked = if (useProductionLookback) pickedJoIds.contains(jobOrderId) else null, | |||
| pickProcessBucket = if (useProductionLookback) bucketByJobOrderId[jobOrderId] else null, | |||
| ) | |||
| } | |||
| @@ -1783,7 +1944,10 @@ open class ProductProcessService( | |||
| content = content, | |||
| totalJobOrders = totalJobOrders, | |||
| page = safePage, | |||
| size = safeSize | |||
| size = safeSize, | |||
| bucketCounts = bucketCounts, | |||
| searchDate = date?.toString(), | |||
| carriedOverCount = carriedOverCount, | |||
| ) | |||
| } | |||
| @@ -200,6 +200,7 @@ class ProductProcessController( | |||
| return productProcessService.getAllJoborderProductProcessInfo(bomType) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */ | |||
| @GetMapping("/Demo/Process/search") | |||
| fun demoprocesssearch( | |||
| @RequestParam(required = false) date: String?, | |||
| @@ -211,6 +212,14 @@ class ProductProcessController( | |||
| /** all | completed | notCompleted */ | |||
| @RequestParam(required = false) putawayStatus: String?, | |||
| @RequestParam(name = "type", required = false) bomType: String?, | |||
| /** Production list: unfinished from (date - lookbackDays) .. date; picked_not_started merged but capped at date. */ | |||
| @RequestParam(required = false) lookbackDays: Int?, | |||
| /** | |||
| * all | pending | processing | |||
| * (pending = not started; processing = started; also accepts fine-grained pick buckets) | |||
| * Only applied when lookbackDays is set. | |||
| */ | |||
| @RequestParam(required = false) bucket: String?, | |||
| @RequestParam(defaultValue = "0") page: Int, | |||
| @RequestParam(defaultValue = "50") size: Int | |||
| ): JobOrderProductProcessPageResponse { | |||
| @@ -233,7 +242,9 @@ class ProductProcessController( | |||
| putawayStatus = putawayStatus, | |||
| bomType = bomType, | |||
| page = page, | |||
| size = size | |||
| size = size, | |||
| lookbackDays = lookbackDays, | |||
| bucket = bucket, | |||
| ) | |||
| } | |||
| @@ -170,6 +170,8 @@ data class AllJoborderProductProcessInfoResponse( | |||
| val itemName: String?, | |||
| val itemCode: String?, | |||
| val bomDescription: String?, | |||
| /** BOM.type (e.g. drink / Powder_Mixture / other). */ | |||
| val bomType: String? = null, | |||
| val matchStatus: String?, | |||
| val RequiredQty: Int?, | |||
| val Uom: String?, | |||
| @@ -187,14 +189,32 @@ data class AllJoborderProductProcessInfoResponse( | |||
| val stockInLineStatus: String?, | |||
| val TimeNeedToComplete: Int?, | |||
| val isDrink: Boolean?, | |||
| val lines: List<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( | |||
| val content: List<AllJoborderProductProcessInfoResponse>, | |||
| val totalJobOrders: Long, | |||
| val page: Int, | |||
| val size: Int | |||
| val size: Int, | |||
| val bucketCounts: JobOrderProductProcessBucketCounts? = null, | |||
| /** Search date echoed for carry-over UI (YYYY-MM-dd). */ | |||
| val searchDate: String? = null, | |||
| val carriedOverCount: Long? = null, | |||
| ) | |||
| /** | |||
| @@ -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.report.service.M18BomShopSyncReportService | |||
| import com.ffii.fpsms.modules.report.service.ReportService | |||
| import com.ffii.fpsms.modules.report.service.ShopOrderReplenishmentReportService | |||
| @RestController | |||
| @RequestMapping("/report") | |||
| class ReportController( | |||
| private val reportService: ReportService, | |||
| private val m18BomShopSyncReportService: M18BomShopSyncReportService, | |||
| private val shopOrderReplenishmentReportService: ShopOrderReplenishmentReportService, | |||
| ) { | |||
| private data class ExcelStyles( | |||
| val title: XSSFCellStyle, | |||
| @@ -1001,6 +1003,32 @@ class ReportController( | |||
| syncStatus = syncStatus, | |||
| ) | |||
| /** | |||
| * Shop Orders Replenishment Records (店鋪訂單補貨記錄). | |||
| * JSON for Excel export: completed replenishments with linked ticket only. | |||
| * | |||
| * Example: `/report/shop-order-replenishment?reorderDateStart=2026-08-01&shopCode=S001` | |||
| */ | |||
| @GetMapping("/shop-order-replenishment") | |||
| fun getShopOrderReplenishmentReport( | |||
| @RequestParam(required = false) reorderDateStart: String?, | |||
| @RequestParam(required = false) reorderDateEnd: String?, | |||
| @RequestParam(required = false) shopOrderDateStart: String?, | |||
| @RequestParam(required = false) shopOrderDateEnd: String?, | |||
| @RequestParam(required = false) deliveredDateStart: String?, | |||
| @RequestParam(required = false) deliveredDateEnd: String?, | |||
| @RequestParam(required = false) shopCode: String?, | |||
| ): Map<String, Any> = | |||
| shopOrderReplenishmentReportService.searchShopOrderReplenishmentReport( | |||
| reorderDateStart = reorderDateStart, | |||
| reorderDateEnd = reorderDateEnd, | |||
| shopOrderDateStart = shopOrderDateStart, | |||
| shopOrderDateEnd = shopOrderDateEnd, | |||
| deliveredDateStart = deliveredDateStart, | |||
| deliveredDateEnd = deliveredDateEnd, | |||
| shopCode = shopCode, | |||
| ) | |||
| companion object { | |||
| /** GRN report fields only users with ADMIN authority may see */ | |||
| private val GRN_FINANCIAL_KEYS = setOf("unitPrice", "lineAmount", "currencyCode") | |||
| @@ -4,9 +4,11 @@ import com.ffii.core.entity.BaseEntity | |||
| import jakarta.persistence.Column | |||
| import jakarta.persistence.Entity | |||
| import jakarta.persistence.JoinColumn | |||
| import jakarta.persistence.ManyToOne | |||
| import jakarta.persistence.OneToOne | |||
| import jakarta.persistence.Table | |||
| import jakarta.validation.constraints.NotNull | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDate | |||
| @Entity | |||
| @Table(name = "stock_ledger") | |||
| @@ -19,6 +21,11 @@ open class StockLedger: BaseEntity<Long>() { | |||
| @JoinColumn(name = "stockOutLineId") | |||
| open var stockOutLine: StockOutLine? = null | |||
| /** Lot line for this movement; prefer this over resolving via stock in/out line. */ | |||
| @ManyToOne | |||
| @JoinColumn(name = "inventoryLotLineId") | |||
| open var inventoryLotLine: InventoryLotLine? = null | |||
| @NotNull | |||
| @OneToOne | |||
| @JoinColumn(name = "inventoryId") | |||
| @@ -33,7 +40,15 @@ open class StockLedger: BaseEntity<Long>() { | |||
| @Column(name = "balance") | |||
| open var balance: Double? = null | |||
| /** 物料庫存單位對應之 uom_conversion.id(與 item_uom / inventory 之 stock UOM 一致) */ | |||
| /** Lot available qty (in−out) before this movement; null for pre-V2 rows. */ | |||
| @Column(name = "lotQtyBefore", precision = 14, scale = 2) | |||
| open var lotQtyBefore: BigDecimal? = null | |||
| /** Lot available qty (in−out) after this movement; null for pre-V2 rows. */ | |||
| @Column(name = "lotQtyAfter", precision = 14, scale = 2) | |||
| open var lotQtyAfter: BigDecimal? = null | |||
| /** Lot line stock UOM → uom.id(優先);無 lot 時才 fallback item/inventory stock UOM */ | |||
| @Column(name = "uomId") | |||
| open var uomId: Long? = null | |||
| @@ -41,6 +41,13 @@ open class StockOutLine: BaseEntity<Long>() { | |||
| @Column(name = "handled_by") | |||
| open var handledBy: Long? = null | |||
| /** | |||
| * DO user-audit: Just Complete that should be reported because pickable stock still existed | |||
| * for the item at complete time. Null/false = not an issue JC (e.g. lot exhausted remainder). | |||
| */ | |||
| @Column(name = "isIssueJustComplete") | |||
| open var isIssueJustComplete: Boolean? = null | |||
| @JsonBackReference | |||
| @ManyToOne | |||
| @JoinColumn(name = "pickOrderLineId") | |||
| @@ -29,6 +29,11 @@ open class SuggestedPickLot: BaseEntity<Long>() { | |||
| @JoinColumn(name = "suggestedLotLineId") | |||
| open var suggestedLotLine: InventoryLotLine? = null | |||
| /** Actual picked lot line; switch/scan updates this without changing [suggestedLotLine]. */ | |||
| @ManyToOne | |||
| @JoinColumn(name = "actualPickLotLineId") | |||
| open var actualPickLotLine: InventoryLotLine? = null | |||
| @JsonBackReference | |||
| @ManyToOne | |||
| @JoinColumn(name = "pickOrderLineId") | |||
| @@ -1378,6 +1378,7 @@ open class StockInLineService( | |||
| } | |||
| @Transactional | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ | |||
| private fun createStockLedgerForStockIn(stockInLine: StockInLine, inQty: Double) { | |||
| val _t0 = System.nanoTime() | |||
| fun _msSince(t: Long): Double = (System.nanoTime() - t) / 1_000_000.0 | |||
| @@ -1403,7 +1404,11 @@ open class StockInLineService( | |||
| val newBalance = previousBalance + inQty | |||
| val _tUom = System.nanoTime() | |||
| val stockUomId = itemUomService.findStockUnitByItemId(item.id!!)?.uom?.id ?: inventory.uom?.id | |||
| val stockUomId = StockLedgerLotSnapshot.resolveLedgerUomId( | |||
| stockInLine.inventoryLotLine, | |||
| itemUomService.findStockUnitByItemId(item.id!!)?.uom?.id, | |||
| inventory.uom?.id, | |||
| ) | |||
| _logStep("itemUomService.findStockUnitByItemId", _tUom) | |||
| val stockLedger = StockLedger().apply { | |||
| @@ -1418,6 +1423,14 @@ open class StockInLineService( | |||
| this.uomId = stockUomId | |||
| this.date = LocalDate.now() | |||
| } | |||
| val ill = stockInLine.inventoryLotLine | |||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||
| StockLedgerLotSnapshot.apply( | |||
| ledger = stockLedger, | |||
| inventoryLotLine = ill, | |||
| lotAvailableAfterMove = lotAfter, | |||
| signedDelta = BigDecimal.valueOf(inQty), | |||
| ) | |||
| val _tSave = System.nanoTime() | |||
| stockLedgerRepository.saveAndFlush(stockLedger) | |||
| @@ -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 | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ | |||
| private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) { | |||
| val item = stockOutLine.item ?: return | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return | |||
| @@ -1519,6 +1520,9 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val newBalance = previousBalance - outQty | |||
| val ill = stockOutLine.inventoryLotLine | |||
| val outQtyBd = BigDecimal.valueOf(outQty) | |||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||
| val stockLedger = StockLedger().apply { | |||
| this.stockOutLine = stockOutLine | |||
| this.inventory = inventory | |||
| @@ -1528,10 +1532,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| this.type = stockOutLine.type | |||
| this.itemId = item.id | |||
| this.itemCode = item.code | |||
| this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id | |||
| ?: inventory.uom?.id | |||
| this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( | |||
| ill, | |||
| itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, | |||
| inventory.uom?.id, | |||
| ) | |||
| this.date = LocalDate.now() | |||
| } | |||
| StockLedgerLotSnapshot.apply( | |||
| ledger = stockLedger, | |||
| inventoryLotLine = ill, | |||
| lotAvailableAfterMove = lotAfter, | |||
| signedDelta = outQtyBd.negate(), | |||
| ) | |||
| stockLedgerRepository.saveAndFlush(stockLedger) | |||
| } | |||
| @@ -1744,6 +1757,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */ | |||
| private fun createStockLedgerForPickDelta( | |||
| stockOutLine: StockOutLine, | |||
| deltaQty: BigDecimal, | |||
| @@ -1791,6 +1805,8 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| } | |||
| } | |||
| val ill = stockOutLine.inventoryLotLine | |||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||
| val ledger = StockLedger().apply { | |||
| this.stockOutLine = stockOutLine | |||
| this.inventory = inventory | |||
| @@ -1800,10 +1816,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| this.type = "NOR" | |||
| this.itemId = item.id | |||
| this.itemCode = item.code | |||
| this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id | |||
| ?: inventory.uom?.id | |||
| this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( | |||
| ill, | |||
| itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, | |||
| inventory.uom?.id, | |||
| ) | |||
| this.date = LocalDate.now() | |||
| } | |||
| StockLedgerLotSnapshot.apply( | |||
| ledger = ledger, | |||
| inventoryLotLine = ill, | |||
| lotAvailableAfterMove = lotAfter, | |||
| signedDelta = deltaQty.negate(), | |||
| ) | |||
| if (flushAfterSave) { | |||
| stockLedgerRepository.saveAndFlush(ledger) | |||
| @@ -2197,6 +2222,9 @@ fun applyStockOutLineDelta( | |||
| val outQty = deltaQty.toDouble() | |||
| val newBalance = previousBalance - outQty | |||
| val ill = savedSol.inventoryLotLine | |||
| val outQtyBd = BigDecimal.valueOf(outQty) | |||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||
| val ledger = StockLedger().apply { | |||
| this.stockOutLine = savedSol | |||
| this.inventory = inventory | |||
| @@ -2207,14 +2235,23 @@ fun applyStockOutLineDelta( | |||
| this.itemId = item.id | |||
| this.itemCode = item.code | |||
| this.uomId = | |||
| itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id | |||
| ?: inventory.uom?.id | |||
| StockLedgerLotSnapshot.resolveLedgerUomId( | |||
| ill, | |||
| itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id, | |||
| inventory.uom?.id, | |||
| ) | |||
| this.date = eventTime.toLocalDate() | |||
| if (!operator.isNullOrBlank()) { | |||
| this.createdBy = operator | |||
| this.modifiedBy = operator | |||
| } | |||
| } | |||
| StockLedgerLotSnapshot.apply( | |||
| ledger = ledger, | |||
| inventoryLotLine = ill, | |||
| lotAvailableAfterMove = lotAfter, | |||
| signedDelta = outQtyBd.negate(), | |||
| ) | |||
| if (deferPersistenceFlush) stockLedgerRepository.save(ledger) | |||
| else stockLedgerRepository.saveAndFlush(ledger) | |||
| } | |||
| @@ -2330,7 +2367,9 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult | |||
| } | |||
| val newBalance = prevBalance - delta.toDouble() | |||
| ledgersToInsert += StockLedger().apply { | |||
| val ill = sol.inventoryLotLine | |||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||
| val ledger = StockLedger().apply { | |||
| this.stockOutLine = sol | |||
| this.inventory = inv | |||
| this.inQty = null | |||
| @@ -2339,10 +2378,20 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult | |||
| this.type = request.type | |||
| this.itemId = itemId | |||
| this.itemCode = item.code | |||
| this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id | |||
| ?: inv.uom?.id | |||
| this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( | |||
| ill, | |||
| itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id, | |||
| inv.uom?.id, | |||
| ) | |||
| this.date = today | |||
| } | |||
| StockLedgerLotSnapshot.apply( | |||
| ledger = ledger, | |||
| inventoryLotLine = ill, | |||
| lotAvailableAfterMove = lotAfter, | |||
| signedDelta = delta.negate(), | |||
| ) | |||
| ledgersToInsert += ledger | |||
| runningLedgerBalanceByItemId[itemId] = newBalance | |||
| } | |||
| stockLedgerRepository.saveAll(ledgersToInsert) | |||
| @@ -198,7 +198,7 @@ open class StockOutLineWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 33 | v1.0.1 | 2026-07-22 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 33 | v1.0.2 | 2026-08-03 | |||
| */ | |||
| @Transactional(rollbackFor = [Exception::class]) | |||
| open fun scanPick(request: WorkbenchScanPickRequest): MessageResponse { | |||
| @@ -517,6 +517,9 @@ if (updated == 0) { | |||
| val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val previousBalance = onHandAfter + deltaQty.toDouble() | |||
| val newBalance = previousBalance - deltaQty.toDouble() | |||
| val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } | |||
| ?: sol.inventoryLotLine | |||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||
| val ledger = StockLedger().apply { | |||
| this.stockOutLine = sol | |||
| this.inventory = inventory | |||
| @@ -526,10 +529,19 @@ if (updated == 0) { | |||
| this.type = "NOR" | |||
| this.itemId = solItem.id | |||
| this.itemCode = solItem.code | |||
| this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id | |||
| ?: inventory.uom?.id | |||
| this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( | |||
| ill, | |||
| itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id, | |||
| inventory.uom?.id, | |||
| ) | |||
| this.date = LocalDate.now() | |||
| } | |||
| StockLedgerLotSnapshot.apply( | |||
| ledger = ledger, | |||
| inventoryLotLine = ill, | |||
| lotAvailableAfterMove = lotAfter, | |||
| signedDelta = deltaQty.negate(), | |||
| ) | |||
| stockLedgerRepository.save(ledger) | |||
| } | |||
| @@ -2488,6 +2488,10 @@ private fun applyVarianceAdjustment( | |||
| } | |||
| val newBalance = previousBalance - qtyToRemove.toDouble() | |||
| // FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 | |||
| // Snapshot from live lot line (in−out) at approve time — not stock_take_record.bookQty. | |||
| val lotAfterAvail = StockLedgerLotSnapshot.availableQty(latestLine).subtract(qtyToRemove) | |||
| val stockLedger = StockLedger().apply { | |||
| this.inventory = inventory | |||
| this.itemId = inventoryLot.item?.id | |||
| @@ -2497,9 +2501,18 @@ private fun applyVarianceAdjustment( | |||
| this.stockOutLine = stockOutLine | |||
| this.balance = newBalance | |||
| this.type = "TKE" | |||
| this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id | |||
| this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( | |||
| latestLine, | |||
| inventory.uom?.id, | |||
| ) | |||
| this.date = LocalDate.now() | |||
| } | |||
| StockLedgerLotSnapshot.apply( | |||
| ledger = stockLedger, | |||
| inventoryLotLine = latestLine, | |||
| lotAvailableAfterMove = lotAfterAvail, | |||
| signedDelta = qtyToRemove.negate(), | |||
| ) | |||
| if (context != null) { | |||
| context.stockLedgers.add(stockLedger) | |||
| @@ -2613,6 +2626,9 @@ private fun applyVarianceAdjustment( | |||
| } | |||
| val newBalance = previousBalance + plusQty.toDouble() | |||
| // latestLine.inQty already includes plusQty above — snapshot live lot line after move. | |||
| val lotAfterAvail = StockLedgerLotSnapshot.availableQty(latestLine) | |||
| val stockLedger = StockLedger().apply { | |||
| this.inventory = inventory | |||
| this.itemId = inventoryLot.item?.id | |||
| @@ -2622,9 +2638,18 @@ private fun applyVarianceAdjustment( | |||
| this.stockInLine = stockInLine | |||
| this.balance = newBalance | |||
| this.type = "TKE" | |||
| this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id | |||
| this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId( | |||
| latestLine, | |||
| inventory.uom?.id, | |||
| ) | |||
| this.date = LocalDate.now() | |||
| } | |||
| StockLedgerLotSnapshot.apply( | |||
| ledger = stockLedger, | |||
| inventoryLotLine = latestLine, | |||
| lotAvailableAfterMove = lotAfterAvail, | |||
| signedDelta = plusQty, | |||
| ) | |||
| if (context != null) { | |||
| context.stockLedgers.add(stockLedger) | |||
| @@ -70,6 +70,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * After workbench scan-pick updates [StockOutLine] qty/status, bind SPL and set **qty** to match SOL (已揀 + 進行中). | |||
| */ | |||
| @Transactional(rollbackFor = [Exception::class]) | |||
| @@ -97,7 +98,14 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| if (spl != null) { | |||
| spl.stockOutLine = sol | |||
| spl.suggestedLotLine = ill | |||
| // Keep suggestedLotLine as the system suggestion; only record what was actually picked. | |||
| if (spl.suggestedLotLine == null && ill != null) { | |||
| spl.suggestedLotLine = ill | |||
| } | |||
| spl.actualPickLotLine = ill | |||
| if (spl.actualPickLotLine == null && spl.suggestedLotLine != null) { | |||
| spl.actualPickLotLine = spl.suggestedLotLine | |||
| } | |||
| spl.pickOrderLine = pol | |||
| spl.qty = qtyBd | |||
| spl.type = SuggestedPickLotType.PICK_ORDER | |||
| @@ -109,6 +117,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| type = SuggestedPickLotType.PICK_ORDER | |||
| stockOutLine = sol | |||
| suggestedLotLine = ill | |||
| actualPickLotLine = ill | |||
| pickOrderLine = pol | |||
| qty = qtyBd | |||
| }, | |||
| @@ -150,7 +159,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * Lot stock UOM must match pick_order_line.uom (UomConversion id). | |||
| */ | |||
| private fun matchesPolUom(lot: InventoryLotLine, line: PickOrderLine): Boolean { | |||
| @@ -159,7 +168,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * Build suggestions with no-hold rule: | |||
| * available = inQty - outQty (ignore holdQty). | |||
| * When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id). | |||
| @@ -292,7 +301,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * Same no-hold allocation as [rebuildNoHoldSuggestionsForPickOrder], but only for one [pickOrderLineId]. | |||
| * Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order. | |||
| */ | |||
| @@ -384,7 +393,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * Workbench explicit-qty remainder support (single-next-lot): | |||
| * Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot. | |||
| * When [storeId] is provided, the next lot must be within the same store (warehouse.store_id). | |||
| @@ -445,7 +454,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * Workbench explicit-qty split support: | |||
| * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). | |||
| * - If [targetQty] <= 0: soft-delete all active suggestions for the line. | |||
| @@ -561,6 +570,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * Align DB rows with [desired]. Returns count of newly inserted SPL rows. | |||
| */ | |||
| private fun syncSuggestedPickLotsForLine( | |||
| @@ -579,6 +589,14 @@ open class SuggestedPickLotWorkbenchService( | |||
| val reuse = keepers.maxByOrNull { it.id ?: 0L } | |||
| if (reuse != null) { | |||
| reuse.suggestedLotLine = ill | |||
| // Pending suggestion: keep actual in sync with suggested until user picks/switches. | |||
| if (reuse.actualPickLotLine == null || | |||
| reuse.actualPickLotLine?.id == null || | |||
| reuse.stockOutLine == null || | |||
| !isSplFrozen(reuse) | |||
| ) { | |||
| reuse.actualPickLotLine = ill | |||
| } | |||
| reuse.pickOrderLine = line | |||
| reuse.type = SuggestedPickLotType.PICK_ORDER | |||
| reuse.qty = qty | |||
| @@ -595,6 +613,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| SuggestedPickLot().apply { | |||
| type = SuggestedPickLotType.PICK_ORDER | |||
| suggestedLotLine = ill | |||
| actualPickLotLine = ill | |||
| pickOrderLine = line | |||
| this.qty = qty | |||
| }, | |||
| @@ -616,6 +635,12 @@ open class SuggestedPickLotWorkbenchService( | |||
| val spl = byIllKey[k] | |||
| if (spl != null) { | |||
| spl.suggestedLotLine = ill | |||
| if (spl.actualPickLotLine == null || | |||
| spl.stockOutLine == null || | |||
| !isSplFrozen(spl) | |||
| ) { | |||
| spl.actualPickLotLine = ill | |||
| } | |||
| spl.pickOrderLine = line | |||
| spl.type = SuggestedPickLotType.PICK_ORDER | |||
| spl.qty = qty | |||
| @@ -629,6 +654,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| SuggestedPickLot().apply { | |||
| type = SuggestedPickLotType.PICK_ORDER | |||
| suggestedLotLine = ill | |||
| actualPickLotLine = ill | |||
| pickOrderLine = line | |||
| this.qty = qty | |||
| }, | |||
| @@ -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`); | |||