Co-authored-by: Cursor <[email protected]>fix負數倉
| @@ -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). | * 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). | * - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else empty (no limit). | ||||
| * - DO / other: pass through request (`null` → service default excludes). | * - 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.entity.enum.InventoryLotLineStatus | ||||
| import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService | import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService | ||||
| import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService | import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService | ||||
| import com.ffii.fpsms.modules.stock.service.isWorkbenchWarehouseCodeExcluded | |||||
| import com.ffii.fpsms.modules.user.service.UserService | import com.ffii.fpsms.modules.user.service.UserService | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.util.Locale | |||||
| /** | /** | ||||
| * Job Order Workbench–only flows (assign prime, hierarchical payload for scan-pick UI). | * Job Order Workbench–only flows (assign prime, hierarchical payload for scan-pick UI). | ||||
| @@ -79,8 +79,7 @@ open class JoWorkbenchMainService( | |||||
| val availableStatus = notDeleted.filter { it.status == InventoryLotLineStatus.AVAILABLE } | val availableStatus = notDeleted.filter { it.status == InventoryLotLineStatus.AVAILABLE } | ||||
| val notExpired = availableStatus.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | val notExpired = availableStatus.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | ||||
| val notExcluded = notExpired.filter { lot -> | 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 positiveQty = notExcluded.filter { lot -> | ||||
| val inQ = lot.inQty ?: BigDecimal.ZERO | val inQ = lot.inQty ?: BigDecimal.ZERO | ||||
| @@ -1,37 +1,18 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.service | 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. | * JO workbench pick constants. | ||||
| * | * | ||||
| * [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService]) | * [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService]) | ||||
| * and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]). | * 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. | * 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 { | object JoWorkbenchPickConstants { | ||||
| val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set<String> = setOf( | 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. | * 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: | * - All other users → empty list (no warehouse limit). Do not return null: | ||||
| * [SuggestedPickLotWorkbenchService] treats null as DO default 2F excludes. | * [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. | * 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 { | object ConsumableWorkbenchPickConstants { | ||||
| const val HARDCODED_EXCLUDE_USER_ID: Long = 246L | 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> { | fun resolveExcludeWarehouseCodes(userId: Long): List<String> { | ||||
| if (userId != HARDCODED_EXCLUDE_USER_ID) return emptyList() | if (userId != HARDCODED_EXCLUDE_USER_ID) return emptyList() | ||||
| return JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES.toList() | 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 { | 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 second = response.secondStockTakeQty | ||||
| val first = response.firstStockTakeQty | val first = response.firstStockTakeQty | ||||
| return when { | 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 { | private fun computeInventoryLotDetailVariancePercentage(response: InventoryLotDetailResponse): BigDecimal { | ||||
| val selectedQty = resolvePickerQtyForVarianceFilter(response) | val selectedQty = resolvePickerQtyForVarianceFilter(response) | ||||
| val bookQty = response.bookQty ?: response.availableQty ?: BigDecimal.ZERO | val bookQty = response.bookQty ?: response.availableQty ?: BigDecimal.ZERO | ||||
| @@ -646,6 +676,7 @@ open class StockTakeRecordService( | |||||
| return stockTakeRepository.findByIdAndDeletedIsFalse(roundKey) | return stockTakeRepository.findByIdAndDeletedIsFalse(roundKey) | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 90 | v1.0.0 | 2026-11-02 */ | |||||
| open fun getApproverInventoryLotDetailsAll( | open fun getApproverInventoryLotDetailsAll( | ||||
| stockTakeId: Long? = null, | stockTakeId: Long? = null, | ||||
| pageNum: Int = 0, | pageNum: Int = 0, | ||||
| @@ -23,6 +23,21 @@ data class WorkbenchSuggestionSummary( | |||||
| val skippedExisting: Int, | 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 | @Service | ||||
| open class SuggestedPickLotWorkbenchService( | open class SuggestedPickLotWorkbenchService( | ||||
| private val pickOrderLineRepository: PickOrderLineRepository, | private val pickOrderLineRepository: PickOrderLineRepository, | ||||
| @@ -127,7 +142,7 @@ open class SuggestedPickLotWorkbenchService( | |||||
| /** | /** | ||||
| * Backend-default avoid warehouse list for workbench suggestions. | * 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. | * - When request.excludeWarehouseCodes is provided, it overrides this default. | ||||
| * | * | ||||
| * Keep it empty if you don't need any default excludes. | * 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 { | 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> { | 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: | * Build suggestions with no-hold rule: | ||||
| * available = inQty - outQty (ignore holdQty). | * available = inQty - outQty (ignore holdQty). | ||||
| * When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id). | * When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id). | ||||
| @@ -204,7 +242,7 @@ open class SuggestedPickLotWorkbenchService( | |||||
| .filter { it.status == InventoryLotLineStatus.AVAILABLE } | .filter { it.status == InventoryLotLineStatus.AVAILABLE } | ||||
| .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | ||||
| .filter { !isWarehouseExcluded(it, excludeSet) } | .filter { !isWarehouseExcluded(it, excludeSet) } | ||||
| .sortedBy { it.inventoryLot?.expiryDate } | |||||
| .sortedWith(this::compareSuggestedLots) | |||||
| .groupBy { it.inventoryLot?.item?.id } | .groupBy { it.inventoryLot?.item?.id } | ||||
| var created = 0 | 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]. | * Same no-hold allocation as [rebuildNoHoldSuggestionsForPickOrder], but only for one [pickOrderLineId]. | ||||
| * Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order. | * Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order. | ||||
| */ | */ | ||||
| @@ -365,7 +403,7 @@ open class SuggestedPickLotWorkbenchService( | |||||
| if (storeKey == null) true | if (storeKey == null) true | ||||
| else normalizeStoreId(lot.warehouse?.store_id) == storeKey | else normalizeStoreId(lot.warehouse?.store_id) == storeKey | ||||
| } | } | ||||
| .sortedBy { it.inventoryLot?.expiryDate } | |||||
| .sortedWith(this::compareSuggestedLots) | |||||
| val desired = buildDesiredAllocations(lots, remaining, pickOrderLineId) | val desired = buildDesiredAllocations(lots, remaining, pickOrderLineId) | ||||
| val created = syncSuggestedPickLotsForLine(lineFresh, desired) | 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): | * Workbench explicit-qty remainder support (single-next-lot): | ||||
| * Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot. | * Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot. | ||||
| * When [storeId] is provided, the next lot must be within the same store (warehouse.store_id). | * When [storeId] is provided, the next lot must be within the same store (warehouse.store_id). | ||||
| @@ -441,7 +479,7 @@ open class SuggestedPickLotWorkbenchService( | |||||
| if (storeKey == null) true | if (storeKey == null) true | ||||
| else normalizeStoreId(lot.warehouse?.store_id) == storeKey | else normalizeStoreId(lot.warehouse?.store_id) == storeKey | ||||
| } | } | ||||
| .sortedBy { it.inventoryLot?.expiryDate } | |||||
| .sortedWith(this::compareSuggestedLots) | |||||
| .firstOrNull() | .firstOrNull() | ||||
| val desired = listOf(nextLot to targetQty) | 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: | * Workbench explicit-qty split support: | ||||
| * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). | * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). | ||||
| * - If [targetQty] <= 0: soft-delete all active suggestions for the line. | * - If [targetQty] <= 0: soft-delete all active suggestions for the line. | ||||
| @@ -491,7 +529,7 @@ open class SuggestedPickLotWorkbenchService( | |||||
| .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | ||||
| .filter { !isWarehouseExcluded(it, excludeSet) } | .filter { !isWarehouseExcluded(it, excludeSet) } | ||||
| .filter { matchesPolUom(it, line) } | .filter { matchesPolUom(it, line) } | ||||
| .sortedBy { it.inventoryLot?.expiryDate } | |||||
| .sortedWith(this::compareSuggestedLots) | |||||
| val desired = buildDesiredAllocations(lots, targetQty, pickOrderLineId) | val desired = buildDesiredAllocations(lots, targetQty, pickOrderLineId) | ||||
| val created = syncSuggestedPickLotsForLine(line, desired) | val created = syncSuggestedPickLotsForLine(line, desired) | ||||