Browse Source

actualPickLotLineId + COALESCE 讀取

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
CANCERYS\kw093 21 hours ago
parent
commit
0c1cb0d4f3
33 changed files with 1628 additions and 147 deletions
  1. +22
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt
  2. +37
    -6
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt
  3. +160
    -12
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  4. +3
    -3
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt
  5. +6
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt
  6. +49
    -38
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt
  7. +40
    -23
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt
  8. +6
    -2
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt
  9. +3
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt
  10. +50
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt
  11. +8
    -4
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt
  12. +31
    -6
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt
  13. +13
    -4
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt
  14. +7
    -5
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt
  15. +68
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt
  16. +183
    -19
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  17. +12
    -1
      src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt
  18. +22
    -2
      src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt
  19. +204
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/DoUserPickAuditReportService.kt
  20. +281
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt
  21. +140
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/DoUserPickAuditReportController.kt
  22. +28
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt
  23. +16
    -1
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedger.kt
  24. +7
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLine.kt
  25. +5
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestedPickLot.kt
  26. +14
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  27. +50
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockLedgerLotSnapshot.kt
  28. +58
    -9
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt
  29. +15
    -3
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt
  30. +27
    -2
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  31. +32
    -6
      src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt
  32. +21
    -0
      src/main/resources/db/changelog/changes/20260723_do_user_pick_audit/01_add_audit_columns.sql
  33. +10
    -0
      src/main/resources/db/changelog/changes/20260724_do_user_pick_audit_perf/01_ledger_sol_index.sql

+ 22
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt View File

@@ -69,6 +69,28 @@ open class DoFloorSupplierSettingsService(
return null return null
} }


/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Warehouse store scope for workbench lot suggestions (`2/F` / `4/F`).
* Uses ticket [dbStoreId] when set; when null (Truck X), derives floor from linked DO
* supplier codes (4F settings list wins; else 2F). Does not write DOPO.storeId.
*/
open fun resolveSuggestionStoreId(dbStoreId: String?, supplierCodes: Collection<String?>): String {
val raw = dbStoreId?.trim()?.takeIf { it.isNotEmpty() }
if (raw != null) {
return when (raw.replace("/", "").uppercase(Locale.ROOT)) {
"2F" -> "2/F"
"4F" -> "4/F"
else -> raw
}
}
val (s2, s4) = loadDoFloorSupplierLists()
val any4F = supplierCodes.any { code ->
preferredStoreFloorForSupplier(code, s2, s4) == "4F"
}
return if (any4F) "4/F" else "2/F"
}

data class SqlPreferredFloorCases( data class SqlPreferredFloorCases(
/** 例如 `CASE WHEN s.code IN (...) THEN '4F' ... END`(單行,可嵌入原生 SQL) */ /** 例如 `CASE WHEN s.code IN (...) THEN '4F' ... END`(單行,可嵌入原生 SQL) */
val floorStringCase: String, val floorStringCase: String,


+ 37
- 6
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt View File

@@ -23,6 +23,7 @@ open class DoWorkbenchDopoAssignmentService(
private val pickOrderRepository: PickOrderRepository, private val pickOrderRepository: PickOrderRepository,
private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService,
private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, private val stockOutLineWorkbenchService: StockOutLineWorkbenchService,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) { ) {


@Transactional @Transactional
@@ -95,7 +96,7 @@ open class DoWorkbenchDopoAssignmentService(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Same UX as [DoPickOrderAssignmentService.assignByLane] but candidates come from * Same UX as [DoPickOrderAssignmentService.assignByLane] but candidates come from
* [delivery_order_pick_order] (+ unassigned [pick_order]), not [do_pick_order]. * [delivery_order_pick_order] (+ unassigned [pick_order]), not [do_pick_order].
*/ */
@@ -208,7 +209,7 @@ open class DoWorkbenchDopoAssignmentService(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Legacy lane assign (V1): old FG-style (no atomic conflict guard). * Legacy lane assign (V1): old FG-style (no atomic conflict guard).
*/ */
@Transactional @Transactional
@@ -399,18 +400,19 @@ open class DoWorkbenchDopoAssignmentService(
) )
} }
// 下方逻辑先保持不变(你后续再做第二阶段批次化)
// Suggestion store scope: use DOPO.storeId when set; Truck X (null) → 2/F or 4/F from DO suppliers.
val storeIdRow = jdbcDao.queryForList( val storeIdRow = jdbcDao.queryForList(
"SELECT storeId FROM fpsmsdb.delivery_order_pick_order WHERE id = :id AND deleted = 0", "SELECT storeId FROM fpsmsdb.delivery_order_pick_order WHERE id = :id AND deleted = 0",
mapOf("id" to dopoId), mapOf("id" to dopoId),
).firstOrNull() ).firstOrNull()
val storeIdKey = storeIdRow?.keys?.find { it.equals("storeId", true) } val storeIdKey = storeIdRow?.keys?.find { it.equals("storeId", true) }
val storeId = storeIdKey?.let { storeIdRow[it]?.toString() }?.trim()?.takeIf { it.isNotEmpty() }
val dbStoreId = storeIdKey?.let { storeIdRow[it]?.toString() }?.trim()?.takeIf { it.isNotEmpty() }
val suggestionStoreId = resolveSuggestionStoreIdForDopo(dopoId, dbStoreId)

for (poId in poIds) { for (poId in poIds) {
suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder( suggestedPickLotWorkbenchService.primeNextSingleLotSuggestionsForPickOrder(
pickOrderId = poId, pickOrderId = poId,
storeId = storeId,
storeId = suggestionStoreId,
excludeWarehouseCodes = null, excludeWarehouseCodes = null,
) )
stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(poId, userId) stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(poId, userId)
@@ -438,4 +440,33 @@ open class DoWorkbenchDopoAssignmentService(
) )
) )
} }

/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Truck X: derive 2/F|4/F from linked DO suppliers when DOPO.storeId is null.
*/
private fun resolveSuggestionStoreIdForDopo(dopoId: Long, dbStoreId: String?): String {
if (!dbStoreId.isNullOrBlank()) {
return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList())
}
val rows = try {
jdbcDao.queryForList(
"""
SELECT DISTINCT s.code AS supplierCode
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0
WHERE po.deliveryOrderPickOrderId = :dopoId AND po.deleted = 0
""".trimIndent(),
mapOf("dopoId" to dopoId),
)
} catch (_: Exception) {
emptyList()
}
val codes = rows.mapNotNull { row ->
val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null
row[k]?.toString()
}
return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes)
}
} }

+ 160
- 12
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt View File

@@ -45,6 +45,7 @@ import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus
import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo
import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService
import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService
import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot
import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress
import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -266,6 +267,7 @@ open class DoWorkbenchMainService(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03
* Workbench scan-pick (DO FG): * Workbench scan-pick (DO FG):
* 1) Post outbound on scanned inventory lot line first; on failure return a clear message. * 1) Post outbound on scanned inventory lot line first; on failure return a clear message.
* 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty], * 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty],
@@ -324,6 +326,7 @@ open class DoWorkbenchMainService(
sol.handledBy = request.userId sol.handledBy = request.userId
if (sol.startTime == null) sol.startTime = LocalDateTime.now() if (sol.startTime == null) sol.startTime = LocalDateTime.now()
sol.endTime = LocalDateTime.now() sol.endTime = LocalDateTime.now()
maybeMarkIssueJustComplete(sol, request.justComplete == true)
stockOutLIneRepository.saveAndFlush(sol) stockOutLIneRepository.saveAndFlush(sol)
val polZero = sol.pickOrderLine val polZero = sol.pickOrderLine
updateJoPickOrderHandledByIfJobOrder( updateJoPickOrderHandledByIfJobOrder(
@@ -618,6 +621,7 @@ if (sol.startTime == null) sol.startTime = LocalDateTime.now()
if (solEndStatus.equals(StockOutLineStatus.COMPLETE.status, ignoreCase = true)) { if (solEndStatus.equals(StockOutLineStatus.COMPLETE.status, ignoreCase = true)) {
sol.endTime = LocalDateTime.now() sol.endTime = LocalDateTime.now()
} }
maybeMarkIssueJustComplete(sol, request.justComplete == true)
stockOutLIneRepository.save(sol) stockOutLIneRepository.save(sol)
stockOutLIneRepository.flush() stockOutLIneRepository.flush()
sol.id?.let { suggestedPickLotWorkbenchService.linkSplToStockOutLineAfterWorkbenchPick(it) } sol.id?.let { suggestedPickLotWorkbenchService.linkSplToStockOutLineAfterWorkbenchPick(it) }
@@ -887,7 +891,7 @@ return MessageResponse(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Workbench Etra view: `isExtra` / `isExtrabatch` / `isExtrasingle` tickets for one day, * Workbench Etra view: `isExtra` / `isExtrabatch` / `isExtrasingle` tickets for one day,
* grouped by shop then truck / time / loading sequence. * grouped by shop then truck / time / loading sequence.
* Truck X (`車線-X`, DB `storeId` null) is included and split by supplier preferred floor * Truck X (`車線-X`, DB `storeId` null) is included and split by supplier preferred floor
@@ -1127,7 +1131,7 @@ return MessageResponse(
/** /**
* @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today). * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today).
* When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day). * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day).
* @param releaseTypeFilter when `isExtra`/`etra`/`etraFamily`, filter Etra family (`isExtra`/`isExtrabatch`/`isExtrasingle`).
* @param releaseTypeFilter when `isExtra`/`etra`, filters Etra family (`isExtra` / `isExtrabatch` / `isExtrasingle`).
* @param floor optional `2F`/`4F`: for Truck X (null storeId) filter by DO supplier preferred floor. * @param floor optional `2F`/`4F`: for Truck X (null storeId) filter by DO supplier preferred floor.
*/ */
open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(
@@ -1739,6 +1743,100 @@ return MessageResponse(
) )
} }


/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Store scope for workbench lot (re)suggestions.
* Prefer [requestStoreId]; else DOPO.storeId; Truck X (both null) → 2/F|4/F from DO suppliers.
*/
private fun resolveWorkbenchSuggestionStoreId(pickOrderId: Long, requestStoreId: String?): String {
if (!requestStoreId.isNullOrBlank()) {
return doFloorSupplierSettingsService.resolveSuggestionStoreId(requestStoreId, emptyList())
}
val header = try {
jdbcDao.queryForList(
"""
SELECT dop.storeId AS storeId
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.delivery_order_pick_order dop
ON dop.id = po.deliveryOrderPickOrderId AND dop.deleted = 0
WHERE po.id = :poId AND po.deleted = 0
LIMIT 1
""".trimIndent(),
mapOf("poId" to pickOrderId),
).firstOrNull()
} catch (_: Exception) {
null
}
val dbStoreId = header?.let { row ->
val k = row.keys.find { it.equals("storeId", true) } ?: return@let null
row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() }
}
if (!dbStoreId.isNullOrBlank()) {
return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList())
}
val supplierRows = try {
jdbcDao.queryForList(
"""
SELECT DISTINCT s.code AS supplierCode
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0
WHERE po.id = :poId AND po.deleted = 0
""".trimIndent(),
mapOf("poId" to pickOrderId),
)
} catch (_: Exception) {
emptyList()
}
val codes = supplierRows.mapNotNull { row ->
val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null
row[k]?.toString()
}
return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* UI / lot-print / scan-pick store scope for a DOPO.
* Truck X keeps DB storeId null; response may expose derived 2/F|4/F (not written to DB).
*/
private fun resolveDisplayStoreIdForDopo(dopoId: Long, dbStoreId: String?): String {
if (!dbStoreId.isNullOrBlank()) {
return doFloorSupplierSettingsService.resolveSuggestionStoreId(dbStoreId, emptyList())
}
val supplierRows = try {
jdbcDao.queryForList(
"""
SELECT DISTINCT s.code AS supplierCode
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0
WHERE po.deliveryOrderPickOrderId = :dopoId AND po.deleted = 0
""".trimIndent(),
mapOf("dopoId" to dopoId),
)
} catch (_: Exception) {
emptyList()
}
val codes = supplierRows.mapNotNull { row ->
val k = row.keys.find { it.equals("supplierCode", true) } ?: return@mapNotNull null
row[k]?.toString()
}
return doFloorSupplierSettingsService.resolveSuggestionStoreId(null, codes)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Copy DOPO header map with Truck X display storeId filled for hierarchical fgInfo.
*/
private fun enrichDopoInfoStoreIdForUi(doPickOrderInfo: Map<String, Any?>, dopoId: Long): Map<String, Any?> {
val storeKey = doPickOrderInfo.keys.find { it.equals("store_id", true) } ?: "store_id"
val dbStoreId = doPickOrderInfo[storeKey]?.toString()?.trim()?.takeIf { it.isNotEmpty() }
if (!dbStoreId.isNullOrBlank()) return doPickOrderInfo
val resolved = resolveDisplayStoreIdForDopo(dopoId, null)
return doPickOrderInfo.toMutableMap().apply { this[storeKey] = resolved }
}

/** /**
* EXISTS clause: Truck X ticket has at least one DO whose supplier preferred floor matches [floorKey] * EXISTS clause: Truck X ticket has at least one DO whose supplier preferred floor matches [floorKey]
* (`2F` / `4F`), and **no** linked DO on the other floor. * (`2F` / `4F`), and **no** linked DO on the other floor.
@@ -1772,7 +1870,7 @@ return MessageResponse(
""".trimIndent() """.trimIndent()
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 */
/** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */
private fun queryWorkbenchReleasedDopoList( private fun queryWorkbenchReleasedDopoList(
shopName: String?, shopName: String?,
storeId: String?, storeId: String?,
@@ -2021,7 +2119,7 @@ return MessageResponse(
payload = assembleHierarchicalFgPayload( payload = assembleHierarchicalFgPayload(
jdbcDao = jdbcDao, jdbcDao = jdbcDao,
doPickOrderId = doPickOrderId, doPickOrderId = doPickOrderId,
doPickOrderInfo = doPickOrderInfoSafe,
doPickOrderInfo = enrichDopoInfoStoreIdForUi(doPickOrderInfoSafe, doPickOrderId),
pickOrdersInfo = pickOrdersInfo, pickOrdersInfo = pickOrdersInfo,
availableQtyInOutOnly = true, availableQtyInOutOnly = true,
timingSink = ::mark, timingSink = ::mark,
@@ -2086,13 +2184,12 @@ return MessageResponse(
return mapOf("fgInfo" to null, "pickOrders" to emptyList<Any>()) return mapOf("fgInfo" to null, "pickOrders" to emptyList<Any>())
} }


// Completed-record detail should keep each PO as an independent group
// (ticket can link multiple POs, e.g. 20 + 25), not merged into a single 45-line bucket.
val doPickOrderInfoForUi = enrichDopoInfoStoreIdForUi(doPickOrderInfo, deliveryOrderPickOrderId)
val perPoPayloads = pickOrdersInfo.map { poInfo -> val perPoPayloads = pickOrdersInfo.map { poInfo ->
assembleHierarchicalFgPayload( assembleHierarchicalFgPayload(
jdbcDao = jdbcDao, jdbcDao = jdbcDao,
doPickOrderId = deliveryOrderPickOrderId, doPickOrderId = deliveryOrderPickOrderId,
doPickOrderInfo = doPickOrderInfo,
doPickOrderInfo = doPickOrderInfoForUi,
pickOrdersInfo = listOf(poInfo), pickOrdersInfo = listOf(poInfo),
availableQtyInOutOnly = true, availableQtyInOutOnly = true,
) )
@@ -2176,6 +2273,13 @@ return MessageResponse(
} else { } else {
emptyList() emptyList()
} }
val dopoId = (row["doPickOrderId"] as? Number)?.toLong() ?: 0L
val dbStoreId = row["storeId"]?.toString()?.trim()?.takeIf { it.isNotEmpty() }
val displayStoreId = if (dopoId > 0L) {
resolveDisplayStoreIdForDopo(dopoId, dbStoreId)
} else {
dbStoreId.orEmpty()
}


mapOf( mapOf(
"doPickOrderId" to (row["doPickOrderId"] ?: 0L), "doPickOrderId" to (row["doPickOrderId"] ?: 0L),
@@ -2200,7 +2304,7 @@ return MessageResponse(
"truckLanceCode" to (row["truckLanceCode"] ?: ""), "truckLanceCode" to (row["truckLanceCode"] ?: ""),
"DepartureTime" to (row["DepartureTime"]?.toString() ?: ""), "DepartureTime" to (row["DepartureTime"]?.toString() ?: ""),
"ticketNo" to (row["ticketNo"] ?: ""), "ticketNo" to (row["ticketNo"] ?: ""),
"storeId" to (row["storeId"] ?: ""),
"storeId" to displayStoreId,
"qrCodeData" to (row["doPickOrderId"] ?: 0L), "qrCodeData" to (row["doPickOrderId"] ?: 0L),
) )
} }
@@ -2282,6 +2386,7 @@ return MessageResponse(
var postMs = 0L var postMs = 0L
try { try {
if (pickOrderId != null) { if (pickOrderId != null) {
val suggestionStoreId = resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId)
val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes( val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes(
pickOrderId = pickOrderId, pickOrderId = pickOrderId,
poType = null, poType = null,
@@ -2294,7 +2399,7 @@ return MessageResponse(
suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineNextSingleLot( suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineNextSingleLot(
pickOrderLineId = polId, pickOrderLineId = polId,
targetQty = explicitRemainder, targetQty = explicitRemainder,
storeId = requestStoreId,
storeId = suggestionStoreId,
excludeInventoryLotLineId = scannedIllId, excludeInventoryLotLineId = scannedIllId,
excludeWarehouseCodes = resuggestExcludeWarehouseCodes, excludeWarehouseCodes = resuggestExcludeWarehouseCodes,
) )
@@ -2328,7 +2433,7 @@ return MessageResponse(
rebuildMs = measureTimeMillis { rebuildMs = measureTimeMillis {
suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine( suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine(
pickOrderLineId = polId, pickOrderLineId = polId,
storeId = requestStoreId,
storeId = suggestionStoreId,
excludeWarehouseCodes = resuggestExcludeWarehouseCodes, excludeWarehouseCodes = resuggestExcludeWarehouseCodes,
) )
} }
@@ -2485,6 +2590,9 @@ return MessageResponse(
val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val previousBalance = onHandAfter + deltaQty.toDouble() val previousBalance = onHandAfter + deltaQty.toDouble()
val newBalance = previousBalance - deltaQty.toDouble() val newBalance = previousBalance - deltaQty.toDouble()
val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) }
?: sol.inventoryLotLine
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val ledger = StockLedger().apply { val ledger = StockLedger().apply {
this.stockOutLine = sol this.stockOutLine = sol
this.inventory = inventory this.inventory = inventory
@@ -2494,14 +2602,54 @@ return MessageResponse(
this.type = "NOR" this.type = "NOR"
this.itemId = solItem.id this.itemId = solItem.id
this.itemCode = solItem.code this.itemCode = solItem.code
this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id
?: inventory.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id,
inventory.uom?.id,
)
this.date = LocalDate.now() this.date = LocalDate.now()
} }
StockLedgerLotSnapshot.apply(
ledger = ledger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = deltaQty.negate(),
)
// Do not flush per pick; transaction flush is sufficient and avoids slow roundtrips/locks. // Do not flush per pick; transaction flush is sufficient and avoids slow roundtrips/locks.
stockLedgerRepository.save(ledger) stockLedgerRepository.save(ledger)
} }


/**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03
* DO user-audit: Just Complete is reportable only when the item still has pickable stock
* at complete time (AVAILABLE, not expired, in−out > 0).
*
* Legitimate (do not flag): suggested lot exhausted → system splits remainder SOL → JC qty=0
* with no other available lots.
* Issue (flag): between split and JC a new/other available lot exists but user still JC.
*/
private fun maybeMarkIssueJustComplete(sol: StockOutLine, justComplete: Boolean) {
if (!justComplete) return
val pol = sol.pickOrderLine ?: return
val po = pol.pickOrder
val isDo = po?.type == PickOrderType.DELIVERY_ORDER
if (!isDo) return

val itemId = pol.item?.id ?: sol.item?.id ?: return
val today = LocalDate.now()
val excludeIllId = sol.inventoryLotLine?.id
val hasPickableLot = inventoryLotLineRepository.findAllByItemIdIn(listOf(itemId))
.asSequence()
.filter { !it.deleted }
.filter { excludeIllId == null || it.id != excludeIllId }
.filter { it.status == InventoryLotLineStatus.AVAILABLE }
.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true }
.any { StockLedgerLotSnapshot.availableQty(it) > BigDecimal.ZERO }
if (hasPickableLot) {
sol.isIssueJustComplete = true
}
}

private fun resolveWorkbenchPreviousBalance( private fun resolveWorkbenchPreviousBalance(
itemId: Long, itemId: Long,
inventory: Inventory, inventory: Inventory,


+ 3
- 3
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt View File

@@ -15,7 +15,7 @@ object WorkbenchReleaseTypeSupport {


fun singleFamilyTypes(): List<String> = listOf(SINGLE, IS_EXTRA_SINGLE) fun singleFamilyTypes(): List<String> = listOf(SINGLE, IS_EXTRA_SINGLE)


/** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 */
/** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */
fun summaryFilterSql(releaseType: String, column: String = "dop.releaseType"): String = fun summaryFilterSql(releaseType: String, column: String = "dop.releaseType"): String =
when (releaseType.trim().lowercase()) { when (releaseType.trim().lowercase()) {
"batch" -> batchFamilySql(column) "batch" -> batchFamilySql(column)
@@ -25,7 +25,7 @@ object WorkbenchReleaseTypeSupport {
else -> "" else -> ""
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27 */
/** FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03 */
fun assignFilterSql(releaseType: String?, column: String = "dop.releaseType"): String { fun assignFilterSql(releaseType: String?, column: String = "dop.releaseType"): String {
val n = releaseType?.trim()?.lowercase().orEmpty() val n = releaseType?.trim()?.lowercase().orEmpty()
return when (n) { return when (n) {
@@ -46,7 +46,7 @@ object WorkbenchReleaseTypeSupport {
" AND LOWER(COALESCE($column, '')) = 'isextra' " " AND LOWER(COALESCE($column, '')) = 'isextra' "


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.0 | 2026-07-27
* FP-MTMS Version Checklist | Functions Ref. No. 37 | v1.0.1 | 2026-08-03
* Standalone `isExtra` plus merged `isExtrabatch` / `isExtrasingle` (Etra workbench). * Standalone `isExtra` plus merged `isExtrabatch` / `isExtrasingle` (Etra workbench).
*/ */
fun etraFamilySql(column: String = "dop.releaseType"): String = fun etraFamilySql(column: String = "dop.releaseType"): String =


+ 6
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchScanPickRequest.kt View File

@@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.deliveryOrder.web.models
import java.math.BigDecimal import java.math.BigDecimal


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03
* Workbench v1: scan lot and post pick immediately (no separate submit step). * Workbench v1: scan lot and post pick immediately (no separate submit step).
* [qty] optional: when null, posts up to remaining quantity for this stock-out line chunk; when set, may exceed that * [qty] optional: when null, posts up to remaining quantity for this stock-out line chunk; when set, may exceed that
* chunk and is capped only by available quantity on the scanned inventory lot line (overscan / UI edit). * chunk and is capped only by available quantity on the scanned inventory lot line (overscan / UI edit).
@@ -25,6 +26,11 @@ data class WorkbenchScanPickRequest(
*/ */
val inventoryLotLineId: Long? = null, val inventoryLotLineId: Long? = null,
val qty: BigDecimal? = null, val qty: BigDecimal? = null,
/**
* When true, UI used Just Complete (no QR). Backend may set [StockOutLine.isIssueJustComplete]
* for DO user-audit reporting.
*/
val justComplete: Boolean? = false,
val userId: Long = 1L, val userId: Long = 1L,
) )



+ 49
- 38
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt View File

@@ -231,6 +231,7 @@ open class JoPickOrderService(
} }
return joPickOrderRecordRepository.saveAll(joPickOrderRecords) return joPickOrderRecordRepository.saveAll(joPickOrderRecords)
} }
/** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */
open fun getAllJobOrderLotsWithDetailsHierarchical(userId: Long): JobOrderLotsHierarchicalResponse { open fun getAllJobOrderLotsWithDetailsHierarchical(userId: Long): JobOrderLotsHierarchicalResponse {
println("=== Debug: getAllJobOrderLotsWithDetailsHierarchical ===") println("=== Debug: getAllJobOrderLotsWithDetailsHierarchical ===")
println("today: ${LocalDate.now()}") println("today: ${LocalDate.now()}")
@@ -557,6 +558,7 @@ open class JoPickOrderService(
} }
// Get completed job order pick orders (for second tab) // Get completed job order pick orders (for second tab)
// Fix the getCompletedJobOrderLotsHierarchical method // Fix the getCompletedJobOrderLotsHierarchical method
/** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */
open fun getCompletedJobOrderLotsHierarchical(userId: Long): Map<String, Any?> { open fun getCompletedJobOrderLotsHierarchical(userId: Long): Map<String, Any?> {
println("=== Debug: getCompletedJobOrderLotsHierarchical (repo version) ===") println("=== Debug: getCompletedJobOrderLotsHierarchical (repo version) ===")
println("today: ${LocalDate.now()}") println("today: ${LocalDate.now()}")
@@ -625,13 +627,20 @@ open class JoPickOrderService(
.filter { it.deleted == false } .filter { it.deleted == false }
} else emptyList() } else emptyList()
val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id }
// Prefer actualPickLotLine for display (after scan/switch); fallback suggested
val inventoryLotLineIds = suggestedPickLots
.mapNotNull { it.actualPickLotLine?.id ?: it.suggestedLotLine?.id }
.distinct()
val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) {
inventoryLotLineRepository inventoryLotLineRepository
.findAllByIdIn(inventoryLotLineIds) .findAllByIdIn(inventoryLotLineIds)
.filter { it.deleted == false } .filter { it.deleted == false }
} else emptyList() } else emptyList()

val inventoryLotLineById = inventoryLotLines
.filter { it.id != null }
.associateBy { it.id!! }
val inventoryLotIds = inventoryLotLines.mapNotNull { it.inventoryLot?.id }.distinct() val inventoryLotIds = inventoryLotLines.mapNotNull { it.inventoryLot?.id }.distinct()
val inventoryLots = if (inventoryLotIds.isNotEmpty()) { val inventoryLots = if (inventoryLotIds.isNotEmpty()) {
@@ -664,16 +673,19 @@ open class JoPickOrderService(
} }
val lots = lineSuggestedLots.mapNotNull { spl -> val lots = lineSuggestedLots.mapNotNull { spl ->
val ill = spl.suggestedLotLine ?: return@mapNotNull null
if (ill.deleted == true) return@mapNotNull null
val displayIllId = (spl.actualPickLotLine?.takeIf { it.deleted != true }?.id)
?: (spl.suggestedLotLine?.takeIf { it.deleted != true }?.id)
?: return@mapNotNull null
val ill = inventoryLotLineById[displayIllId] ?: return@mapNotNull null
val il = ill.inventoryLot ?: return@mapNotNull null val il = ill.inventoryLot ?: return@mapNotNull null
if (il.deleted == true) return@mapNotNull null if (il.deleted == true) return@mapNotNull null
val warehouse = ill.warehouse val warehouse = ill.warehouse
val sol = stockOutLines.firstOrNull {
it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id
}
val sol = spl.stockOutLine?.takeIf { it.deleted != true && it.pickOrderLine?.id == pol.id }
?: stockOutLines.firstOrNull {
it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id
}
val availableQty = if (sol?.status == "rejected") { val availableQty = if (sol?.status == "rejected") {
null null
@@ -1140,7 +1152,7 @@ open fun recordSecondScanIssue(request: SecondScanIssueRequest): MessageResponse
il.lotNo as lotNo, il.lotNo as lotNo,
w.name as storeLocation w.name as storeLocation
FROM fpsmsdb.suggested_pick_lot spl FROM fpsmsdb.suggested_pick_lot spl
JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id
JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId)
JOIN fpsmsdb.inventory_lot il ON ill.inventoryLotId = il.id JOIN fpsmsdb.inventory_lot il ON ill.inventoryLotId = il.id
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
WHERE spl.pickOrderLineId = :pickOrderLineId WHERE spl.pickOrderLineId = :pickOrderLineId
@@ -1506,7 +1518,7 @@ open fun getCompletedJobOrderPickOrderLotDetails(pickOrderId: Long): List<Map<St
JOIN fpsmsdb.items i ON i.id = pol.itemId JOIN fpsmsdb.items i ON i.id = pol.itemId
LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId
LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId
LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id
LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId)


LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
@@ -1932,7 +1944,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
JOIN fpsmsdb.items i ON i.id = pol.itemId JOIN fpsmsdb.items i ON i.id = pol.itemId
LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId
LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId
LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id
LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId)
LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
-- no-lot:ill.id 為 null 時,匹配 sol.inventoryLotLineId IS NULL -- no-lot:ill.id 為 null 時,匹配 sol.inventoryLotLineId IS NULL
@@ -2088,7 +2100,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
JOIN fpsmsdb.items i ON i.id = pol.itemId JOIN fpsmsdb.items i ON i.id = pol.itemId
LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId
LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId
LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id
LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId)


LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
@@ -2187,7 +2199,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
FROM fpsmsdb.pick_order po FROM fpsmsdb.pick_order po
JOIN fpsmsdb.pick_order_line pol ON pol.poId = po.id AND pol.deleted = false JOIN fpsmsdb.pick_order_line pol ON pol.poId = po.id AND pol.deleted = false
LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId AND spl.deleted = false LEFT JOIN fpsmsdb.suggested_pick_lot spl ON pol.id = spl.pickOrderLineId AND spl.deleted = false
LEFT JOIN fpsmsdb.inventory_lot_line ill ON spl.suggestedLotLineId = ill.id AND ill.deleted = false
LEFT JOIN fpsmsdb.inventory_lot_line ill ON ill.id = COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AND ill.deleted = false
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = false LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = false
WHERE po.deleted = false WHERE po.deleted = false
@@ -2382,7 +2394,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
jobOrderType = jobOrderType?.name, jobOrderType = jobOrderType?.name,
itemId = item.id ?: 0L, itemId = item.id ?: 0L,
itemName = item.name ?: "", itemName = item.name ?: "",
itemCode = item.code,
bomDescription = bom?.description, bomDescription = bom?.description,
bomType = bom?.type,
reqQty = jobOrder.reqQty ?: BigDecimal.ZERO, reqQty = jobOrder.reqQty ?: BigDecimal.ZERO,
//uomId = bom.outputQtyUom?.id : 0L, //uomId = bom.outputQtyUom?.id : 0L,
uomId = 0, uomId = 0,
@@ -2438,6 +2452,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
} }
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03 */
open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLotsHierarchicalResponse { open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLotsHierarchicalResponse {
println("=== getJobOrderLotsHierarchicalByPickOrderId ===") println("=== getJobOrderLotsHierarchicalByPickOrderId ===")
println("pickOrderId: $pickOrderId") println("pickOrderId: $pickOrderId")
@@ -2518,7 +2533,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
} }


// 获取所有 inventory lot line IDs // 获取所有 inventory lot line IDs
val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id }
val inventoryLotLineIds = suggestedPickLots
.mapNotNull { it.actualPickLotLine?.id ?: it.suggestedLotLine?.id }
.distinct()


// 获取 inventory lot lines // 获取 inventory lot lines
val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) {
@@ -2571,18 +2588,13 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
.associateBy { it.id!! } .associateBy { it.id!! }


// 获取 stock in lines 通过 inventoryLotLineId(用于填充 stockInLineId) // 获取 stock in lines 通过 inventoryLotLineId(用于填充 stockInLineId)
val stockInLinesByInventoryLotLineId = if (inventoryLotLineIds.isNotEmpty()) {
// ✅ 修复:直接使用已加载的 inventoryLotLines 实体获取 stockInLineId
inventoryLotLines.associateBy { it.id!! }
.mapNotNull { (illId, ill) ->
// 通过关系链:InventoryLotLine -> InventoryLot -> StockInLine -> id
ill.inventoryLot?.stockInLine?.id?.let { stockInLineId ->
illId to stockInLineId
}
val stockInLinesByInventoryLotLineId = buildMap {
(inventoryLotLines + stockOutInventoryLotLines).forEach { ill ->
val id = ill.id ?: return@forEach
if (!containsKey(id)) {
ill.inventoryLot?.stockInLine?.id?.let { put(id, it) }
} }
.toMap()
} else {
emptyMap()
}
} }


// 获取 jo_pick_order 记录 // 获取 jo_pick_order 记录
@@ -2617,22 +2629,22 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
userService.find(uid).orElse(null)?.name userService.find(uid).orElse(null)?.name
} }
// 构建 lots 数据 // 构建 lots 数据
// COALESCE(actualPickLotLine, suggestedLotLine) for display after switch
val lots = lineSuggestedLots.mapNotNull { spl -> val lots = lineSuggestedLots.mapNotNull { spl ->
val ill = spl.suggestedLotLine
if (ill == null || ill.deleted == true) return@mapNotNull null
val displayIllId = (spl.actualPickLotLine?.takeIf { it.deleted != true }?.id)
?: (spl.suggestedLotLine?.takeIf { it.deleted != true }?.id)
?: return@mapNotNull null
val ill = inventoryLotLineById[displayIllId] ?: return@mapNotNull null


val il = ill.inventoryLot val il = ill.inventoryLot
if (il == null || il.deleted == true) return@mapNotNull null if (il == null || il.deleted == true) return@mapNotNull null


val warehouse = ill.warehouse val warehouse = ill.warehouse
val sol = stockOutLines.firstOrNull {
it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id
}
val jpo = joPickOrders.firstOrNull { it.itemId == item?.id }
val handlerName = jpo?.handledBy?.let { uid ->
userService.find(uid).orElse(null)?.name
}
println("handlerName: $handlerName")
val sol = spl.stockOutLine?.takeIf { it.deleted != true && it.pickOrderLine?.id == pol.id }
?: stockOutLines.firstOrNull {
it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id
}
val jpoInner = joPickOrders.firstOrNull { it.itemId == item?.id }
val availableQty = if (sol?.status == "rejected") { val availableQty = if (sol?.status == "rejected") {
null null
} else { } else {
@@ -2655,7 +2667,6 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
else -> "pending" else -> "pending"
} }


// 获取 stockInLineId
val stockInLineId = ill.id?.let { illId -> val stockInLineId = ill.id?.let { illId ->
stockInLinesByInventoryLotLineId[illId] stockInLinesByInventoryLotLineId[illId]
} }
@@ -2685,11 +2696,11 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
routerArea = warehouse?.code, routerArea = warehouse?.code,
routerRoute = warehouse?.code, routerRoute = warehouse?.code,
uomShortDesc = uom?.udfShortDesc, uomShortDesc = uom?.udfShortDesc,
matchStatus = jpo?.matchStatus?.value,
matchBy = jpo?.matchBy,
matchQty = jpo?.matchQty?.toDouble()
matchStatus = jpoInner?.matchStatus?.value,
matchBy = jpoInner?.matchBy,
matchQty = jpoInner?.matchQty?.toDouble()
) )
}
}.distinctBy { it.lotId }


// 构建 stockouts 数据:用于无 suggested lot / noLot 场景也能显示并闭环(submit 0) // 构建 stockouts 数据:用于无 suggested lot / noLot 场景也能显示并闭环(submit 0)
val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol -> val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol ->


+ 40
- 23
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt View File

@@ -32,7 +32,8 @@ import java.util.Locale


/** /**
* Job Order Workbench–only flows (assign prime, hierarchical payload for scan-pick UI). * Job Order Workbench–only flows (assign prime, hierarchical payload for scan-pick UI).
* [getJobOrderLotsHierarchicalByPickOrderIdWorkbench] uses **in − out** for available qty (matches workbench scan-pick).
* [getJobOrderLotsHierarchicalByPickOrderIdWorkbench] uses **in − out** for available qty (matches workbench scan-pick)
* and displays COALESCE(actualPickLotLine, suggestedLotLine).
* Non-workbench hierarchical stays in [JoPickOrderService.getJobOrderLotsHierarchicalByPickOrderId] (in − out − hold). * Non-workbench hierarchical stays in [JoPickOrderService.getJobOrderLotsHierarchicalByPickOrderId] (in − out − hold).
*/ */
@Service @Service
@@ -190,7 +191,9 @@ open class JoWorkbenchMainService(
} }


/** /**
* Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line.
* FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03
* Hierarchical pick UI for JO Workbench: available qty **in − out**;
* lot display uses COALESCE(actualPickLotLine, suggestedLotLine) (same as DO workbench).
*/ */
open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse {
println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===")
@@ -239,7 +242,10 @@ open class JoWorkbenchMainService(
emptyList() emptyList()
} }


val inventoryLotLineIds = suggestedPickLots.mapNotNull { it.suggestedLotLine?.id }
// Prefer actualPickLotLine for display (after scan/switch); fallback suggested (old / not yet picked).
val inventoryLotLineIds = suggestedPickLots
.mapNotNull { displayIllForWorkbench(it)?.id }
.distinct()


val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) { val inventoryLotLines = if (inventoryLotLineIds.isNotEmpty()) {
inventoryLotLineRepository.findAllByIdIn(inventoryLotLineIds) inventoryLotLineRepository.findAllByIdIn(inventoryLotLineIds)
@@ -253,16 +259,6 @@ open class JoWorkbenchMainService(
inventoryLotRepository.findAllByIdIn(inventoryLotIds).filter { it.deleted == false } inventoryLotRepository.findAllByIdIn(inventoryLotIds).filter { it.deleted == false }
} }


val stockOutLines = if (pickOrderLineIds.isNotEmpty() && inventoryLotLineIds.isNotEmpty()) {
pickOrderLineIds.flatMap { polId ->
inventoryLotLineIds.flatMap { illId ->
stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse(polId, illId)
}
}
} else {
emptyList()
}

val stockOutLinesByPickOrderLine = pickOrderLineIds.associateWith { polId -> val stockOutLinesByPickOrderLine = pickOrderLineIds.associateWith { polId ->
stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(polId)
} }
@@ -322,27 +318,33 @@ open class JoWorkbenchMainService(
val uom = pol.uom val uom = pol.uom
val lineId = pol.id!! val lineId = pol.id!!
val lineSuggestedLots = suggestedPickLots.filter { it.pickOrderLine?.id == pol.id } val lineSuggestedLots = suggestedPickLots.filter { it.pickOrderLine?.id == pol.id }
val lineSols = stockOutLinesByPickOrderLine[lineId] ?: emptyList()
val jpo = joPickOrders.firstOrNull { it.itemId == item?.id } val jpo = joPickOrders.firstOrNull { it.itemId == item?.id }
val handlerName = jpo?.handledBy?.let { uid -> val handlerName = jpo?.handledBy?.let { uid ->
userService.find(uid).orElse(null)?.name userService.find(uid).orElse(null)?.name
} }


val lots = lineSuggestedLots.mapNotNull { spl -> val lots = lineSuggestedLots.mapNotNull { spl ->
val ill = spl.suggestedLotLine
if (ill == null || ill.deleted == true) return@mapNotNull null
val displayIllId = displayIllForWorkbench(spl)?.id ?: return@mapNotNull null
// Prefer freshly loaded ILL (actual or suggested) so lotNo/location match display id.
val ill = inventoryLotLineById[displayIllId]
?: displayIllForWorkbench(spl)
?: return@mapNotNull null
if (ill.deleted == true) return@mapNotNull null


val il = ill.inventoryLot val il = ill.inventoryLot
if (il == null || il.deleted == true) return@mapNotNull null if (il == null || il.deleted == true) return@mapNotNull null


val warehouse = ill.warehouse val warehouse = ill.warehouse
val sol = stockOutLines.firstOrNull {
it.pickOrderLine?.id == pol.id && it.inventoryLotLine?.id == ill.id
val linkedSolId = spl.stockOutLine?.takeIf { it.deleted != true }?.id
val sol = when {
linkedSolId != null -> lineSols.firstOrNull { it.id == linkedSolId }
else -> lineSols.firstOrNull { it.inventoryLotLineId == ill.id }
} }
val jpoInner = joPickOrders.firstOrNull { it.itemId == item?.id } val jpoInner = joPickOrders.firstOrNull { it.itemId == item?.id }
val handlerNameInner = jpoInner?.handledBy?.let { uid -> val handlerNameInner = jpoInner?.handledBy?.let { uid ->
userService.find(uid).orElse(null)?.name userService.find(uid).orElse(null)?.name
} }
//println("handlerName: $handlerNameInner")
val availableQty = if (sol?.status == "rejected") { val availableQty = if (sol?.status == "rejected") {
null null
} else { } else {
@@ -377,7 +379,7 @@ open class JoWorkbenchMainService(
location = warehouse?.code, location = warehouse?.code,
availableQty = availableQty?.toDouble(), availableQty = availableQty?.toDouble(),
requiredQty = spl.qty?.toDouble() ?: 0.0, requiredQty = spl.qty?.toDouble() ?: 0.0,
actualPickQty = sol?.qty ?: 0.0,
actualPickQty = sol?.qty?.toDouble() ?: 0.0,
processingStatus = processingStatus, processingStatus = processingStatus,
lotAvailability = lotAvailability, lotAvailability = lotAvailability,
pickOrderId = pickOrder.id, pickOrderId = pickOrder.id,
@@ -387,7 +389,7 @@ open class JoWorkbenchMainService(
stockOutLineId = sol?.id, stockOutLineId = sol?.id,
stockInLineId = stockInLineId, stockInLineId = stockInLineId,
suggestedPickLotId = spl.id, suggestedPickLotId = spl.id,
stockOutLineQty = sol?.qty ?: 0.0,
stockOutLineQty = sol?.qty?.toDouble() ?: 0.0,
stockOutLineStatus = sol?.status, stockOutLineStatus = sol?.status,
routerIndex = warehouse?.order?.toString(), routerIndex = warehouse?.order?.toString(),
routerArea = warehouse?.code, routerArea = warehouse?.code,
@@ -397,9 +399,9 @@ open class JoWorkbenchMainService(
matchBy = jpoInner?.matchBy, matchBy = jpoInner?.matchBy,
matchQty = jpoInner?.matchQty?.toDouble() matchQty = jpoInner?.matchQty?.toDouble()
) )
}
}.distinctBy { it.lotId }


val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol ->
val stockouts = lineSols.map { sol ->
val illId = sol.inventoryLotLineId val illId = sol.inventoryLotLineId
val ill = if (illId != null) inventoryLotLineById[illId] else null val ill = if (illId != null) inventoryLotLineById[illId] else null
val lot = ill?.inventoryLot val lot = ill?.inventoryLot
@@ -412,7 +414,10 @@ open class JoWorkbenchMainService(
illAvailableQtyWorkbench(ill) illAvailableQtyWorkbench(ill)
} }
val splForSol = lineSuggestedLots.firstOrNull { sp -> val splForSol = lineSuggestedLots.firstOrNull { sp ->
sp.suggestedLotLine?.id != null && sp.suggestedLotLine?.id == illId
sp.stockOutLine?.id != null && sp.stockOutLine?.id == sol.id
} ?: lineSuggestedLots.firstOrNull { sp ->
val displayId = displayIllForWorkbench(sp)?.id
displayId != null && displayId == illId
} }


StockOutLineDetailResponse( StockOutLineDetailResponse(
@@ -456,6 +461,18 @@ open class JoWorkbenchMainService(
} }
} }


/**
* FP-MTMS Version Checklist | Functions Ref. No. 44 | v1.0.0 | 2026-08-03
* Workbench UI display lot: prefer actual pick (after scan/switch), fallback suggested (pending / old data).
*/
private fun displayIllForWorkbench(spl: com.ffii.fpsms.modules.stock.entity.SuggestedPickLot): InventoryLotLine? {
val actual = spl.actualPickLotLine
if (actual != null && actual.deleted != true) return actual
val suggested = spl.suggestedLotLine
if (suggested != null && suggested.deleted != true) return suggested
return null
}

private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalWorkbenchResponse { private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalWorkbenchResponse {
println("❌ $message") println("❌ $message")
return JobOrderLotsHierarchicalWorkbenchResponse( return JobOrderLotsHierarchicalWorkbenchResponse(


+ 6
- 2
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt View File

@@ -21,7 +21,8 @@ import java.util.concurrent.atomic.AtomicBoolean
* [JobOrder.planStart] fell on the previous calendar day. * [JobOrder.planStart] fell on the previous calendar day.
* *
* - Branch A: no pick submitted, product process pending → hide job order. * - Branch A: no pick submitted, product process pending → hide job order.
* - Branch B: pick submitted, product process still pending → reschedule to today 00:00:00 and renumber.
* - Branch B (disabled): pick submitted, process pending — previously rescheduled + renumbered;
* now SKIP so unfinished JOs stay visible via Production Process list lookback instead.
*/ */
@Service @Service
open class JobOrderPlanStartAutoService( open class JobOrderPlanStartAutoService(
@@ -47,6 +48,7 @@ open class JobOrderPlanStartAutoService(
val errors: Int = 0, val errors: Int = 0,
) )


/** FP-MTMS Version Checklist | Functions Ref. No. 42 | v1.0.0 | 2026-08-03 */
open fun runAutoProcess(runAt: LocalDateTime = LocalDateTime.now()): JobOrderPlanStartAutoReport { open fun runAutoProcess(runAt: LocalDateTime = LocalDateTime.now()): JobOrderPlanStartAutoReport {
if (!inFlight.compareAndSet(false, true)) { if (!inFlight.compareAndSet(false, true)) {
logger.warn("Job order plan-start auto process skipped: previous run still in flight") logger.warn("Job order plan-start auto process skipped: previous run still in flight")
@@ -147,6 +149,7 @@ open class JobOrderPlanStartAutoService(
SKIP, SKIP,
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 42 | v1.0.0 | 2026-08-03 */
private fun classify( private fun classify(
jo: JobOrder, jo: JobOrder,
pickOrders: List<PickOrder>, pickOrders: List<PickOrder>,
@@ -158,7 +161,8 @@ open class JobOrderPlanStartAutoService(
val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0 val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0
return when { return when {
maxSubmittedLines == 0 -> Branch.HIDE maxSubmittedLines == 0 -> Branch.HIDE
maxSubmittedLines > 0 -> Branch.RESCHEDULE
// Renumber / planStart roll-forward disabled — Production Process list carry-over covers visibility.
maxSubmittedLines > 0 -> Branch.SKIP
else -> Branch.SKIP else -> Branch.SKIP
} }
} }


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt View File

@@ -49,7 +49,10 @@ data class AllJoPickOrderResponse(
val jobOrderType: String?, val jobOrderType: String?,
val itemId: Long, val itemId: Long,
val itemName: String, val itemName: String,
val itemCode: String? = null,
val bomDescription: String?, val bomDescription: String?,
/** BOM.type (e.g. drink / Powder_Mixture / other). */
val bomType: String? = null,
val reqQty: BigDecimal, val reqQty: BigDecimal,
val uomId: Long, val uomId: Long,
val uomName: String, val uomName: String,


+ 50
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt View File

@@ -254,4 +254,54 @@ fun findAllReleasedJoWorkbenchPickOrders(
nativeQuery = true, nativeQuery = true,
) )
fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int

/**
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03
* JO ids that have at least one stock_out_line counted as picked
* (checked / partially_completed / completed / picking, or rejected with qty > 0).
*/
@Query(
value = """
SELECT DISTINCT po.joId
FROM pick_order po
INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0
INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0
WHERE po.deleted = 0
AND po.joId IN (:joIds)
AND (
LOWER(COALESCE(sol.status, '')) IN ('checked', 'partially_completed', 'completed', 'picking')
OR (LOWER(COALESCE(sol.status, '')) = 'rejected' AND COALESCE(sol.qty, 0) > 0)
)
""",
nativeQuery = true,
)
fun findPickedJobOrderIds(@Param("joIds") joIds: Collection<Long>): List<Long>

/** Unlimited: picked + product process still pending / not started. */
@Query(
value = """
SELECT DISTINCT po.joId
FROM pick_order po
INNER JOIN job_order jo
ON jo.id = po.joId
AND IFNULL(jo.deleted, 0) = 0
AND IFNULL(jo.isHidden, 0) = 0
AND LOWER(IFNULL(jo.status, '')) NOT IN ('completed', 'planning')
INNER JOIN productprocess pp
ON pp.jobOrderId = jo.id
AND IFNULL(pp.deleted, 0) = 0
AND LOWER(pp.status) = 'pending'
AND pp.startTime IS NULL
INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0
INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0
WHERE po.deleted = 0
AND po.joId IS NOT NULL
AND (
LOWER(COALESCE(sol.status, '')) IN ('checked', 'partially_completed', 'completed', 'picking')
OR (LOWER(COALESCE(sol.status, '')) = 'rejected' AND COALESCE(sol.qty, 0) > 0)
)
""",
nativeQuery = true,
)
fun findPickedNotStartedJobOrderIds(): List<Long>
} }

+ 8
- 4
src/main/java/com/ffii/fpsms/modules/pickOrder/service/HierarchicalFgPayloadAssembler.kt View File

@@ -5,6 +5,7 @@ import java.util.Locale
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03
* Shared hierarchical FG payload (fgInfo + merged pickOrders with lines/lots) for both * Shared hierarchical FG payload (fgInfo + merged pickOrders with lines/lots) for both
* legacy [do_pick_order_line] and workbench [delivery_order_pick_order] flows. * legacy [do_pick_order_line] and workbench [delivery_order_pick_order] flows.
*/ */
@@ -60,7 +61,8 @@ fun assembleHierarchicalFgPayload(
AND pol.deleted = false AND pol.deleted = false
), ),
ll AS ( ll AS (
SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId
SELECT spl.pickOrderLineId,
COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId
FROM fpsmsdb.suggested_pick_lot spl FROM fpsmsdb.suggested_pick_lot spl
JOIN target_pol tp ON tp.id = spl.pickOrderLineId JOIN target_pol tp ON tp.id = spl.pickOrderLineId
WHERE spl.deleted = false WHERE spl.deleted = false
@@ -73,11 +75,13 @@ ll AS (
WHERE sol.deleted = false WHERE sol.deleted = false
), ),
sm AS ( sm AS (
SELECT s.pickOrderLineId, s.suggestedLotLineId, MAX(s.id) AS maxId
SELECT s.pickOrderLineId,
COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) AS displayLotLineId,
MAX(s.id) AS maxId
FROM fpsmsdb.suggested_pick_lot s FROM fpsmsdb.suggested_pick_lot s
JOIN target_pol tp ON tp.id = s.pickOrderLineId JOIN target_pol tp ON tp.id = s.pickOrderLineId
WHERE s.deleted = false WHERE s.deleted = false
GROUP BY s.pickOrderLineId, s.suggestedLotLineId
GROUP BY s.pickOrderLineId, COALESCE(s.actualPickLotLineId, s.suggestedLotLineId)
) )
SELECT SELECT
po.id as pickOrderId, po.id as pickOrderId,
@@ -150,7 +154,7 @@ LEFT JOIN ll ON ll.pickOrderLineId = pol.id


LEFT JOIN sm LEFT JOIN sm
ON sm.pickOrderLineId = pol.id ON sm.pickOrderLineId = pol.id
AND sm.suggestedLotLineId <=> ll.lotLineId
AND sm.displayLotLineId <=> ll.lotLineId
LEFT JOIN fpsmsdb.suggested_pick_lot spl LEFT JOIN fpsmsdb.suggested_pick_lot spl
ON spl.id = sm.maxId ON spl.id = sm.maxId
AND spl.deleted = false AND spl.deleted = false


+ 31
- 6
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt View File

@@ -45,6 +45,7 @@ import com.ffii.fpsms.modules.common.CodeGenerator
import com.ffii.fpsms.modules.stock.web.model.BatchStockOutResult import com.ffii.fpsms.modules.stock.web.model.BatchStockOutResult
import com.ffii.fpsms.modules.stock.entity.StockLedger import com.ffii.fpsms.modules.stock.entity.StockLedger
import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository
import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot
import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus
import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository
@@ -2369,6 +2370,7 @@ private fun updateInventoryAfterLotLineChange(lotLine: InventoryLotLine) {
e.printStackTrace() e.printStackTrace()
} }
} }
/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */
private fun createStockLedgerForStockOut( private fun createStockLedgerForStockOut(
stockOutLine: StockOutLine, stockOutLine: StockOutLine,
ledgerType: String? = null, ledgerType: String? = null,
@@ -2394,6 +2396,9 @@ private fun createStockLedgerForStockOut(
val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return
val ill = stockOutLine.inventoryLotLine
val outQtyBd = BigDecimal.valueOf(outQty)
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val stockLedger = StockLedger().apply { val stockLedger = StockLedger().apply {
this.stockOutLine = stockOutLine this.stockOutLine = stockOutLine
this.inventory = inventory this.inventory = inventory
@@ -2403,10 +2408,19 @@ private fun createStockLedgerForStockOut(
this.type = ledgerTypeToUse this.type = ledgerTypeToUse
this.itemId = item.id this.itemId = item.id
this.itemCode = item.code this.itemCode = item.code
this.uomId = inventory.uom?.id
?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
inventory.uom?.id,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id,
)
this.date = LocalDate.now() this.date = LocalDate.now()
} }
StockLedgerLotSnapshot.apply(
ledger = stockLedger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = outQtyBd.negate(),
)
stockLedgerRepository.saveAndFlush(stockLedger) stockLedgerRepository.saveAndFlush(stockLedger)
@@ -2417,8 +2431,6 @@ private fun createStockLedgerForStockOut(
println("===========================") println("===========================")
} }




@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class]) @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = [Exception::class])
private fun handleBadItemPackageProblem(request: PickExecutionIssueRequest, badItemQty: BigDecimal) { private fun handleBadItemPackageProblem(request: PickExecutionIssueRequest, badItemQty: BigDecimal) {
println("=== HANDLING BAD ITEM PACKAGE PROBLEM (DON'T REJECT LOT) ===") println("=== HANDLING BAD ITEM PACKAGE PROBLEM (DON'T REJECT LOT) ===")
@@ -2863,6 +2875,7 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse
} }


// New: Create stock ledger with explicit balance (to avoid double subtraction) // New: Create stock ledger with explicit balance (to avoid double subtraction)
/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */
private fun createStockLedgerForStockOutWithBalance( private fun createStockLedgerForStockOutWithBalance(
stockOutLine: StockOutLine, stockOutLine: StockOutLine,
balance: Double, balance: Double,
@@ -2880,6 +2893,9 @@ private fun createStockLedgerForStockOutWithBalance(
val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return
val ill = stockOutLine.inventoryLotLine
val outQtyBd = BigDecimal.valueOf(outQty)
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val stockLedger = StockLedger().apply { val stockLedger = StockLedger().apply {
this.stockOutLine = stockOutLine this.stockOutLine = stockOutLine
this.inventory = inventory this.inventory = inventory
@@ -2889,10 +2905,19 @@ private fun createStockLedgerForStockOutWithBalance(
this.type = ledgerTypeToUse this.type = ledgerTypeToUse
this.itemId = item.id this.itemId = item.id
this.itemCode = item.code this.itemCode = item.code
this.uomId = inventory.uom?.id
?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
inventory.uom?.id,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id,
)
this.date = LocalDate.now() this.date = LocalDate.now()
} }
StockLedgerLotSnapshot.apply(
ledger = stockLedger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = outQtyBd.negate(),
)
stockLedgerRepository.saveAndFlush(stockLedger) stockLedgerRepository.saveAndFlush(stockLedger)


+ 13
- 4
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt View File

@@ -3804,8 +3804,10 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A
LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId


LEFT JOIN ( LEFT JOIN (
SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId
SELECT spl.pickOrderLineId,
COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId
FROM fpsmsdb.suggested_pick_lot spl FROM fpsmsdb.suggested_pick_lot spl
WHERE spl.deleted = false
UNION UNION
SELECT sol.pickOrderLineId, sol.inventoryLotLineId SELECT sol.pickOrderLineId, sol.inventoryLotLineId
FROM fpsmsdb.stock_out_line sol FROM fpsmsdb.stock_out_line sol
@@ -3813,7 +3815,9 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A
) ll ON ll.pickOrderLineId = pol.id ) ll ON ll.pickOrderLineId = pol.id


LEFT JOIN fpsmsdb.suggested_pick_lot spl LEFT JOIN fpsmsdb.suggested_pick_lot spl
ON spl.pickOrderLineId = pol.id AND spl.suggestedLotLineId = ll.lotLineId
ON spl.pickOrderLineId = pol.id
AND spl.deleted = false
AND COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) <=> ll.lotLineId


-- lots 用(保留原逻辑) -- lots 用(保留原逻辑)
LEFT JOIN fpsmsdb.stock_out_line sol LEFT JOIN fpsmsdb.stock_out_line sol
@@ -4018,6 +4022,7 @@ println("DEBUG sol polIds in linesResults: " + linesResults.mapNotNull { it["sto
) )
} }
*/ */
/** FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03 */
open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, Any?> { open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, Any?> {
val user = userService.find(userId).orElse(null) val user = userService.find(userId).orElse(null)
if (user == null) { if (user == null) {
@@ -4162,15 +4167,19 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A
JOIN fpsmsdb.items i ON i.id = pol.itemId JOIN fpsmsdb.items i ON i.id = pol.itemId
LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId LEFT JOIN fpsmsdb.uom_conversion uc ON uc.id = pol.uomId
LEFT JOIN ( LEFT JOIN (
SELECT spl.pickOrderLineId, spl.suggestedLotLineId AS lotLineId
SELECT spl.pickOrderLineId,
COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) AS lotLineId
FROM fpsmsdb.suggested_pick_lot spl FROM fpsmsdb.suggested_pick_lot spl
WHERE spl.deleted = false
UNION UNION
SELECT sol.pickOrderLineId, sol.inventoryLotLineId SELECT sol.pickOrderLineId, sol.inventoryLotLineId
FROM fpsmsdb.stock_out_line sol FROM fpsmsdb.stock_out_line sol
WHERE sol.deleted = false WHERE sol.deleted = false
) ll ON ll.pickOrderLineId = pol.id ) ll ON ll.pickOrderLineId = pol.id
LEFT JOIN fpsmsdb.suggested_pick_lot spl LEFT JOIN fpsmsdb.suggested_pick_lot spl
ON spl.pickOrderLineId = pol.id AND spl.suggestedLotLineId = ll.lotLineId
ON spl.pickOrderLineId = pol.id
AND spl.deleted = false
AND COALESCE(spl.actualPickLotLineId, spl.suggestedLotLineId) <=> ll.lotLineId
LEFT JOIN fpsmsdb.stock_out_line sol LEFT JOIN fpsmsdb.stock_out_line sol
ON sol.pickOrderLineId = pol.id ON sol.pickOrderLineId = pol.id
AND ( (sol.inventoryLotLineId = ll.lotLineId) AND ( (sol.inventoryLotLineId = ll.lotLineId)


+ 7
- 5
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderWorkbenchService.kt View File

@@ -145,6 +145,7 @@ open class PickOrderWorkbenchService(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03
* Consumable workbench hierarchical payload for Tab3. * Consumable workbench hierarchical payload for Tab3.
* Uses no-hold available qty semantics (inQty - outQty). * Uses no-hold available qty semantics (inQty - outQty).
*/ */
@@ -275,6 +276,7 @@ open class PickOrderWorkbenchService(


/** /**
* Workbench line detail V2 (consumable): * Workbench line detail V2 (consumable):
* FP-MTMS Version Checklist | Functions Ref. No. 45 | v1.0.0 | 2026-08-03
* - Returns only lot rows already linked by suggestion/stock_out_line for this pick order line. * - Returns only lot rows already linked by suggestion/stock_out_line for this pick order line.
* - Uses no-hold availability semantics: available = inQty - outQty. * - Uses no-hold availability semantics: available = inQty - outQty.
* - Includes no-lot stock_out_line rows. * - Includes no-lot stock_out_line rows.
@@ -306,14 +308,14 @@ open class PickOrderWorkbenchService(
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
s.suggestedLotLineId AS lotLineId,
COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) AS lotLineId,
MAX(s.id) AS suggestedPickLotId, MAX(s.id) AS suggestedPickLotId,
SUM(COALESCE(s.qty,0)) AS requiredQty SUM(COALESCE(s.qty,0)) AS requiredQty
FROM fpsmsdb.suggested_pick_lot s FROM fpsmsdb.suggested_pick_lot s
WHERE s.pickOrderLineId = :pickOrderLineId WHERE s.pickOrderLineId = :pickOrderLineId
AND s.deleted = false AND s.deleted = false
AND s.suggestedLotLineId IS NOT NULL
GROUP BY s.suggestedLotLineId
AND COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) IS NOT NULL
GROUP BY COALESCE(s.actualPickLotLineId, s.suggestedLotLineId)
) spl ON spl.lotLineId = ill.id ) spl ON spl.lotLineId = ill.id
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
@@ -328,11 +330,11 @@ open class PickOrderWorkbenchService(
GROUP BY so.inventoryLotLineId GROUP BY so.inventoryLotLineId
) sol ON sol.lotLineId = ill.id ) sol ON sol.lotLineId = ill.id
WHERE ill.id IN ( WHERE ill.id IN (
SELECT DISTINCT s.suggestedLotLineId
SELECT DISTINCT COALESCE(s.actualPickLotLineId, s.suggestedLotLineId)
FROM fpsmsdb.suggested_pick_lot s FROM fpsmsdb.suggested_pick_lot s
WHERE s.pickOrderLineId = :pickOrderLineId WHERE s.pickOrderLineId = :pickOrderLineId
AND s.deleted = false AND s.deleted = false
AND s.suggestedLotLineId IS NOT NULL
AND COALESCE(s.actualPickLotLineId, s.suggestedLotLineId) IS NOT NULL
UNION UNION
SELECT DISTINCT so.inventoryLotLineId SELECT DISTINCT so.inventoryLotLineId
FROM fpsmsdb.stock_out_line so FROM fpsmsdb.stock_out_line so


+ 68
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt View File

@@ -55,4 +55,72 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe
@Param("useBomIds") useBomIds: Boolean, @Param("useBomIds") useBomIds: Boolean,
@Param("bomIds") bomIds: Collection<Long>, @Param("bomIds") bomIds: Collection<Long>,
): List<JobOrderProcessAggregate> ): List<JobOrderProcessAggregate>

/**
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03
* Production list lookback: inclusive date range on ProductProcess.date.
*/
@Query(
"""
SELECT
p.jobOrder.id AS jobOrderId,
MAX(p.date) AS maxDate,
MIN(COALESCE(p.productionPriority, 2147483647)) AS minPriority
FROM ProductProcess p
WHERE p.deleted = false
AND p.jobOrder IS NOT NULL
AND COALESCE(p.jobOrder.isHidden, false) = false
AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.PLANNING
AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.COMPLETED
AND (:dateFrom IS NULL OR p.date >= :dateFrom)
AND (:dateTo IS NULL OR p.date <= :dateTo)
AND (:itemCode IS NULL OR LOWER(COALESCE(p.item.code, '')) LIKE LOWER(CONCAT('%', :itemCode, '%')))
AND (:jobOrderCode IS NULL OR LOWER(COALESCE(p.jobOrder.code, '')) LIKE LOWER(CONCAT('%', :jobOrderCode, '%')))
AND (:bomType IS NULL OR LOWER(COALESCE(p.bom.type, '')) = LOWER(:bomType))
AND (:useBomIds = false OR p.bom.id IN :bomIds)
AND (:excludeCompletedProcess = false OR p.status <> com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus.COMPLETED)
GROUP BY p.jobOrder.id
ORDER BY MAX(p.date) DESC, MIN(COALESCE(p.productionPriority, 2147483647)) ASC
"""
)
fun findCandidateJobOrderAggregatesByDateRange(
@Param("dateFrom") dateFrom: LocalDate?,
@Param("dateTo") dateTo: LocalDate?,
@Param("itemCode") itemCode: String?,
@Param("jobOrderCode") jobOrderCode: String?,
@Param("bomType") bomType: String?,
@Param("useBomIds") useBomIds: Boolean,
@Param("bomIds") bomIds: Collection<Long>,
@Param("excludeCompletedProcess") excludeCompletedProcess: Boolean,
): List<JobOrderProcessAggregate>

@Query(
"""
SELECT
p.jobOrder.id AS jobOrderId,
MAX(p.date) AS maxDate,
MIN(COALESCE(p.productionPriority, 2147483647)) AS minPriority
FROM ProductProcess p
WHERE p.deleted = false
AND p.jobOrder IS NOT NULL
AND p.jobOrder.id IN :jobOrderIds
AND COALESCE(p.jobOrder.isHidden, false) = false
AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.PLANNING
AND p.jobOrder.status <> com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus.COMPLETED
AND (:itemCode IS NULL OR LOWER(COALESCE(p.item.code, '')) LIKE LOWER(CONCAT('%', :itemCode, '%')))
AND (:jobOrderCode IS NULL OR LOWER(COALESCE(p.jobOrder.code, '')) LIKE LOWER(CONCAT('%', :jobOrderCode, '%')))
AND (:bomType IS NULL OR LOWER(COALESCE(p.bom.type, '')) = LOWER(:bomType))
AND (:useBomIds = false OR p.bom.id IN :bomIds)
GROUP BY p.jobOrder.id
"""
)
/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */
fun findCandidateJobOrderAggregatesByJobOrderIds(
@Param("jobOrderIds") jobOrderIds: Collection<Long>,
@Param("itemCode") itemCode: String?,
@Param("jobOrderCode") jobOrderCode: String?,
@Param("bomType") bomType: String?,
@Param("useBomIds") useBomIds: Boolean,
@Param("bomIds") bomIds: Collection<Long>,
): List<JobOrderProcessAggregate>
} }

+ 183
- 19
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt View File

@@ -1460,6 +1460,7 @@ open class ProductProcessService(
itemName = productProcesses.item?.name, itemName = productProcesses.item?.name,
itemCode = productProcesses.item?.code, itemCode = productProcesses.item?.code,
bomDescription = productProcesses.bom?.description, bomDescription = productProcesses.bom?.description,
bomType = productProcesses.bom?.type,
pickOrderId = pickOrder?.id, pickOrderId = pickOrder?.id,
pickOrderStatus = pickOrder?.status?.value, pickOrderStatus = pickOrder?.status?.value,
jobOrderId = productProcesses.jobOrder?.id, jobOrderId = productProcesses.jobOrder?.id,
@@ -1497,6 +1498,7 @@ open class ProductProcessService(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03
* QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。
* *
* 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。
@@ -1511,7 +1513,9 @@ open class ProductProcessService(
includePutaway: Boolean?, includePutaway: Boolean?,
putawayStatus: String?, putawayStatus: String?,
page: Int, page: Int,
size: Int
size: Int,
lookbackDays: Int? = null,
bucket: String? = null,
): JobOrderProductProcessPageResponse { ): JobOrderProductProcessPageResponse {
val safeSize = if (size <= 0) 50 else size val safeSize = if (size <= 0) 50 else size
val safePage = if (page < 0) 0 else page val safePage = if (page < 0) 0 else page
@@ -1522,16 +1526,82 @@ open class ProductProcessService(
val normalizedBomIds = bomIds?.distinct().orEmpty() val normalizedBomIds = bomIds?.distinct().orEmpty()
val useBomIds = normalizedBomIds.isNotEmpty() val useBomIds = normalizedBomIds.isNotEmpty()
val bomIdsForQuery = if (useBomIds) normalizedBomIds else listOf(-1L) val bomIdsForQuery = if (useBomIds) normalizedBomIds else listOf(-1L)
val normalizedBucket = bucket?.trim()?.lowercase()?.takeIf { it.isNotBlank() && it != "all" }
val useLookback = lookbackDays != null && lookbackDays >= 0 && date != null
/** Production list: unfinished process tabs + picked-not-started (capped at search date). */
val useProductionLookback = useLookback && qcReady != true
/** Waiting QC putaway: date lookback only (completed process OK), no pick buckets. */
val usePutawayLookback = useLookback && qcReady == true


// 1) DB 端先完成候選 jobOrder 篩選 + 分組排序,避免全表拉回 JVM // 1) DB 端先完成候選 jobOrder 篩選 + 分組排序,避免全表拉回 JVM
val candidateAggregates = productProcessRepository.findCandidateJobOrderAggregates(
date = date,
itemCode = trimmedItemCode,
jobOrderCode = trimmedJobOrderCode,
bomType = normalizedType,
useBomIds = useBomIds,
bomIds = bomIdsForQuery,
)
val candidateAggregates = if (useProductionLookback) {
val dateFrom = date!!.minusDays(lookbackDays!!.toLong())
val rangeAggs = productProcessRepository.findCandidateJobOrderAggregatesByDateRange(
dateFrom = dateFrom,
dateTo = date,
itemCode = trimmedItemCode,
jobOrderCode = trimmedJobOrderCode,
bomType = normalizedType,
useBomIds = useBomIds,
bomIds = bomIdsForQuery,
excludeCompletedProcess = true,
)
// Picked-not-started: still merge outside lookback window, but never after search date
// (avoids future plan dates appearing when searching a past day).
val unlimitedPickedNotStartedIds = pickOrderRepository.findPickedNotStartedJobOrderIds()
val extraAggs = if (unlimitedPickedNotStartedIds.isEmpty()) {
emptyList()
} else {
productProcessRepository.findCandidateJobOrderAggregatesByJobOrderIds(
jobOrderIds = unlimitedPickedNotStartedIds,
itemCode = trimmedItemCode,
jobOrderCode = trimmedJobOrderCode,
bomType = normalizedType,
useBomIds = useBomIds,
bomIds = bomIdsForQuery,
).filter { agg ->
val maxDate = agg.maxDate
maxDate == null || !maxDate.isAfter(date)
}
}
val byId = LinkedHashMap<Long, JobOrderProcessAggregate>()
(rangeAggs + extraAggs).forEach { agg ->
val existing = byId[agg.jobOrderId]
if (existing == null) {
byId[agg.jobOrderId] = agg
} else {
val maxDate = listOfNotNull(existing.maxDate, agg.maxDate).maxOrNull()
val minPriority = listOfNotNull(existing.minPriority, agg.minPriority).minOrNull()
byId[agg.jobOrderId] = object : JobOrderProcessAggregate {
override val jobOrderId = agg.jobOrderId
override val maxDate = maxDate
override val minPriority = minPriority
}
}
}
byId.values.toList()
} else if (usePutawayLookback) {
val dateFrom = date!!.minusDays(lookbackDays!!.toLong())
productProcessRepository.findCandidateJobOrderAggregatesByDateRange(
dateFrom = dateFrom,
dateTo = date,
itemCode = trimmedItemCode,
jobOrderCode = trimmedJobOrderCode,
bomType = normalizedType,
useBomIds = useBomIds,
bomIds = bomIdsForQuery,
excludeCompletedProcess = false,
)
} else {
productProcessRepository.findCandidateJobOrderAggregates(
date = date,
itemCode = trimmedItemCode,
jobOrderCode = trimmedJobOrderCode,
bomType = normalizedType,
useBomIds = useBomIds,
bomIds = bomIdsForQuery,
)
}
val candidateJobOrderIds = candidateAggregates.map { it.jobOrderId } val candidateJobOrderIds = candidateAggregates.map { it.jobOrderId }


if (candidateJobOrderIds.isEmpty()) { if (candidateJobOrderIds.isEmpty()) {
@@ -1539,7 +1609,10 @@ open class ProductProcessService(
content = emptyList(), content = emptyList(),
totalJobOrders = 0L, totalJobOrders = 0L,
page = safePage, page = safePage,
size = safeSize
size = safeSize,
bucketCounts = if (useProductionLookback) JobOrderProductProcessBucketCounts() else null,
searchDate = date?.toString(),
carriedOverCount = if (useLookback) 0L else null,
) )
} }


@@ -1625,19 +1698,104 @@ open class ProductProcessService(
} }


val filteredQcKeys = qcKeys.filter { key -> qcReady == null || key.ready == qcReady } val filteredQcKeys = qcKeys.filter { key -> qcReady == null || key.ready == qcReady }
val totalJobOrders = filteredQcKeys.size.toLong()


val sortedQcKeys = filteredQcKeys.sortedWith(
compareByDescending<JobOrderQcKey> { it.maxDate }.thenBy { it.minPriority }
val processByJobOrderIdForBucket = if (useProductionLookback) {
productProcessRepository
.findByJobOrder_IdInAndDeletedIsFalse(filteredQcKeys.map { it.jobOrderId })
.groupBy { it.jobOrder?.id }
.mapNotNull { (joId, list) ->
if (joId == null) null
else joId to (list.firstOrNull { it.status != ProductProcessStatus.COMPLETED } ?: list.first())
}
.toMap()
} else emptyMap()

val pickedJoIds = if (useProductionLookback && filteredQcKeys.isNotEmpty()) {
pickOrderRepository.findPickedJobOrderIds(filteredQcKeys.map { it.jobOrderId }).toSet()
} else emptySet()

fun classifyBucket(jobOrderId: Long): String? {
val pp = processByJobOrderIdForBucket[jobOrderId] ?: return null
val status = pp.status
if (status == ProductProcessStatus.COMPLETED) return null
val started =
status == ProductProcessStatus.IN_PROGRESS ||
status == ProductProcessStatus.STOPPED ||
pp.startTime != null
val notStarted = status == ProductProcessStatus.PENDING && pp.startTime == null
val picked = pickedJoIds.contains(jobOrderId)
return when {
notStarted && !picked -> "not_picked_not_started"
notStarted && picked -> "picked_not_started"
started && picked -> "picked_started"
started && !picked -> "not_picked_started"
else -> null
}
}

val keysWithBucket = if (useProductionLookback) {
filteredQcKeys.mapNotNull { key ->
val b = classifyBucket(key.jobOrderId) ?: return@mapNotNull null
key to b
}
} else {
filteredQcKeys.map { it to null as String? }
}

val bucketCounts = if (useProductionLookback) {
JobOrderProductProcessBucketCounts(
notPickedNotStarted = keysWithBucket.count { it.second == "not_picked_not_started" }.toLong(),
pickedNotStarted = keysWithBucket.count { it.second == "picked_not_started" }.toLong(),
pickedStarted = keysWithBucket.count { it.second == "picked_started" }.toLong(),
notPickedStarted = keysWithBucket.count { it.second == "not_picked_started" }.toLong(),
)
} else null

fun matchesMergedBucket(fine: String?, merged: String): Boolean {
return when (merged) {
"pending" ->
fine == "not_picked_not_started" || fine == "picked_not_started"
"processing" ->
fine == "picked_started" || fine == "not_picked_started"
else -> fine == merged
}
}

val afterBucketFilter = if (normalizedBucket != null && useProductionLookback) {
keysWithBucket.filter { matchesMergedBucket(it.second, normalizedBucket) }
} else {
keysWithBucket
}

val totalJobOrders = afterBucketFilter.size.toLong()
val carriedOverCount = if (useLookback && date != null) {
afterBucketFilter.count { (key, _) ->
val d = key.maxDate
d != null && d.isBefore(date)
}.toLong()
} else null

val sortedPairs = afterBucketFilter.sortedWith(
compareByDescending<Pair<JobOrderQcKey, String?>> { it.first.maxDate }.thenBy { it.first.minPriority }
) )


val from = safePage * safeSize val from = safePage * safeSize
if (from >= sortedQcKeys.size) {
return JobOrderProductProcessPageResponse(emptyList(), totalJobOrders, safePage, safeSize)
if (from >= sortedPairs.size) {
return JobOrderProductProcessPageResponse(
content = emptyList(),
totalJobOrders = totalJobOrders,
page = safePage,
size = safeSize,
bucketCounts = bucketCounts,
searchDate = date?.toString(),
carriedOverCount = carriedOverCount,
)
} }


val to = minOf(from + safeSize, sortedQcKeys.size)
val pagedJobOrderIds = sortedQcKeys.subList(from, to).map { it.jobOrderId }
val to = minOf(from + safeSize, sortedPairs.size)
val pagedPairs = sortedPairs.subList(from, to)
val pagedJobOrderIds = pagedPairs.map { it.first.jobOrderId }
val bucketByJobOrderId = pagedPairs.associate { it.first.jobOrderId to it.second }
val pagedJobOrderIdSet = pagedJobOrderIds.toSet() val pagedJobOrderIdSet = pagedJobOrderIds.toSet()


// 4) 只對當前頁資料做批量關聯查詢,並且排序/資料縮小盡量下推 DB // 4) 只對當前頁資料做批量關聯查詢,並且排序/資料縮小盡量下推 DB
@@ -1756,6 +1914,7 @@ open class ProductProcessService(
itemName = productProcess.item?.name, itemName = productProcess.item?.name,
itemCode = productProcess.item?.code, itemCode = productProcess.item?.code,
bomDescription = productProcess.bom?.description, bomDescription = productProcess.bom?.description,
bomType = productProcess.bom?.type,
pickOrderId = pickOrder?.id, pickOrderId = pickOrder?.id,
pickOrderStatus = pickOrder?.status?.value, pickOrderStatus = pickOrder?.status?.value,
jobOrderId = productProcess.jobOrder?.id, jobOrderId = productProcess.jobOrder?.id,
@@ -1775,7 +1934,9 @@ open class ProductProcessService(
endTime = line.endTime, endTime = line.endTime,
status = line.status ?: "" status = line.status ?: ""
) )
}
},
isPicked = if (useProductionLookback) pickedJoIds.contains(jobOrderId) else null,
pickProcessBucket = if (useProductionLookback) bucketByJobOrderId[jobOrderId] else null,
) )
} }


@@ -1783,7 +1944,10 @@ open class ProductProcessService(
content = content, content = content,
totalJobOrders = totalJobOrders, totalJobOrders = totalJobOrders,
page = safePage, page = safePage,
size = safeSize
size = safeSize,
bucketCounts = bucketCounts,
searchDate = date?.toString(),
carriedOverCount = carriedOverCount,
) )
} }




+ 12
- 1
src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt View File

@@ -200,6 +200,7 @@ class ProductProcessController(
return productProcessService.getAllJoborderProductProcessInfo(bomType) return productProcessService.getAllJoborderProductProcessInfo(bomType)
} }


/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */
@GetMapping("/Demo/Process/search") @GetMapping("/Demo/Process/search")
fun demoprocesssearch( fun demoprocesssearch(
@RequestParam(required = false) date: String?, @RequestParam(required = false) date: String?,
@@ -211,6 +212,14 @@ class ProductProcessController(
/** all | completed | notCompleted */ /** all | completed | notCompleted */
@RequestParam(required = false) putawayStatus: String?, @RequestParam(required = false) putawayStatus: String?,
@RequestParam(name = "type", required = false) bomType: String?, @RequestParam(name = "type", required = false) bomType: String?,
/** Production list: unfinished from (date - lookbackDays) .. date; picked_not_started merged but capped at date. */
@RequestParam(required = false) lookbackDays: Int?,
/**
* all | pending | processing
* (pending = not started; processing = started; also accepts fine-grained pick buckets)
* Only applied when lookbackDays is set.
*/
@RequestParam(required = false) bucket: String?,
@RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "50") size: Int @RequestParam(defaultValue = "50") size: Int
): JobOrderProductProcessPageResponse { ): JobOrderProductProcessPageResponse {
@@ -233,7 +242,9 @@ class ProductProcessController(
putawayStatus = putawayStatus, putawayStatus = putawayStatus,
bomType = bomType, bomType = bomType,
page = page, page = page,
size = size
size = size,
lookbackDays = lookbackDays,
bucket = bucket,
) )
} }




+ 22
- 2
src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt View File

@@ -170,6 +170,8 @@ data class AllJoborderProductProcessInfoResponse(
val itemName: String?, val itemName: String?,
val itemCode: String?, val itemCode: String?,
val bomDescription: String?, val bomDescription: String?,
/** BOM.type (e.g. drink / Powder_Mixture / other). */
val bomType: String? = null,
val matchStatus: String?, val matchStatus: String?,
val RequiredQty: Int?, val RequiredQty: Int?,
val Uom: String?, val Uom: String?,
@@ -187,14 +189,32 @@ data class AllJoborderProductProcessInfoResponse(
val stockInLineStatus: String?, val stockInLineStatus: String?,
val TimeNeedToComplete: Int?, val TimeNeedToComplete: Int?,
val isDrink: Boolean?, val isDrink: Boolean?,
val lines: List<ProductProcessInfoResponse>
val lines: List<ProductProcessInfoResponse>,
/** True when JO pick has at least one counted stock_out_line progress. */
val isPicked: Boolean? = null,
/**
* Fine-grained pick/process bucket:
* not_picked_not_started | picked_not_started | picked_started | not_picked_started
*/
val pickProcessBucket: String? = null,
)

data class JobOrderProductProcessBucketCounts(
val notPickedNotStarted: Long = 0,
val pickedNotStarted: Long = 0,
val pickedStarted: Long = 0,
val notPickedStarted: Long = 0,
) )


data class JobOrderProductProcessPageResponse( data class JobOrderProductProcessPageResponse(
val content: List<AllJoborderProductProcessInfoResponse>, val content: List<AllJoborderProductProcessInfoResponse>,
val totalJobOrders: Long, val totalJobOrders: Long,
val page: Int, val page: Int,
val size: Int
val size: Int,
val bucketCounts: JobOrderProductProcessBucketCounts? = null,
/** Search date echoed for carry-over UI (YYYY-MM-dd). */
val searchDate: String? = null,
val carriedOverCount: Long? = null,
) )


/** /**


+ 204
- 0
src/main/java/com/ffii/fpsms/modules/report/service/DoUserPickAuditReportService.kt View File

@@ -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() }
}
}

+ 281
- 0
src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt View File

@@ -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
}
}

+ 140
- 0
src/main/java/com/ffii/fpsms/modules/report/web/DoUserPickAuditReportController.kt View File

@@ -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()
}
}

+ 28
- 0
src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt View File

@@ -24,12 +24,14 @@ import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.common.SecurityUtils import com.ffii.fpsms.modules.common.SecurityUtils
import com.ffii.fpsms.modules.report.service.M18BomShopSyncReportService import com.ffii.fpsms.modules.report.service.M18BomShopSyncReportService
import com.ffii.fpsms.modules.report.service.ReportService import com.ffii.fpsms.modules.report.service.ReportService
import com.ffii.fpsms.modules.report.service.ShopOrderReplenishmentReportService


@RestController @RestController
@RequestMapping("/report") @RequestMapping("/report")
class ReportController( class ReportController(
private val reportService: ReportService, private val reportService: ReportService,
private val m18BomShopSyncReportService: M18BomShopSyncReportService, private val m18BomShopSyncReportService: M18BomShopSyncReportService,
private val shopOrderReplenishmentReportService: ShopOrderReplenishmentReportService,
) { ) {
private data class ExcelStyles( private data class ExcelStyles(
val title: XSSFCellStyle, val title: XSSFCellStyle,
@@ -1001,6 +1003,32 @@ class ReportController(
syncStatus = syncStatus, syncStatus = syncStatus,
) )


/**
* Shop Orders Replenishment Records (店鋪訂單補貨記錄).
* JSON for Excel export: completed replenishments with linked ticket only.
*
* Example: `/report/shop-order-replenishment?reorderDateStart=2026-08-01&shopCode=S001`
*/
@GetMapping("/shop-order-replenishment")
fun getShopOrderReplenishmentReport(
@RequestParam(required = false) reorderDateStart: String?,
@RequestParam(required = false) reorderDateEnd: String?,
@RequestParam(required = false) shopOrderDateStart: String?,
@RequestParam(required = false) shopOrderDateEnd: String?,
@RequestParam(required = false) deliveredDateStart: String?,
@RequestParam(required = false) deliveredDateEnd: String?,
@RequestParam(required = false) shopCode: String?,
): Map<String, Any> =
shopOrderReplenishmentReportService.searchShopOrderReplenishmentReport(
reorderDateStart = reorderDateStart,
reorderDateEnd = reorderDateEnd,
shopOrderDateStart = shopOrderDateStart,
shopOrderDateEnd = shopOrderDateEnd,
deliveredDateStart = deliveredDateStart,
deliveredDateEnd = deliveredDateEnd,
shopCode = shopCode,
)

companion object { companion object {
/** GRN report fields only users with ADMIN authority may see */ /** GRN report fields only users with ADMIN authority may see */
private val GRN_FINANCIAL_KEYS = setOf("unitPrice", "lineAmount", "currencyCode") private val GRN_FINANCIAL_KEYS = setOf("unitPrice", "lineAmount", "currencyCode")


+ 16
- 1
src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedger.kt View File

@@ -4,9 +4,11 @@ import com.ffii.core.entity.BaseEntity
import jakarta.persistence.Column import jakarta.persistence.Column
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.JoinColumn import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToOne import jakarta.persistence.OneToOne
import jakarta.persistence.Table import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.NotNull
import java.math.BigDecimal
import java.time.LocalDate import java.time.LocalDate
@Entity @Entity
@Table(name = "stock_ledger") @Table(name = "stock_ledger")
@@ -19,6 +21,11 @@ open class StockLedger: BaseEntity<Long>() {
@JoinColumn(name = "stockOutLineId") @JoinColumn(name = "stockOutLineId")
open var stockOutLine: StockOutLine? = null open var stockOutLine: StockOutLine? = null


/** Lot line for this movement; prefer this over resolving via stock in/out line. */
@ManyToOne
@JoinColumn(name = "inventoryLotLineId")
open var inventoryLotLine: InventoryLotLine? = null

@NotNull @NotNull
@OneToOne @OneToOne
@JoinColumn(name = "inventoryId") @JoinColumn(name = "inventoryId")
@@ -33,7 +40,15 @@ open class StockLedger: BaseEntity<Long>() {
@Column(name = "balance") @Column(name = "balance")
open var balance: Double? = null open var balance: Double? = null


/** 物料庫存單位對應之 uom_conversion.id(與 item_uom / inventory 之 stock UOM 一致) */
/** Lot available qty (in−out) before this movement; null for pre-V2 rows. */
@Column(name = "lotQtyBefore", precision = 14, scale = 2)
open var lotQtyBefore: BigDecimal? = null

/** Lot available qty (in−out) after this movement; null for pre-V2 rows. */
@Column(name = "lotQtyAfter", precision = 14, scale = 2)
open var lotQtyAfter: BigDecimal? = null

/** Lot line stock UOM → uom.id(優先);無 lot 時才 fallback item/inventory stock UOM */
@Column(name = "uomId") @Column(name = "uomId")
open var uomId: Long? = null open var uomId: Long? = null




+ 7
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutLine.kt View File

@@ -41,6 +41,13 @@ open class StockOutLine: BaseEntity<Long>() {
@Column(name = "handled_by") @Column(name = "handled_by")
open var handledBy: Long? = null open var handledBy: Long? = null


/**
* DO user-audit: Just Complete that should be reported because pickable stock still existed
* for the item at complete time. Null/false = not an issue JC (e.g. lot exhausted remainder).
*/
@Column(name = "isIssueJustComplete")
open var isIssueJustComplete: Boolean? = null

@JsonBackReference @JsonBackReference
@ManyToOne @ManyToOne
@JoinColumn(name = "pickOrderLineId") @JoinColumn(name = "pickOrderLineId")


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestedPickLot.kt View File

@@ -29,6 +29,11 @@ open class SuggestedPickLot: BaseEntity<Long>() {
@JoinColumn(name = "suggestedLotLineId") @JoinColumn(name = "suggestedLotLineId")
open var suggestedLotLine: InventoryLotLine? = null open var suggestedLotLine: InventoryLotLine? = null


/** Actual picked lot line; switch/scan updates this without changing [suggestedLotLine]. */
@ManyToOne
@JoinColumn(name = "actualPickLotLineId")
open var actualPickLotLine: InventoryLotLine? = null

@JsonBackReference @JsonBackReference
@ManyToOne @ManyToOne
@JoinColumn(name = "pickOrderLineId") @JoinColumn(name = "pickOrderLineId")


+ 14
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt View File

@@ -1378,6 +1378,7 @@ open class StockInLineService(
} }
@Transactional @Transactional


/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */
private fun createStockLedgerForStockIn(stockInLine: StockInLine, inQty: Double) { private fun createStockLedgerForStockIn(stockInLine: StockInLine, inQty: Double) {
val _t0 = System.nanoTime() val _t0 = System.nanoTime()
fun _msSince(t: Long): Double = (System.nanoTime() - t) / 1_000_000.0 fun _msSince(t: Long): Double = (System.nanoTime() - t) / 1_000_000.0
@@ -1403,7 +1404,11 @@ open class StockInLineService(
val newBalance = previousBalance + inQty val newBalance = previousBalance + inQty


val _tUom = System.nanoTime() val _tUom = System.nanoTime()
val stockUomId = itemUomService.findStockUnitByItemId(item.id!!)?.uom?.id ?: inventory.uom?.id
val stockUomId = StockLedgerLotSnapshot.resolveLedgerUomId(
stockInLine.inventoryLotLine,
itemUomService.findStockUnitByItemId(item.id!!)?.uom?.id,
inventory.uom?.id,
)
_logStep("itemUomService.findStockUnitByItemId", _tUom) _logStep("itemUomService.findStockUnitByItemId", _tUom)


val stockLedger = StockLedger().apply { val stockLedger = StockLedger().apply {
@@ -1418,6 +1423,14 @@ open class StockInLineService(
this.uomId = stockUomId this.uomId = stockUomId
this.date = LocalDate.now() this.date = LocalDate.now()
} }
val ill = stockInLine.inventoryLotLine
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
StockLedgerLotSnapshot.apply(
ledger = stockLedger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = BigDecimal.valueOf(inQty),
)


val _tSave = System.nanoTime() val _tSave = System.nanoTime()
stockLedgerRepository.saveAndFlush(stockLedger) stockLedgerRepository.saveAndFlush(stockLedger)


+ 50
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/StockLedgerLotSnapshot.kt View File

@@ -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 }
}
}

+ 58
- 9
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt View File

@@ -1509,6 +1509,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
} }
} }
@Transactional @Transactional
/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */
private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) { private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) {
val item = stockOutLine.item ?: return val item = stockOutLine.item ?: return
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return
@@ -1519,6 +1520,9 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val newBalance = previousBalance - outQty val newBalance = previousBalance - outQty


val ill = stockOutLine.inventoryLotLine
val outQtyBd = BigDecimal.valueOf(outQty)
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val stockLedger = StockLedger().apply { val stockLedger = StockLedger().apply {
this.stockOutLine = stockOutLine this.stockOutLine = stockOutLine
this.inventory = inventory this.inventory = inventory
@@ -1528,10 +1532,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
this.type = stockOutLine.type this.type = stockOutLine.type
this.itemId = item.id this.itemId = item.id
this.itemCode = item.code this.itemCode = item.code
this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id
?: inventory.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id,
inventory.uom?.id,
)
this.date = LocalDate.now() this.date = LocalDate.now()
} }
StockLedgerLotSnapshot.apply(
ledger = stockLedger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = outQtyBd.negate(),
)


stockLedgerRepository.saveAndFlush(stockLedger) stockLedgerRepository.saveAndFlush(stockLedger)
} }
@@ -1744,6 +1757,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
} }




/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03 */
private fun createStockLedgerForPickDelta( private fun createStockLedgerForPickDelta(
stockOutLine: StockOutLine, stockOutLine: StockOutLine,
deltaQty: BigDecimal, deltaQty: BigDecimal,
@@ -1791,6 +1805,8 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
} }
} }


val ill = stockOutLine.inventoryLotLine
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val ledger = StockLedger().apply { val ledger = StockLedger().apply {
this.stockOutLine = stockOutLine this.stockOutLine = stockOutLine
this.inventory = inventory this.inventory = inventory
@@ -1800,10 +1816,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
this.type = "NOR" this.type = "NOR"
this.itemId = item.id this.itemId = item.id
this.itemCode = item.code this.itemCode = item.code
this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id
?: inventory.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id,
inventory.uom?.id,
)
this.date = LocalDate.now() this.date = LocalDate.now()
} }
StockLedgerLotSnapshot.apply(
ledger = ledger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = deltaQty.negate(),
)


if (flushAfterSave) { if (flushAfterSave) {
stockLedgerRepository.saveAndFlush(ledger) stockLedgerRepository.saveAndFlush(ledger)
@@ -2197,6 +2222,9 @@ fun applyStockOutLineDelta(
val outQty = deltaQty.toDouble() val outQty = deltaQty.toDouble()
val newBalance = previousBalance - outQty val newBalance = previousBalance - outQty


val ill = savedSol.inventoryLotLine
val outQtyBd = BigDecimal.valueOf(outQty)
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val ledger = StockLedger().apply { val ledger = StockLedger().apply {
this.stockOutLine = savedSol this.stockOutLine = savedSol
this.inventory = inventory this.inventory = inventory
@@ -2207,14 +2235,23 @@ fun applyStockOutLineDelta(
this.itemId = item.id this.itemId = item.id
this.itemCode = item.code this.itemCode = item.code
this.uomId = this.uomId =
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id
?: inventory.uom?.id
StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id,
inventory.uom?.id,
)
this.date = eventTime.toLocalDate() this.date = eventTime.toLocalDate()
if (!operator.isNullOrBlank()) { if (!operator.isNullOrBlank()) {
this.createdBy = operator this.createdBy = operator
this.modifiedBy = operator this.modifiedBy = operator
} }
} }
StockLedgerLotSnapshot.apply(
ledger = ledger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = outQtyBd.negate(),
)
if (deferPersistenceFlush) stockLedgerRepository.save(ledger) if (deferPersistenceFlush) stockLedgerRepository.save(ledger)
else stockLedgerRepository.saveAndFlush(ledger) else stockLedgerRepository.saveAndFlush(ledger)
} }
@@ -2330,7 +2367,9 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult
} }
val newBalance = prevBalance - delta.toDouble() val newBalance = prevBalance - delta.toDouble()


ledgersToInsert += StockLedger().apply {
val ill = sol.inventoryLotLine
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val ledger = StockLedger().apply {
this.stockOutLine = sol this.stockOutLine = sol
this.inventory = inv this.inventory = inv
this.inQty = null this.inQty = null
@@ -2339,10 +2378,20 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult
this.type = request.type this.type = request.type
this.itemId = itemId this.itemId = itemId
this.itemCode = item.code this.itemCode = item.code
this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id
?: inv.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id,
inv.uom?.id,
)
this.date = today this.date = today
} }
StockLedgerLotSnapshot.apply(
ledger = ledger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = delta.negate(),
)
ledgersToInsert += ledger
runningLedgerBalanceByItemId[itemId] = newBalance runningLedgerBalanceByItemId[itemId] = newBalance
} }
stockLedgerRepository.saveAll(ledgersToInsert) stockLedgerRepository.saveAll(ledgersToInsert)


+ 15
- 3
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt View File

@@ -198,7 +198,7 @@ open class StockOutLineWorkbenchService(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 33 | v1.0.1 | 2026-07-22
* FP-MTMS Version Checklist | Functions Ref. No. 33 | v1.0.2 | 2026-08-03
*/ */
@Transactional(rollbackFor = [Exception::class]) @Transactional(rollbackFor = [Exception::class])
open fun scanPick(request: WorkbenchScanPickRequest): MessageResponse { open fun scanPick(request: WorkbenchScanPickRequest): MessageResponse {
@@ -517,6 +517,9 @@ if (updated == 0) {
val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val previousBalance = onHandAfter + deltaQty.toDouble() val previousBalance = onHandAfter + deltaQty.toDouble()
val newBalance = previousBalance - deltaQty.toDouble() val newBalance = previousBalance - deltaQty.toDouble()
val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) }
?: sol.inventoryLotLine
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val ledger = StockLedger().apply { val ledger = StockLedger().apply {
this.stockOutLine = sol this.stockOutLine = sol
this.inventory = inventory this.inventory = inventory
@@ -526,10 +529,19 @@ if (updated == 0) {
this.type = "NOR" this.type = "NOR"
this.itemId = solItem.id this.itemId = solItem.id
this.itemCode = solItem.code this.itemCode = solItem.code
this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id
?: inventory.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id,
inventory.uom?.id,
)
this.date = LocalDate.now() this.date = LocalDate.now()
} }
StockLedgerLotSnapshot.apply(
ledger = ledger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = deltaQty.negate(),
)
stockLedgerRepository.save(ledger) stockLedgerRepository.save(ledger)
} }




+ 27
- 2
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt View File

@@ -2488,6 +2488,10 @@ private fun applyVarianceAdjustment(
} }
val newBalance = previousBalance - qtyToRemove.toDouble() val newBalance = previousBalance - qtyToRemove.toDouble()


// FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.0 | 2026-08-03
// Snapshot from live lot line (in−out) at approve time — not stock_take_record.bookQty.
val lotAfterAvail = StockLedgerLotSnapshot.availableQty(latestLine).subtract(qtyToRemove)

val stockLedger = StockLedger().apply { val stockLedger = StockLedger().apply {
this.inventory = inventory this.inventory = inventory
this.itemId = inventoryLot.item?.id this.itemId = inventoryLot.item?.id
@@ -2497,9 +2501,18 @@ private fun applyVarianceAdjustment(
this.stockOutLine = stockOutLine this.stockOutLine = stockOutLine
this.balance = newBalance this.balance = newBalance
this.type = "TKE" this.type = "TKE"
this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
latestLine,
inventory.uom?.id,
)
this.date = LocalDate.now() this.date = LocalDate.now()
} }
StockLedgerLotSnapshot.apply(
ledger = stockLedger,
inventoryLotLine = latestLine,
lotAvailableAfterMove = lotAfterAvail,
signedDelta = qtyToRemove.negate(),
)


if (context != null) { if (context != null) {
context.stockLedgers.add(stockLedger) context.stockLedgers.add(stockLedger)
@@ -2613,6 +2626,9 @@ private fun applyVarianceAdjustment(
} }
val newBalance = previousBalance + plusQty.toDouble() val newBalance = previousBalance + plusQty.toDouble()


// latestLine.inQty already includes plusQty above — snapshot live lot line after move.
val lotAfterAvail = StockLedgerLotSnapshot.availableQty(latestLine)

val stockLedger = StockLedger().apply { val stockLedger = StockLedger().apply {
this.inventory = inventory this.inventory = inventory
this.itemId = inventoryLot.item?.id this.itemId = inventoryLot.item?.id
@@ -2622,9 +2638,18 @@ private fun applyVarianceAdjustment(
this.stockInLine = stockInLine this.stockInLine = stockInLine
this.balance = newBalance this.balance = newBalance
this.type = "TKE" this.type = "TKE"
this.uomId = latestLine.stockUom?.uom?.id ?: inventory.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
latestLine,
inventory.uom?.id,
)
this.date = LocalDate.now() this.date = LocalDate.now()
} }
StockLedgerLotSnapshot.apply(
ledger = stockLedger,
inventoryLotLine = latestLine,
lotAvailableAfterMove = lotAfterAvail,
signedDelta = plusQty,
)


if (context != null) { if (context != null) {
context.stockLedgers.add(stockLedger) context.stockLedgers.add(stockLedger)


+ 32
- 6
src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt View File

@@ -70,6 +70,7 @@ open class SuggestedPickLotWorkbenchService(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* After workbench scan-pick updates [StockOutLine] qty/status, bind SPL and set **qty** to match SOL (已揀 + 進行中). * After workbench scan-pick updates [StockOutLine] qty/status, bind SPL and set **qty** to match SOL (已揀 + 進行中).
*/ */
@Transactional(rollbackFor = [Exception::class]) @Transactional(rollbackFor = [Exception::class])
@@ -97,7 +98,14 @@ open class SuggestedPickLotWorkbenchService(
} }
if (spl != null) { if (spl != null) {
spl.stockOutLine = sol spl.stockOutLine = sol
spl.suggestedLotLine = ill
// Keep suggestedLotLine as the system suggestion; only record what was actually picked.
if (spl.suggestedLotLine == null && ill != null) {
spl.suggestedLotLine = ill
}
spl.actualPickLotLine = ill
if (spl.actualPickLotLine == null && spl.suggestedLotLine != null) {
spl.actualPickLotLine = spl.suggestedLotLine
}
spl.pickOrderLine = pol spl.pickOrderLine = pol
spl.qty = qtyBd spl.qty = qtyBd
spl.type = SuggestedPickLotType.PICK_ORDER spl.type = SuggestedPickLotType.PICK_ORDER
@@ -109,6 +117,7 @@ open class SuggestedPickLotWorkbenchService(
type = SuggestedPickLotType.PICK_ORDER type = SuggestedPickLotType.PICK_ORDER
stockOutLine = sol stockOutLine = sol
suggestedLotLine = ill suggestedLotLine = ill
actualPickLotLine = ill
pickOrderLine = pol pickOrderLine = pol
qty = qtyBd qty = qtyBd
}, },
@@ -150,7 +159,7 @@ open class SuggestedPickLotWorkbenchService(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* Lot stock UOM must match pick_order_line.uom (UomConversion id). * Lot stock UOM must match pick_order_line.uom (UomConversion id).
*/ */
private fun matchesPolUom(lot: InventoryLotLine, line: PickOrderLine): Boolean { private fun matchesPolUom(lot: InventoryLotLine, line: PickOrderLine): Boolean {
@@ -159,7 +168,7 @@ open class SuggestedPickLotWorkbenchService(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* Build suggestions with no-hold rule: * Build suggestions with no-hold rule:
* available = inQty - outQty (ignore holdQty). * available = inQty - outQty (ignore holdQty).
* When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id). * When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id).
@@ -292,7 +301,7 @@ open class SuggestedPickLotWorkbenchService(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* Same no-hold allocation as [rebuildNoHoldSuggestionsForPickOrder], but only for one [pickOrderLineId]. * Same no-hold allocation as [rebuildNoHoldSuggestionsForPickOrder], but only for one [pickOrderLineId].
* Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order. * Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order.
*/ */
@@ -384,7 +393,7 @@ open class SuggestedPickLotWorkbenchService(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* Workbench explicit-qty remainder support (single-next-lot): * Workbench explicit-qty remainder support (single-next-lot):
* Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot. * Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot.
* When [storeId] is provided, the next lot must be within the same store (warehouse.store_id). * When [storeId] is provided, the next lot must be within the same store (warehouse.store_id).
@@ -445,7 +454,7 @@ open class SuggestedPickLotWorkbenchService(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.1 | 2026-07-22
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* Workbench explicit-qty split support: * Workbench explicit-qty split support:
* Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty).
* - If [targetQty] <= 0: soft-delete all active suggestions for the line. * - If [targetQty] <= 0: soft-delete all active suggestions for the line.
@@ -561,6 +570,7 @@ open class SuggestedPickLotWorkbenchService(
} }


/** /**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* Align DB rows with [desired]. Returns count of newly inserted SPL rows. * Align DB rows with [desired]. Returns count of newly inserted SPL rows.
*/ */
private fun syncSuggestedPickLotsForLine( private fun syncSuggestedPickLotsForLine(
@@ -579,6 +589,14 @@ open class SuggestedPickLotWorkbenchService(
val reuse = keepers.maxByOrNull { it.id ?: 0L } val reuse = keepers.maxByOrNull { it.id ?: 0L }
if (reuse != null) { if (reuse != null) {
reuse.suggestedLotLine = ill reuse.suggestedLotLine = ill
// Pending suggestion: keep actual in sync with suggested until user picks/switches.
if (reuse.actualPickLotLine == null ||
reuse.actualPickLotLine?.id == null ||
reuse.stockOutLine == null ||
!isSplFrozen(reuse)
) {
reuse.actualPickLotLine = ill
}
reuse.pickOrderLine = line reuse.pickOrderLine = line
reuse.type = SuggestedPickLotType.PICK_ORDER reuse.type = SuggestedPickLotType.PICK_ORDER
reuse.qty = qty reuse.qty = qty
@@ -595,6 +613,7 @@ open class SuggestedPickLotWorkbenchService(
SuggestedPickLot().apply { SuggestedPickLot().apply {
type = SuggestedPickLotType.PICK_ORDER type = SuggestedPickLotType.PICK_ORDER
suggestedLotLine = ill suggestedLotLine = ill
actualPickLotLine = ill
pickOrderLine = line pickOrderLine = line
this.qty = qty this.qty = qty
}, },
@@ -616,6 +635,12 @@ open class SuggestedPickLotWorkbenchService(
val spl = byIllKey[k] val spl = byIllKey[k]
if (spl != null) { if (spl != null) {
spl.suggestedLotLine = ill spl.suggestedLotLine = ill
if (spl.actualPickLotLine == null ||
spl.stockOutLine == null ||
!isSplFrozen(spl)
) {
spl.actualPickLotLine = ill
}
spl.pickOrderLine = line spl.pickOrderLine = line
spl.type = SuggestedPickLotType.PICK_ORDER spl.type = SuggestedPickLotType.PICK_ORDER
spl.qty = qty spl.qty = qty
@@ -629,6 +654,7 @@ open class SuggestedPickLotWorkbenchService(
SuggestedPickLot().apply { SuggestedPickLot().apply {
type = SuggestedPickLotType.PICK_ORDER type = SuggestedPickLotType.PICK_ORDER
suggestedLotLine = ill suggestedLotLine = ill
actualPickLotLine = ill
pickOrderLine = line pickOrderLine = line
this.qty = qty this.qty = qty
}, },


+ 21
- 0
src/main/resources/db/changelog/changes/20260723_do_user_pick_audit/01_add_audit_columns.sql View File

@@ -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`);

+ 10
- 0
src/main/resources/db/changelog/changes/20260724_do_user_pick_audit_perf/01_ledger_sol_index.sql View File

@@ -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`);

Loading…
Cancel
Save