| @@ -259,9 +259,9 @@ open class DoWorkbenchMainService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.3 | 2026-11-02 | |||
| * Warehouse exclude list for workbench **re-suggest** after scan-pick (shortfall / lot split). | |||
| * - JO: same list as assign ([JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES]). | |||
| * - JO: same list as assign ([JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES], all `4F-` bins). | |||
| * - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else empty (no limit). | |||
| * - DO / other: pass through request (`null` → service default excludes). | |||
| */ | |||
| @@ -23,12 +23,12 @@ import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | |||
| import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus | |||
| import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService | |||
| import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService | |||
| import com.ffii.fpsms.modules.stock.service.isWorkbenchWarehouseCodeExcluded | |||
| import com.ffii.fpsms.modules.user.service.UserService | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.transaction.annotation.Transactional | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDate | |||
| import java.util.Locale | |||
| /** | |||
| * Job Order Workbench–only flows (assign prime, hierarchical payload for scan-pick UI). | |||
| @@ -79,8 +79,7 @@ open class JoWorkbenchMainService( | |||
| val availableStatus = notDeleted.filter { it.status == InventoryLotLineStatus.AVAILABLE } | |||
| val notExpired = availableStatus.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | |||
| val notExcluded = notExpired.filter { lot -> | |||
| val code = lot.warehouse?.code?.trim()?.uppercase(Locale.ROOT) | |||
| code.isNullOrEmpty() || code !in workbenchDefaultExcludeWarehouseCodes | |||
| !isWorkbenchWarehouseCodeExcluded(lot.warehouse?.code, workbenchDefaultExcludeWarehouseCodes) | |||
| } | |||
| val positiveQty = notExcluded.filter { lot -> | |||
| val inQ = lot.inQty ?: BigDecimal.ZERO | |||
| @@ -1,37 +1,18 @@ | |||
| package com.ffii.fpsms.modules.jobOrder.service | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 | |||
| * JO workbench pick constants. | |||
| * | |||
| * [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService]) | |||
| * and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]). | |||
| * JO re-suggest keeps [storeId] null (same as assign); it does not use DO floor store resolution. | |||
| * | |||
| * Each entry matches a warehouse code exactly, or any code with that prefix plus `-`. | |||
| * `"4F"` therefore excludes every 4F bin (`4F-W402-…`, `4F-W401-…`, and the rest). | |||
| */ | |||
| object JoWorkbenchPickConstants { | |||
| val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set<String> = setOf( | |||
| "4F-W402-01-00", | |||
| "4F-W402-02-00", | |||
| "4F-W402-03-00", | |||
| "4F-W402-04-00", | |||
| "4F-W402-05-00", | |||
| "4F-W402-#A-00", | |||
| "4F-W402-#B-00", | |||
| "4F-W402-#C-00", | |||
| "4F-W402-#D-00", | |||
| "4F-W402-#E-00", | |||
| "4F-W402-#F-00", | |||
| "4F-W402-#G-00", | |||
| "4F-W402-#H-00", | |||
| "4F-W402-#I-00", | |||
| "4F-W402-#J-00", | |||
| "4F-W402-#K-00", | |||
| "4F-W402-#L-00", | |||
| "4F-W402-#M-00", | |||
| "4F-W402-#N-00", | |||
| "4F-W402-#O-00", | |||
| "4F-W402-#P-00", | |||
| "4F-W402-#Q-00", | |||
| "4F-W402-#R-00", | |||
| "4F-W402-#S-00", | |||
| "4F", | |||
| ) | |||
| } | |||
| @@ -5,7 +5,7 @@ import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchPickConstants | |||
| /** | |||
| * Temporary consumable workbench exclude list until per-user DB config (Scheme A) lands. | |||
| * | |||
| * - [HARDCODED_EXCLUDE_USER_ID] → same warehouses as JO ([JoWorkbenchPickConstants]). | |||
| * - [HARDCODED_EXCLUDE_USER_ID] → same warehouses as JO ([JoWorkbenchPickConstants], all `4F-` bins). | |||
| * - All other users → empty list (no warehouse limit). Do not return null: | |||
| * [SuggestedPickLotWorkbenchService] treats null as DO default 2F excludes. | |||
| * Consumable re-suggest keeps storeId null (same as first prime / JO); it does not use DO floor store resolution. | |||
| @@ -13,7 +13,7 @@ import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchPickConstants | |||
| object ConsumableWorkbenchPickConstants { | |||
| const val HARDCODED_EXCLUDE_USER_ID: Long = 246L | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 66 | v1.0.0 | 2026-08-13 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 66 | v1.0.1 | 2026-11-02 */ | |||
| fun resolveExcludeWarehouseCodes(userId: Long): List<String> { | |||
| if (userId != HARDCODED_EXCLUDE_USER_ID) return emptyList() | |||
| return JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES.toList() | |||
| @@ -316,8 +316,31 @@ open class StockTakeRecordService( | |||
| } | |||
| } | |||
| /** 與 Approver 列表前端一致:優先第二次盤點數,否則第一次 */ | |||
| /** | |||
| * 與核准列表「差異百分比」同一個選擇數量。 | |||
| * lastSelect:1 第一次、2 第二次、3 審核員(盤點數 − 不良數)。 | |||
| * 沒有 lastSelect 時用 finalQty 對應;再沒有才退回第二次,否則第一次。 | |||
| */ | |||
| private fun resolvePickerQtyForVarianceFilter(response: InventoryLotDetailResponse): BigDecimal { | |||
| when (response.lastSelect) { | |||
| 1 -> return response.firstStockTakeQty ?: BigDecimal.ZERO | |||
| 2 -> return response.secondStockTakeQty ?: BigDecimal.ZERO | |||
| 3 -> return approverGoodQty(response) | |||
| } | |||
| val finalQty = response.finalQty | |||
| if (finalQty != null) { | |||
| val approverGood = approverGoodQty(response) | |||
| if (response.approverQty != null && sameStockTakeQty(approverGood, finalQty)) { | |||
| return approverGood | |||
| } | |||
| val second = response.secondStockTakeQty | |||
| if (second != null && sameStockTakeQty(second, finalQty)) return second | |||
| val first = response.firstStockTakeQty | |||
| if (first != null && sameStockTakeQty(first, finalQty)) return first | |||
| if (response.approverQty != null && sameStockTakeQty(response.approverQty, finalQty)) { | |||
| return approverGood | |||
| } | |||
| } | |||
| val second = response.secondStockTakeQty | |||
| val first = response.firstStockTakeQty | |||
| return when { | |||
| @@ -327,6 +350,13 @@ open class StockTakeRecordService( | |||
| } | |||
| } | |||
| /** 與前端選擇數量一致:審核員盤點數減不良數。 */ | |||
| private fun approverGoodQty(response: InventoryLotDetailResponse): BigDecimal = | |||
| (response.approverQty ?: BigDecimal.ZERO).subtract(response.approverBadQty ?: BigDecimal.ZERO) | |||
| private fun sameStockTakeQty(a: BigDecimal?, b: BigDecimal?): Boolean = | |||
| a != null && b != null && a.compareTo(b) == 0 | |||
| private fun computeInventoryLotDetailVariancePercentage(response: InventoryLotDetailResponse): BigDecimal { | |||
| val selectedQty = resolvePickerQtyForVarianceFilter(response) | |||
| val bookQty = response.bookQty ?: response.availableQty ?: BigDecimal.ZERO | |||
| @@ -646,6 +676,7 @@ open class StockTakeRecordService( | |||
| return stockTakeRepository.findByIdAndDeletedIsFalse(roundKey) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 90 | v1.0.0 | 2026-11-02 */ | |||
| open fun getApproverInventoryLotDetailsAll( | |||
| stockTakeId: Long? = null, | |||
| pageNum: Int = 0, | |||
| @@ -23,6 +23,21 @@ data class WorkbenchSuggestionSummary( | |||
| val skippedExisting: Int, | |||
| ) | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 | |||
| * Case-insensitive match of [warehouseCode] against [excludeCodes]. | |||
| * An entry matches the code exactly, or any code that starts with `{entry}-`. | |||
| * `"4F"` therefore excludes every 4F bin. | |||
| */ | |||
| fun isWorkbenchWarehouseCodeExcluded(warehouseCode: String?, excludeCodes: Collection<String>): Boolean { | |||
| val code = warehouseCode?.trim()?.uppercase() ?: return false | |||
| if (code.isEmpty() || excludeCodes.isEmpty()) return false | |||
| return excludeCodes.any { raw -> | |||
| val entry = raw.trim().uppercase() | |||
| entry.isNotEmpty() && (code == entry || code.startsWith("$entry-")) | |||
| } | |||
| } | |||
| @Service | |||
| open class SuggestedPickLotWorkbenchService( | |||
| private val pickOrderLineRepository: PickOrderLineRepository, | |||
| @@ -127,7 +142,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| /** | |||
| * Backend-default avoid warehouse list for workbench suggestions. | |||
| * - Case-insensitive match against `warehouse.code`. | |||
| * - Case-insensitive match against `warehouse.code` (exact, or `{entry}-` prefix). | |||
| * - When request.excludeWarehouseCodes is provided, it overrides this default. | |||
| * | |||
| * Keep it empty if you don't need any default excludes. | |||
| @@ -143,9 +158,32 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| private fun isWarehouseExcluded(lot: InventoryLotLine, excludeWarehouseCodes: Set<String>): Boolean { | |||
| if (excludeWarehouseCodes.isEmpty()) return false | |||
| val code = lot.warehouse?.code?.trim()?.uppercase() | |||
| return !code.isNullOrEmpty() && code in excludeWarehouseCodes | |||
| return isWorkbenchWarehouseCodeExcluded(lot.warehouse?.code, excludeWarehouseCodes) | |||
| } | |||
| /** 3F before 2F when expiry is the same. Other floors stay after those two. */ | |||
| private fun suggestionFloorRank(lot: InventoryLotLine): Int { | |||
| val store = normalizeStoreId(lot.warehouse?.store_id) | |||
| ?: floorTokenFromWarehouseCode(lot.warehouse?.code) | |||
| return when (store) { | |||
| "3F" -> 0 | |||
| "2F" -> 1 | |||
| else -> 2 | |||
| } | |||
| } | |||
| private fun floorTokenFromWarehouseCode(code: String?): String? { | |||
| val head = code?.trim()?.substringBefore("-") ?: return null | |||
| return normalizeStoreId(head) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 | |||
| * Closest expiry first; same day prefers 3F, then 2F. | |||
| */ | |||
| private fun compareSuggestedLots(a: InventoryLotLine, b: InventoryLotLine): Int { | |||
| val byExpiry = compareValues(a.inventoryLot?.expiryDate, b.inventoryLot?.expiryDate) | |||
| if (byExpiry != 0) return byExpiry | |||
| return suggestionFloorRank(a).compareTo(suggestionFloorRank(b)) | |||
| } | |||
| private fun effectiveExcludeWarehouseCodes(raw: List<String>?): Set<String> { | |||
| @@ -168,7 +206,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 | |||
| * 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). | |||
| @@ -204,7 +242,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| .filter { it.status == InventoryLotLineStatus.AVAILABLE } | |||
| .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | |||
| .filter { !isWarehouseExcluded(it, excludeSet) } | |||
| .sortedBy { it.inventoryLot?.expiryDate } | |||
| .sortedWith(this::compareSuggestedLots) | |||
| .groupBy { it.inventoryLot?.item?.id } | |||
| var created = 0 | |||
| @@ -301,7 +339,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 | |||
| * 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. | |||
| */ | |||
| @@ -365,7 +403,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| if (storeKey == null) true | |||
| else normalizeStoreId(lot.warehouse?.store_id) == storeKey | |||
| } | |||
| .sortedBy { it.inventoryLot?.expiryDate } | |||
| .sortedWith(this::compareSuggestedLots) | |||
| val desired = buildDesiredAllocations(lots, remaining, pickOrderLineId) | |||
| val created = syncSuggestedPickLotsForLine(lineFresh, desired) | |||
| @@ -393,7 +431,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 | |||
| * 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). | |||
| @@ -441,7 +479,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| if (storeKey == null) true | |||
| else normalizeStoreId(lot.warehouse?.store_id) == storeKey | |||
| } | |||
| .sortedBy { it.inventoryLot?.expiryDate } | |||
| .sortedWith(this::compareSuggestedLots) | |||
| .firstOrNull() | |||
| val desired = listOf(nextLot to targetQty) | |||
| @@ -454,7 +492,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 | |||
| * 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. | |||
| @@ -491,7 +529,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | |||
| .filter { !isWarehouseExcluded(it, excludeSet) } | |||
| .filter { matchesPolUom(it, line) } | |||
| .sortedBy { it.inventoryLot?.expiryDate } | |||
| .sortedWith(this::compareSuggestedLots) | |||
| val desired = buildDesiredAllocations(lots, targetQty, pickOrderLineId) | |||
| val created = syncSuggestedPickLotsForLine(line, desired) | |||