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

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

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


+ 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 suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService,
private val stockOutLineWorkbenchService: StockOutLineWorkbenchService,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) {

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

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

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

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

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

+ 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.service.StockOutLineWorkbenchService
import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService
import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot
import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress
import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus
import org.springframework.stereotype.Service
@@ -266,6 +267,7 @@ open class DoWorkbenchMainService(
}

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

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

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

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

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

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

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

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

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

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

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

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


+ 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)

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

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

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


+ 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

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


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

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

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

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

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

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

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

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

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

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

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

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

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


+ 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).
* [getJobOrderLotsHierarchicalByPickOrderIdWorkbench] uses **in − out** for available qty (matches workbench scan-pick).
* [getJobOrderLotsHierarchicalByPickOrderIdWorkbench] uses **in − out** for available qty (matches workbench scan-pick)
* and displays COALESCE(actualPickLotLine, suggestedLotLine).
* Non-workbench hierarchical stays in [JoPickOrderService.getJobOrderLotsHierarchicalByPickOrderId] (in − out − hold).
*/
@Service
@@ -190,7 +191,9 @@ open class JoWorkbenchMainService(
}

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

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

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

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

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

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

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

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

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

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

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

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


+ 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.
*
* - Branch A: no pick submitted, product process pending → hide job order.
* - Branch B: pick submitted, product process still pending → reschedule to today 00:00:00 and renumber.
* - Branch B (disabled): pick submitted, process pending — previously rescheduled + renumbered;
* now SKIP so unfinished JOs stay visible via Production Process list lookback instead.
*/
@Service
open class JobOrderPlanStartAutoService(
@@ -47,6 +48,7 @@ open class JobOrderPlanStartAutoService(
val errors: Int = 0,
)

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

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


+ 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 itemId: Long,
val itemName: String,
val itemCode: String? = null,
val bomDescription: String?,
/** BOM.type (e.g. drink / Powder_Mixture / other). */
val bomType: String? = null,
val reqQty: BigDecimal,
val uomId: Long,
val uomName: String,


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

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

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

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

+ 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

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

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


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



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

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


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

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

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


+ 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.
* Uses no-hold available qty semantics (inQty - outQty).
*/
@@ -275,6 +276,7 @@ open class PickOrderWorkbenchService(

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


+ 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("bomIds") bomIds: Collection<Long>,
): List<JobOrderProcessAggregate>

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

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

+ 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,
itemCode = productProcesses.item?.code,
bomDescription = productProcesses.bom?.description,
bomType = productProcesses.bom?.type,
pickOrderId = pickOrder?.id,
pickOrderStatus = pickOrder?.status?.value,
jobOrderId = productProcesses.jobOrder?.id,
@@ -1497,6 +1498,7 @@ open class ProductProcessService(
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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



+ 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)
}

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



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

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

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

/**


+ 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.report.service.M18BomShopSyncReportService
import com.ffii.fpsms.modules.report.service.ReportService
import com.ffii.fpsms.modules.report.service.ShopOrderReplenishmentReportService

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

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

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


+ 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.Entity
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToOne
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull
import java.math.BigDecimal
import java.time.LocalDate
@Entity
@Table(name = "stock_ledger")
@@ -19,6 +21,11 @@ open class StockLedger: BaseEntity<Long>() {
@JoinColumn(name = "stockOutLineId")
open var stockOutLine: StockOutLine? = null

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

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

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

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

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



+ 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")
open var handledBy: Long? = null

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

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


+ 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")
open var suggestedLotLine: InventoryLotLine? = null

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

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


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

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

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

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

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

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


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

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

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


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

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

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

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

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


+ 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])
open fun scanPick(request: WorkbenchScanPickRequest): MessageResponse {
@@ -517,6 +517,9 @@ if (updated == 0) {
val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val previousBalance = onHandAfter + deltaQty.toDouble()
val newBalance = previousBalance - deltaQty.toDouble()
val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) }
?: sol.inventoryLotLine
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val ledger = StockLedger().apply {
this.stockOutLine = sol
this.inventory = inventory
@@ -526,10 +529,19 @@ if (updated == 0) {
this.type = "NOR"
this.itemId = solItem.id
this.itemCode = solItem.code
this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id
?: inventory.uom?.id
this.uomId = StockLedgerLotSnapshot.resolveLedgerUomId(
ill,
itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id,
inventory.uom?.id,
)
this.date = LocalDate.now()
}
StockLedgerLotSnapshot.apply(
ledger = ledger,
inventoryLotLine = ill,
lotAvailableAfterMove = lotAfter,
signedDelta = deltaQty.negate(),
)
stockLedgerRepository.save(ledger)
}



+ 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()

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

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

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

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

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

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


+ 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 (已揀 + 進行中).
*/
@Transactional(rollbackFor = [Exception::class])
@@ -97,7 +98,14 @@ open class SuggestedPickLotWorkbenchService(
}
if (spl != null) {
spl.stockOutLine = sol
spl.suggestedLotLine = ill
// Keep suggestedLotLine as the system suggestion; only record what was actually picked.
if (spl.suggestedLotLine == null && ill != null) {
spl.suggestedLotLine = ill
}
spl.actualPickLotLine = ill
if (spl.actualPickLotLine == null && spl.suggestedLotLine != null) {
spl.actualPickLotLine = spl.suggestedLotLine
}
spl.pickOrderLine = pol
spl.qty = qtyBd
spl.type = SuggestedPickLotType.PICK_ORDER
@@ -109,6 +117,7 @@ open class SuggestedPickLotWorkbenchService(
type = SuggestedPickLotType.PICK_ORDER
stockOutLine = sol
suggestedLotLine = ill
actualPickLotLine = ill
pickOrderLine = pol
qty = qtyBd
},
@@ -150,7 +159,7 @@ open class SuggestedPickLotWorkbenchService(
}

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

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

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

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

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

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


+ 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