kelvin.yau vor 12 Stunden
Ursprung
Commit
a6d2b67cd5
15 geänderte Dateien mit 574 neuen und 402 gelöschten Zeilen
  1. +6
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt
  2. +8
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt
  3. +17
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt
  4. +136
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt
  5. +6
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt
  6. +11
    -4
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  7. +75
    -106
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt
  8. +1
    -1
      src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt
  9. +4
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt
  10. +0
    -3
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  11. +283
    -256
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt
  12. +21
    -27
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  13. +3
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt
  14. +1
    -1
      src/main/resources/jasper/SemiFGProductionAnalysisReport.jrxml
  15. +2
    -2
      src/main/resources/jasper/StockInTraceabilityReport.jrxml

+ 6
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt Datei anzeigen

@@ -107,4 +107,10 @@ data class ReleasedDoPickOrderListItem(
data class AssignByDoPickOrderIdRequest( data class AssignByDoPickOrderIdRequest(
val userId: Long, val userId: Long,
val doPickOrderId: Long val doPickOrderId: Long
)

/** Workbench: assign a `delivery_order_pick_order` ticket + its linked pick orders. */
data class AssignByDeliveryOrderPickOrderIdRequest(
val userId: Long,
val deliveryOrderPickOrderId: Long,
) )

+ 8
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt Datei anzeigen

@@ -1627,11 +1627,18 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
if (completedDate != null) { if (completedDate != null) {
val from = completedDate.atStartOfDay() val from = completedDate.atStartOfDay()
val toExclusive = completedDate.plusDays(1).atStartOfDay() val toExclusive = completedDate.plusDays(1).atStartOfDay()
/*
pickOrderRepository.findAllCompletedWithJobOrderPlanEndOnDay( pickOrderRepository.findAllCompletedWithJobOrderPlanEndOnDay(
PickOrderStatus.COMPLETED, PickOrderStatus.COMPLETED,
from, from,
toExclusive, toExclusive,
) )
*/
pickOrderRepository.findAllCompletedWithJobOrderPlanStartOnDay(
PickOrderStatus.COMPLETED,
from,
toExclusive,
)
} else { } else {
pickOrderRepository pickOrderRepository
.findAllByStatusIn(listOf(PickOrderStatus.COMPLETED)) .findAllByStatusIn(listOf(PickOrderStatus.COMPLETED))
@@ -1669,7 +1676,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
"${it.year}-${"%02d".format(it.monthValue)}-${"%02d".format(it.dayOfMonth)}" "${it.year}-${"%02d".format(it.monthValue)}-${"%02d".format(it.dayOfMonth)}"
}, },
"pickOrderStatus" to pickOrder.status, "pickOrderStatus" to pickOrder.status,
"completedDate" to jobOrder.planEnd,
"completedDate" to jobOrder.planStart,
"jobOrderId" to jobOrder.id, "jobOrderId" to jobOrder.id,
"jobOrderCode" to jobOrder.code, "jobOrderCode" to jobOrder.code,
"jobOrderName" to jobOrder.bom?.name, "jobOrderName" to jobOrder.bom?.name,


+ 17
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt Datei anzeigen

@@ -112,4 +112,21 @@ fun findAllCompletedWithJobOrderPlanEndOnDay(
@Param("planEndFrom") planEndFrom: LocalDateTime, @Param("planEndFrom") planEndFrom: LocalDateTime,
@Param("planEndToExclusive") planEndToExclusive: LocalDateTime, @Param("planEndToExclusive") planEndToExclusive: LocalDateTime,
): List<PickOrder> ): List<PickOrder>

@Query(
"""
SELECT po FROM PickOrder po
WHERE po.status = :status
AND po.deleted = false
AND po.jobOrder IS NOT NULL
AND po.jobOrder.planStart IS NOT NULL
AND po.jobOrder.planStart >= :planStartFrom
AND po.jobOrder.planStart < :planStartToExclusive
"""
)
fun findAllCompletedWithJobOrderPlanStartOnDay(
@Param("status") status: PickOrderStatus,
@Param("planStartFrom") planStartFrom: LocalDateTime,
@Param("planStartToExclusive") planStartToExclusive: LocalDateTime,
): List<PickOrder>
} }

+ 136
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt Datei anzeigen

@@ -401,6 +401,142 @@ open class PickExecutionIssueService(
) )
} }
} }

/**
* Jo / 无异常量场景:不写入 pick_execution_issue、不更新 inventory_lot_line.issue_qty。
* 仅 (1) 按 actualPickQty - requiredQty 调整 holdQty;(5) 将对应 stock_out_line 标为 checked(不改 qty)。
* 可重复调用,避免 DUPLICATE。
*/
open fun applyPickHoldAndMarkSolChecked(request: PickExecutionIssueRequest): MessageResponse {
try {
println("=== applyPickHoldAndMarkSolChecked: START ===")
val missQty = request.missQty ?: BigDecimal.ZERO
val badItemQty = request.badItemQty ?: BigDecimal.ZERO
if (missQty.compareTo(BigDecimal.ZERO) > 0 || badItemQty.compareTo(BigDecimal.ZERO) > 0) {
return MessageResponse(
id = null,
name = "Invalid request for hold-only API",
code = "ERROR",
type = "pick_execution_adjustment",
message = "This endpoint accepts only actual pick adjustment (miss and bad quantities must be zero). Use /recordIssue for issues.",
errorPosition = null
)
}
if (request.lotId == null) {
return MessageResponse(
id = null,
name = "lotId required",
code = "ERROR",
type = "pick_execution_adjustment",
message = "inventory lot line id (lotId) is required",
errorPosition = null
)
}

val inventoryLotLine = inventoryLotLineRepository.findById(request.lotId).orElse(null)
val bookQty = if (inventoryLotLine != null) {
val inQty = inventoryLotLine.inQty ?: BigDecimal.ZERO
val outQty = inventoryLotLine.outQty ?: BigDecimal.ZERO
inQty.subtract(outQty)
} else {
BigDecimal.ZERO
}

val requiredQty = request.requiredQty ?: BigDecimal.ZERO
val actualPickQty = request.actualPickQty ?: BigDecimal.ZERO
val lotRemainAvailable = bookQty
val maxAllowed = requiredQty.add(lotRemainAvailable)

if (actualPickQty > maxAllowed) {
return MessageResponse(
id = null,
name = "Actual pick qty too large",
code = "ERROR",
type = "pick_execution_adjustment",
message = "Actual pick qty cannot exceed required qty plus lot remaining available.",
errorPosition = null
)
}

if (inventoryLotLine != null) {
val deltaHold = actualPickQty.subtract(requiredQty)
if (deltaHold.compareTo(BigDecimal.ZERO) != 0) {
val latestLotLine = inventoryLotLineRepository.findById(request.lotId).orElse(null)
?: throw IllegalArgumentException("Inventory lot line not found: ${request.lotId}")

val currentHold = latestLotLine.holdQty ?: BigDecimal.ZERO
val currentOut = latestLotLine.outQty ?: BigDecimal.ZERO
val currentIn = latestLotLine.inQty ?: BigDecimal.ZERO

val newHold = currentHold.add(deltaHold)
if (newHold < BigDecimal.ZERO) {
return MessageResponse(
id = null,
name = "Invalid hold quantity adjustment",
code = "ERROR",
type = "pick_execution_adjustment",
message = "Cannot adjust holdQty by $deltaHold. Current holdQty=$currentHold, requiredQty=$requiredQty, actualPickQty=$actualPickQty",
errorPosition = null
)
}

if (deltaHold > BigDecimal.ZERO) {
val remaining = currentIn.subtract(currentOut).subtract(currentHold)
if (deltaHold > remaining) {
return MessageResponse(
id = null,
name = "Insufficient remaining quantity",
code = "ERROR",
type = "pick_execution_adjustment",
message = "Cannot reserve additional $deltaHold. Remaining=$remaining (in=$currentIn, out=$currentOut, hold=$currentHold)",
errorPosition = null
)
}
}

latestLotLine.holdQty = newHold
latestLotLine.modified = LocalDateTime.now()
latestLotLine.modifiedBy = "system"
inventoryLotLineRepository.saveAndFlush(latestLotLine)
println("✅ [hold-only] Adjusted inventory_lot_line ${request.lotId} holdQty: $currentHold -> $newHold (delta=$deltaHold)")
}
}

val stockOutLines = stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse(
request.pickOrderLineId,
request.lotId
)
stockOutLines.forEach { sol ->
sol.status = "checked"
sol.modified = LocalDateTime.now()
sol.modifiedBy = "system"
stockOutLineRepository.save(sol)
}
stockOutLineRepository.flush()

println("=== applyPickHoldAndMarkSolChecked: SUCCESS (${stockOutLines.size} SOL checked) ===")
return MessageResponse(
id = stockOutLines.firstOrNull()?.id,
name = "Pick hold adjusted and lines marked checked",
code = "SUCCESS",
type = "pick_execution_adjustment",
message = "Pick hold adjusted and stock out lines marked checked",
errorPosition = null
)
} catch (e: Exception) {
println("=== applyPickHoldAndMarkSolChecked: ERROR === ${e.message}")
e.printStackTrace()
return MessageResponse(
id = null,
name = "Failed to apply pick hold adjustment",
code = "ERROR",
type = "pick_execution_adjustment",
message = "Error: ${e.message}",
errorPosition = null
)
}
}

private fun handleAllZeroMarkCompleted(request: PickExecutionIssueRequest) { private fun handleAllZeroMarkCompleted(request: PickExecutionIssueRequest) {
val stockOutLines = stockOutLineRepository val stockOutLines = stockOutLineRepository
.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( .findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse(


+ 6
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt Datei anzeigen

@@ -20,6 +20,12 @@ class PickExecutionIssueController(
return pickExecutionIssueService.recordPickExecutionIssue(request) return pickExecutionIssueService.recordPickExecutionIssue(request)
} }


/** 无 miss/bad:仅调整 hold + SOL 标 checked,不写 pick_execution_issue(可重复提交)。 */
@PostMapping("/applyHoldAndChecked")
fun applyPickHoldAndMarkSolChecked(@RequestBody request: PickExecutionIssueRequest): MessageResponse {
return pickExecutionIssueService.applyPickHoldAndMarkSolChecked(request)
}

@GetMapping("/issues/pickOrder/{pickOrderId}") @GetMapping("/issues/pickOrder/{pickOrderId}")
fun getPickExecutionIssuesByPickOrder(@PathVariable pickOrderId: Long): List<PickExecutionIssue> { fun getPickExecutionIssuesByPickOrder(@PathVariable pickOrderId: Long): List<PickExecutionIssue> {
return pickExecutionIssueService.getPickExecutionIssuesByPickOrder(pickOrderId) return pickExecutionIssueService.getPickExecutionIssuesByPickOrder(pickOrderId)


+ 11
- 4
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt Datei anzeigen

@@ -652,6 +652,7 @@ return result
* Queries the database for Stock In Traceability Report data (入倉追蹤 PDF). * Queries the database for Stock In Traceability Report data (入倉追蹤 PDF).
* Joins stock_in_line, stock_in, items, qc_result, inventory_lot, inventory_lot_line, warehouse, and shop tables. * Joins stock_in_line, stock_in, items, qc_result, inventory_lot, inventory_lot_line, warehouse, and shop tables.
* Supports comma-separated values for stockCategory (items.type) and itemCode. * Supports comma-separated values for stockCategory (items.type) and itemCode.
* Date range [lastInDateStart, lastInDateEnd] filters on stock_in_line.productionDate (完成生產日期), same basis as 成品/半成品生產分析報告.
*/ */
fun searchStockInTraceabilityReport( fun searchStockInTraceabilityReport(
stockCategory: String?, stockCategory: String?,
@@ -673,13 +674,13 @@ return result
val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) {
val formattedDate = lastInDateStart.replace("/", "-") val formattedDate = lastInDateStart.replace("/", "-")
args["lastInDateStart"] = formattedDate args["lastInDateStart"] = formattedDate
"AND DATE(sil.receiptDate) >= DATE(:lastInDateStart)"
"AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) >= DATE(:lastInDateStart)"
} else "" } else ""
val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) { val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) {
val formattedDate = lastInDateEnd.replace("/", "-") val formattedDate = lastInDateEnd.replace("/", "-")
args["lastInDateEnd"] = formattedDate args["lastInDateEnd"] = formattedDate
"AND DATE(sil.receiptDate) <= DATE(:lastInDateEnd)"
"AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) <= DATE(:lastInDateEnd)"
} else "" } else ""


val sql = """ val sql = """
@@ -691,7 +692,7 @@ return result
COALESCE(sil.lotNo, il.lotNo, '') as lotNo, COALESCE(sil.lotNo, il.lotNo, '') as lotNo,
COALESCE(DATE_FORMAT(COALESCE(sil.expiryDate, il.expiryDate), '%Y-%m-%d'), '') as expiryDate, COALESCE(DATE_FORMAT(COALESCE(sil.expiryDate, il.expiryDate), '%Y-%m-%d'), '') as expiryDate,
CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN '0' CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN '0'
ELSE TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(sil.acceptedQty, 0), 2)))
ELSE TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(pa_sil.putAwayQtySum, 0), 2)))
END as stockInQty, END as stockInQty,
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(sil.acceptedQty, 0), 2))) as iqcSampleQty, TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(sil.acceptedQty, 0), 2))) as iqcSampleQty,
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(qr_agg.failQtySum, 0), 2))) as iqcDefectQty, TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(qr_agg.failQtySum, 0), 2))) as iqcDefectQty,
@@ -706,7 +707,7 @@ return result
COALESCE(wh.code, '') as storeLocation, COALESCE(wh.code, '') as storeLocation,
COALESCE(sp_si.code, sp_po.code, '') as supplierID, COALESCE(sp_si.code, sp_po.code, '') as supplierID,
COALESCE(sp_si.name, sp_po.name, '') as supplierName, COALESCE(sp_si.name, sp_po.name, '') as supplierName,
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id), 2))) as totalStockInQty,
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(pa_sil.putAwayQtySum, 0)) OVER (PARTITION BY it.id), 2))) as totalStockInQty,
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id), 2))) as totalIqcSampleQty TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id), 2))) as totalIqcSampleQty
FROM stock_in_line sil FROM stock_in_line sil
LEFT JOIN stock_in si ON sil.stockInId = si.id LEFT JOIN stock_in si ON sil.stockInId = si.id
@@ -715,6 +716,12 @@ return result
LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
LEFT JOIN inventory_lot il ON sil.inventoryLotId = il.id LEFT JOIN inventory_lot il ON sil.inventoryLotId = il.id
LEFT JOIN (
SELECT inventoryLotId, SUM(COALESCE(inQty, 0)) AS putAwayQtySum
FROM inventory_lot_line
WHERE deleted = false
GROUP BY inventoryLotId
) pa_sil ON pa_sil.inventoryLotId = sil.inventoryLotId
LEFT JOIN inventory_lot_line ill ON il.id = ill.inventoryLotId LEFT JOIN inventory_lot_line ill ON il.id = ill.inventoryLotId
LEFT JOIN warehouse wh ON ill.warehouseId = wh.id LEFT JOIN warehouse wh ON ill.warehouseId = wh.id
LEFT JOIN shop sp_si ON si.supplierId = sp_si.id LEFT JOIN shop sp_si ON si.supplierId = sp_si.id


+ 75
- 106
src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt Datei anzeigen

@@ -57,11 +57,13 @@ class SemiFGProductionAnalysisReportService(


/** /**
* Queries the database for Semi FG Production Analysis Report data. * Queries the database for Semi FG Production Analysis Report data.
* Flow:
* 1. Filter bom by description (FG/WIP) to get bom.code values
* 2. Match bom.code with stock_ledger.itemCode
* 3. Join stock_in_line; aggregate by calendar month of stock_in_line.productionDate (完成生產日期), not stock_ledger.modified
* Supports comma-separated values for stockCategory, stockSubCategory, and itemCode.
* Aligned with [ReportService.searchStockInTraceabilityReport] totals for the same filters:
* - stock_in_line driven (no stock_ledger gate); INNER JOIN bom so only items that exist as BOM rows appear
* - stockCategory → items.type (exact, comma-separated); itemCode → items.code (LIKE, comma-separated)
* - Date range / year on productionDate (with IS NOT NULL when range bound is set)
* - Put-away qty: SUM(inventory_lot_line.inQty) by sil.inventoryLotId (same as traceability pa_sil)
* - QC any fail → line qty 0 (same as traceability stockInQty)
* - One row per stockInLineId per month before pivot; all lines counted (not only job orders)
*/ */
fun searchSemiFGProductionAnalysisReport( fun searchSemiFGProductionAnalysisReport(
stockCategory: String?, stockCategory: String?,
@@ -72,82 +74,72 @@ class SemiFGProductionAnalysisReportService(
lastOutDateEnd: String? lastOutDateEnd: String?
): List<Map<String, Any>> { ): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>() val args = mutableMapOf<String, Any>()
// Filter by stockCategory from bom.description (FG/WIP) - this finds which bom.code values match
// Supports multiple categories separated by comma (e.g., "FG,WIP")
// If "All" is selected or contains "All", don't filter by description

val stockCategorySql = if (!itemCode.isNullOrBlank()) { val stockCategorySql = if (!itemCode.isNullOrBlank()) {
// When itemCode is provided, skip stockCategory filter
"" ""
} else if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { } else if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) {
// Handle multiple categories (comma-separated)
val categories = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() && it != "All" }
if (categories.isNotEmpty()) {
val conditions = categories.mapIndexed { index, cat ->
val paramName = "stockCategory_$index"
args[paramName] = cat
"b.description = :$paramName"
}
"AND (${conditions.joinToString(" OR ")})"
} else {
""
}
buildMultiValueExactClause(stockCategory, "it.type", "semiSc", args)
} else { } else {
"" ""
} }
val stockSubCategorySql = buildMultiValueLikeClause(stockSubCategory, "ic.sub", "stockSubCategory", args) val stockSubCategorySql = buildMultiValueLikeClause(stockSubCategory, "ic.sub", "stockSubCategory", args)
// Filter by itemCode - match bom.code (user input should match bom.code, which then matches stock_ledger.itemCode)
val itemCodeSql = buildMultiValueExactClause(itemCode, "b.code", "itemCode", args)
val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "semiItem", args)

val yearSql = if (!year.isNullOrBlank() && year != "All") { val yearSql = if (!year.isNullOrBlank() && year != "All") {
args["year"] = year args["year"] = year
"AND YEAR(si.productionDate) = :year" "AND YEAR(si.productionDate) = :year"
} else { } else {
"" ""
} }
val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) { val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) {
val formattedDate = lastOutDateStart.replace("/", "-") val formattedDate = lastOutDateStart.replace("/", "-")
args["lastOutDateStart"] = formattedDate args["lastOutDateStart"] = formattedDate
"AND DATE(si.productionDate) >= DATE(:lastOutDateStart)"
"AND si.productionDate IS NOT NULL AND DATE(si.productionDate) >= DATE(:lastOutDateStart)"
} else "" } else ""
val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) { val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) {
val formattedDate = lastOutDateEnd.replace("/", "-") val formattedDate = lastOutDateEnd.replace("/", "-")
args["lastOutDateEnd"] = formattedDate args["lastOutDateEnd"] = formattedDate
"AND DATE(si.productionDate) <= DATE(:lastOutDateEnd)"
"AND si.productionDate IS NOT NULL AND DATE(si.productionDate) <= DATE(:lastOutDateEnd)"
} else "" } else ""


val sql = """ val sql = """
WITH base AS (
WITH qr_agg AS (
SELECT SELECT
COALESCE(sl.itemCode, '') as itemNo,
COALESCE(b.name, '') as itemName,
COALESCE(ic.sub, '') as stockSubCategory,
COALESCE(uc.udfudesc, '') as unitOfMeasure,
MONTH(si.productionDate) as mon,
si.id as stockInLineId,
si.acceptedQty as acceptedQty,
si.jobOrderId as jobOrderId
FROM stock_ledger sl
INNER JOIN bom b
ON sl.itemCode = b.code AND b.deleted = false
INNER JOIN stock_in_line si
ON si.id = sl.stockInLineId
AND si.deleted = false
AND si.productionDate IS NOT NULL
LEFT JOIN items it
ON sl.itemId = it.id
LEFT JOIN item_category ic
ON it.categoryId = ic.id
LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = true
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id
WHERE sl.deleted = false
AND sl.inQty IS NOT NULL
AND sl.inQty > 0
qr.stockInLineId,
MAX(CASE WHEN qr.qcPassed = 0 THEN 1 ELSE 0 END) AS qcFailed
FROM qc_result qr
WHERE qr.deleted = 0
GROUP BY qr.stockInLineId
),
pa_sil AS (
SELECT inventoryLotId, SUM(COALESCE(inQty, 0)) AS putAwayQtySum
FROM inventory_lot_line
WHERE deleted = false
GROUP BY inventoryLotId
),
base AS (
SELECT
COALESCE(it.code, '') AS itemNo,
COALESCE(it.name, '') AS itemName,
COALESCE(ic.sub, '') AS stockSubCategory,
COALESCE(uc.udfudesc, '') AS unitOfMeasure,
MONTH(si.productionDate) AS mon,
si.id AS stockInLineId,
CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0
ELSE COALESCE(pa_sil.putAwayQtySum, 0)
END AS linePutAwayQty
FROM stock_in_line si
INNER JOIN items it ON si.itemId = it.id
INNER JOIN bom b ON b.code = it.code AND b.deleted = false
LEFT JOIN qr_agg ON qr_agg.stockInLineId = si.id
LEFT JOIN pa_sil ON pa_sil.inventoryLotId = si.inventoryLotId
LEFT JOIN item_category ic ON it.categoryId = ic.id
LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
WHERE si.deleted = false
AND si.productionDate IS NOT NULL
$stockCategorySql $stockCategorySql
$stockSubCategorySql $stockSubCategorySql
$itemCodeSql $itemCodeSql
@@ -155,7 +147,6 @@ class SemiFGProductionAnalysisReportService(
$lastOutDateStartSql $lastOutDateStartSql
$lastOutDateEndSql $lastOutDateEndSql
), ),
-- Deduplicate: stock_in_line can join to multiple stock_ledger rows; acceptedQty must be counted once per stockInLineId.
dedup AS ( dedup AS (
SELECT SELECT
itemNo, itemNo,
@@ -164,36 +155,34 @@ class SemiFGProductionAnalysisReportService(
unitOfMeasure, unitOfMeasure,
mon, mon,
stockInLineId, stockInLineId,
MAX(COALESCE(acceptedQty, 0)) as acceptedQty,
MAX(jobOrderId) as jobOrderId
MAX(linePutAwayQty) AS linePutAwayQty
FROM base FROM base
GROUP BY itemNo, itemName, stockSubCategory, unitOfMeasure, mon, stockInLineId GROUP BY itemNo, itemName, stockSubCategory, unitOfMeasure, mon, stockInLineId
) )
SELECT SELECT
MAX(d.stockSubCategory) as stockSubCategory,
d.itemNo as itemNo,
MAX(d.itemName) as itemName,
MAX(d.unitOfMeasure) as unitOfMeasure,
CAST(COALESCE(SUM(CASE WHEN d.mon = 1 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyJan,
CAST(COALESCE(SUM(CASE WHEN d.mon = 2 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyFeb,
CAST(COALESCE(SUM(CASE WHEN d.mon = 3 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyMar,
CAST(COALESCE(SUM(CASE WHEN d.mon = 4 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyApr,
CAST(COALESCE(SUM(CASE WHEN d.mon = 5 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyMay,
CAST(COALESCE(SUM(CASE WHEN d.mon = 6 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyJun,
CAST(COALESCE(SUM(CASE WHEN d.mon = 7 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyJul,
CAST(COALESCE(SUM(CASE WHEN d.mon = 8 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyAug,
CAST(COALESCE(SUM(CASE WHEN d.mon = 9 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtySep,
CAST(COALESCE(SUM(CASE WHEN d.mon = 10 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyOct,
CAST(COALESCE(SUM(CASE WHEN d.mon = 11 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyNov,
CAST(COALESCE(SUM(CASE WHEN d.mon = 12 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyDec,
-- Keep as CHAR for Jasper compatibility (previous template expects String).
CAST(COALESCE(SUM(CASE WHEN d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS CHAR) as totalProductionQty
MAX(d.stockSubCategory) AS stockSubCategory,
d.itemNo AS itemNo,
MAX(d.itemName) AS itemName,
MAX(d.unitOfMeasure) AS unitOfMeasure,
CAST(COALESCE(SUM(CASE WHEN d.mon = 1 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyJan,
CAST(COALESCE(SUM(CASE WHEN d.mon = 2 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyFeb,
CAST(COALESCE(SUM(CASE WHEN d.mon = 3 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyMar,
CAST(COALESCE(SUM(CASE WHEN d.mon = 4 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyApr,
CAST(COALESCE(SUM(CASE WHEN d.mon = 5 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyMay,
CAST(COALESCE(SUM(CASE WHEN d.mon = 6 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyJun,
CAST(COALESCE(SUM(CASE WHEN d.mon = 7 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyJul,
CAST(COALESCE(SUM(CASE WHEN d.mon = 8 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyAug,
CAST(COALESCE(SUM(CASE WHEN d.mon = 9 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtySep,
CAST(COALESCE(SUM(CASE WHEN d.mon = 10 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyOct,
CAST(COALESCE(SUM(CASE WHEN d.mon = 11 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyNov,
CAST(COALESCE(SUM(CASE WHEN d.mon = 12 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyDec,
CAST(COALESCE(SUM(d.linePutAwayQty), 0) AS CHAR) AS totalProductionQty
FROM dedup d FROM dedup d
GROUP BY d.itemNo GROUP BY d.itemNo
HAVING COALESCE(SUM(CASE WHEN d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) > 0
HAVING COALESCE(SUM(d.linePutAwayQty), 0) > 0
ORDER BY d.itemNo ORDER BY d.itemNo
""".trimIndent() """.trimIndent()
return jdbcDao.queryForList(sql, args) return jdbcDao.queryForList(sql, args)
} }


@@ -208,25 +197,15 @@ class SemiFGProductionAnalysisReportService(
val args = mutableMapOf<String, Any>() val args = mutableMapOf<String, Any>()
val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) {
// Handle multiple categories (comma-separated)
val categories = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() && it != "All" }
if (categories.isNotEmpty()) {
val conditions = categories.mapIndexed { index, cat ->
val paramName = "stockCategory_$index"
args[paramName] = cat
"b.description = :$paramName"
}
"AND (${conditions.joinToString(" OR ")})"
} else {
""
}
buildMultiValueExactClause(stockCategory, "it.type", "semiFgCodesSc", args)
} else { } else {
"" ""
} }


val sql = """ val sql = """
SELECT DISTINCT b.code, COALESCE(b.name, '') as name
SELECT DISTINCT b.code, COALESCE(it.name, b.name, '') AS name
FROM bom b FROM bom b
INNER JOIN items it ON it.code = b.code AND it.deleted = false
WHERE b.deleted = false WHERE b.deleted = false
AND b.code IS NOT NULL AND b.code IS NOT NULL
AND b.code != '' AND b.code != ''
@@ -255,25 +234,15 @@ class SemiFGProductionAnalysisReportService(
val args = mutableMapOf<String, Any>() val args = mutableMapOf<String, Any>()
val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) {
// Handle multiple categories (comma-separated)
val categories = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() && it != "All" }
if (categories.isNotEmpty()) {
val conditions = categories.mapIndexed { index, cat ->
val paramName = "stockCategory_$index"
args[paramName] = cat
"b.description = :$paramName"
}
"AND (${conditions.joinToString(" OR ")})"
} else {
""
}
buildMultiValueExactClause(stockCategory, "it.type", "semiFgCodesCatSc", args)
} else { } else {
"" ""
} }


val sql = """ val sql = """
SELECT DISTINCT b.code, COALESCE(b.description, '') as category, COALESCE(b.name, '') as name
SELECT DISTINCT b.code, COALESCE(it.type, '') AS category, COALESCE(it.name, b.name, '') AS name
FROM bom b FROM bom b
INNER JOIN items it ON it.code = b.code AND it.deleted = false
WHERE b.deleted = false WHERE b.deleted = false
AND b.code IS NOT NULL AND b.code IS NOT NULL
AND b.code != '' AND b.code != ''


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt Datei anzeigen

@@ -225,7 +225,7 @@ class SemiFGProductionAnalysisReportController(
"十月", "十月",
"十一月", "十一月",
"十二月", "十二月",
"總和"
"上架總計"
) )


val headerRow = sheet.createRow(rowIndex++) val headerRow = sheet.createRow(rowIndex++)


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt Datei anzeigen

@@ -12,5 +12,9 @@ interface SuggestPickLotRepository : AbstractRepository<SuggestedPickLot, Long>
fun findAllByPickOrderLineId(pickOrderLineId: Long): List<SuggestedPickLot> fun findAllByPickOrderLineId(pickOrderLineId: Long): List<SuggestedPickLot>


fun findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId: Long): List<SuggestedPickLot>

fun findAllByPickOrderLineIdInAndDeletedFalse(pickOrderLineIds: List<Long>): List<SuggestedPickLot>

fun findFirstByStockOutLineId(stockOutLineId: Long): SuggestedPickLot? fun findFirstByStockOutLineId(stockOutLineId: Long): SuggestedPickLot?
} }

+ 0
- 3
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt Datei anzeigen

@@ -115,9 +115,6 @@ open class InventoryLotLineService(
val stockUom = request.stockUomId?.let { itemUomRespository.findById(it).getOrNull() } val stockUom = request.stockUomId?.let { itemUomRespository.findById(it).getOrNull() }
val status = request.status?.let { _status -> InventoryLotLineStatus.entries.find { it.value == _status } } val status = request.status?.let { _status -> InventoryLotLineStatus.entries.find { it.value == _status } }


println("status: ${request.status}")
println("status123: ${status?.value}")

inventoryLotLine.apply { inventoryLotLine.apply {
this.inventoryLot = inventoryLot this.inventoryLot = inventoryLot
this.warehouse = warehouse this.warehouse = warehouse


+ 283
- 256
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt Datei anzeigen

@@ -47,6 +47,9 @@ import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssueRepository
import java.time.LocalTime import java.time.LocalTime
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository
import com.ffii.fpsms.modules.stock.entity.InventoryLotLine
import jakarta.persistence.EntityManager
import jakarta.persistence.PersistenceContext
import java.util.UUID import java.util.UUID
@Service @Service
open class StockOutLineService( open class StockOutLineService(
@@ -76,6 +79,10 @@ private val inventoryLotLineService: InventoryLotLineService,
private val pickExecutionIssueRepository: PickExecutionIssueRepository, private val pickExecutionIssueRepository: PickExecutionIssueRepository,
private val itemUomService: ItemUomService, private val itemUomService: ItemUomService,
): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) { ): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) {

@PersistenceContext
private lateinit var entityManager: EntityManager

private fun isEndStatus(status: String?): Boolean { private fun isEndStatus(status: String?): Boolean {
val s = status?.trim()?.lowercase() ?: return false val s = status?.trim()?.lowercase() ?: return false
return s == "completed" || s == "rejected" || s == "partially_completed" return s == "completed" || s == "rejected" || s == "partially_completed"
@@ -105,6 +112,22 @@ private val inventoryLotLineService: InventoryLotLineService,
} }
} }
} }

/** When every POL on this pick order is COMPLETED or PARTIALLY_COMPLETE, mark pick order completed and cascade DO completion. */
private fun refreshPickOrderHeaderIfAllLinesCompleted(pickOrderId: Long) {
val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return
val allLines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(pickOrderId)
val allCompleted = allLines.all {
it.status == PickOrderLineStatus.COMPLETED || it.status == PickOrderLineStatus.PARTIALLY_COMPLETE
}
if (allCompleted && allLines.isNotEmpty()) {
pickOrder.status = PickOrderStatus.COMPLETED
pickOrderRepository.save(pickOrder)
completeDoForPickOrder(pickOrderId)
completeDoIfAllPickOrdersCompleted(pickOrderId)
}
}

@Throws(IOException::class) @Throws(IOException::class)
@Transactional @Transactional
open fun findAllByStockOutId(stockOutId: Long): List<StockOutLine> { open fun findAllByStockOutId(stockOutId: Long): List<StockOutLine> {
@@ -379,7 +402,7 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
// }) // })
// } // }
@Transactional @Transactional
fun checkIsStockOutLineCompleted(pickOrderLineId: Long) {
fun checkIsStockOutLineCompleted(pickOrderLineId: Long, quiet: Boolean = false) {
val allStockOutLines = stockOutLineRepository val allStockOutLines = stockOutLineRepository
.findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId) .findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId)
@@ -410,7 +433,9 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
acc + (issue.issueQty ?: BigDecimal.ZERO) acc + (issue.issueQty ?: BigDecimal.ZERO)
} }
} catch (e: Exception) { } catch (e: Exception) {
println(" Error fetching issues for pickOrderLineId $pickOrderLineId: ${e.message}")
if (!quiet) {
println(" Error fetching issues for pickOrderLineId $pickOrderLineId: ${e.message}")
}
BigDecimal.ZERO BigDecimal.ZERO
} }


@@ -431,14 +456,16 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
// ✅ 现在的规则:这三类状态都算“已结束” // ✅ 现在的规则:这三类状态都算“已结束”
!(isComplete || isRejected || isPartiallyComplete) !(isComplete || isRejected || isPartiallyComplete)
} }
println("Unfinished lines: ${unfinishedLine.size}")
if (unfinishedLine.isNotEmpty()) {
unfinishedLine.forEach { sol ->
println(" - StockOutLine ${sol.id}: status=${sol.status}, qty=${sol.qty}")

if (!quiet) {
println("Unfinished lines: ${unfinishedLine.size}")
if (unfinishedLine.isNotEmpty()) {
unfinishedLine.forEach { sol ->
println(" - StockOutLine ${sol.id}: status=${sol.status}, qty=${sol.qty}")
}
} }
} }
if (unfinishedLine.isEmpty()) { if (unfinishedLine.isEmpty()) {
// set pick order line status to complete // set pick order line status to complete
val pol = pickOrderLineRepository.findById(pickOrderLineId).orElseThrow() val pol = pickOrderLineRepository.findById(pickOrderLineId).orElseThrow()
@@ -448,11 +475,28 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
this.status = PickOrderLineStatus.COMPLETED this.status = PickOrderLineStatus.COMPLETED
} }
) )
println("✅ Updated pick order line $pickOrderLineId from $previousStatus to COMPLETED")
} else {
if (!quiet) {
println("✅ Updated pick order line $pickOrderLineId from $previousStatus to COMPLETED")
}
} else if (!quiet) {
println("⏳ Pick order line $pickOrderLineId not completed yet - has ${unfinishedLine.size} unfinished stock out lines") println("⏳ Pick order line $pickOrderLineId not completed yet - has ${unfinishedLine.size} unfinished stock out lines")
} }
} }

/** Batch pick: same qty rules as [InventoryLotLineService.updateInventoryLotLineQuantities] pick, without double findById / extra service layers. */
private fun applyPickToInventoryLotLineInBatch(ill: InventoryLotLine, submitQty: BigDecimal) {
val zero = BigDecimal.ZERO
val newHold = (ill.holdQty ?: zero).minus(submitQty)
val newOut = (ill.outQty ?: zero).plus(submitQty)
if (newHold < zero || newOut < zero) {
throw IllegalArgumentException("Invalid pick quantities for lotLine ${ill.id}: holdQty=$newHold, outQty=$newOut")
}
val prevStatus = ill.status
ill.holdQty = newHold
ill.outQty = newOut
ill.status = inventoryLotLineService.deriveInventoryLotLineStatus(prevStatus, ill.inQty, ill.outQty, ill.holdQty)
inventoryLotLineRepository.save(ill)
}
private fun completeDoIfAllPickOrdersCompleted(pickOrderId: Long) { private fun completeDoIfAllPickOrdersCompleted(pickOrderId: Long) {
// 1) 先用 line 关联找 do_pick_order_id // 1) 先用 line 关联找 do_pick_order_id
val lines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(pickOrderId) val lines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(pickOrderId)
@@ -652,60 +696,66 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
println("Updating StockOutLine ID: ${request.id}") println("Updating StockOutLine ID: ${request.id}")
println("Current status: ${stockOutLine.status}") println("Current status: ${stockOutLine.status}")
println("New status: ${request.status}") println("New status: ${request.status}")
val deferAggregate = request.deferAggregatePickOrderEffects == true
val savedStockOutLine = applyStockOutLineDelta( val savedStockOutLine = applyStockOutLineDelta(
stockOutLineId = request.id,
stockOutLine = stockOutLine,
deltaQty = BigDecimal((request.qty ?: 0.0).toString()), deltaQty = BigDecimal((request.qty ?: 0.0).toString()),
newStatus = request.status, newStatus = request.status,
skipInventoryWrite = request.skipInventoryWrite == true, skipInventoryWrite = request.skipInventoryWrite == true,
skipLedgerWrite = request.skipLedgerWrite == true
skipLedgerWrite = request.skipLedgerWrite == true,
skipTryCompletePickOrderLine = deferAggregate,
deferPersistenceFlush = deferAggregate
) )
println("Updated StockOutLine: ${savedStockOutLine.id} with status: ${savedStockOutLine.status}") println("Updated StockOutLine: ${savedStockOutLine.id} with status: ${savedStockOutLine.status}")
try {
val item = savedStockOutLine.item
val inventoryLotLine = savedStockOutLine.inventoryLotLine
val reqDeltaQty = request.qty ?: 0.0
// 只在状态为 completed 或 partially_completed,且数量增加时创建 BagLotLine
val isCompletedOrPartiallyCompleted = request.status == "completed" ||
request.status == "partially_completed" ||
request.status == "PARTIALLY_COMPLETE"
if (item?.isBag == true &&
inventoryLotLine != null &&
isCompletedOrPartiallyCompleted &&
reqDeltaQty > 0) {
println(" Item isBag=true, creating BagLotLine...")
val bag = bagRepository.findByItemIdAndDeletedIsFalse(item.id!!)
if (bag != null) {
val lotNo = inventoryLotLine.inventoryLot?.lotNo
if (lotNo != null) {
val createBagLotLineRequest = CreateBagLotLineRequest(
bagId = bag.id!!,
lotId = inventoryLotLine.inventoryLot?.id ?: 0L,
itemId = item.id!!,
lotNo = lotNo,
stockQty = reqDeltaQty.toInt(),
date = LocalDate.now(),
time = LocalTime.now(),
stockOutLineId = savedStockOutLine.id
)

bagService.createBagLotLinesByBagId(createBagLotLineRequest)
println(" ✓ BagLotLine created successfully for item ${item.code}")
if (!deferAggregate) {
try {
val item = savedStockOutLine.item
val inventoryLotLine = savedStockOutLine.inventoryLotLine
val reqDeltaQty = request.qty ?: 0.0

// 只在状态为 completed 或 partially_completed,且数量增加时创建 BagLotLine
val isCompletedOrPartiallyCompleted = request.status == "completed" ||
request.status == "partially_completed" ||
request.status == "PARTIALLY_COMPLETE"

if (item?.isBag == true &&
inventoryLotLine != null &&
isCompletedOrPartiallyCompleted &&
reqDeltaQty > 0
) {

println(" Item isBag=true, creating BagLotLine...")

val bag = bagRepository.findByItemIdAndDeletedIsFalse(item.id!!)

if (bag != null) {
val lotNo = inventoryLotLine.inventoryLot?.lotNo
if (lotNo != null) {
val createBagLotLineRequest = CreateBagLotLineRequest(
bagId = bag.id!!,
lotId = inventoryLotLine.inventoryLot?.id ?: 0L,
itemId = item.id!!,
lotNo = lotNo,
stockQty = reqDeltaQty.toInt(),
date = LocalDate.now(),
time = LocalTime.now(),
stockOutLineId = savedStockOutLine.id
)

bagService.createBagLotLinesByBagId(createBagLotLineRequest)
println(" ✓ BagLotLine created successfully for item ${item.code}")
} else {
println(" Warning: lotNo is null, skipping BagLotLine creation")
}
} else { } else {
println(" Warning: lotNo is null, skipping BagLotLine creation")
println(" Warning: Bag not found for itemId ${item.id}, skipping BagLotLine creation")
} }
} else {
println(" Warning: Bag not found for itemId ${item.id}, skipping BagLotLine creation")
} }
} catch (e: Exception) {
println(" Error creating BagLotLine: ${e.message}")
e.printStackTrace()
// 不中断主流程,只记录错误
} }
} catch (e: Exception) {
println(" Error creating BagLotLine: ${e.message}")
e.printStackTrace()
// 不中断主流程,只记录错误
} }
// 3. 如果被拒绝,触发特殊处理 // 3. 如果被拒绝,触发特殊处理
if (request.status == "rejected" || request.status == "REJECTED") { if (request.status == "rejected" || request.status == "REJECTED") {
@@ -713,26 +763,18 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
handleLotRejectionFromStockOutLine(savedStockOutLine) handleLotRejectionFromStockOutLine(savedStockOutLine)
} }
// 4. 自动刷 pickOrderLine 状态
val pickOrderLine = savedStockOutLine.pickOrderLine
if (pickOrderLine != null) {
checkIsStockOutLineCompleted(pickOrderLine.id)
// 5. 自动刷 pickOrder 状态
val pickOrder = pickOrderLine.pickOrder
if (pickOrder != null && pickOrder.id != null) {
// ✅ 修复:使用 repository 查询所有 lines,避免懒加载问题
val allLines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(pickOrder.id!!)
val allCompleted = allLines.all { it.status == PickOrderLineStatus.COMPLETED || it.status == PickOrderLineStatus.PARTIALLY_COMPLETE }
if (allCompleted && allLines.isNotEmpty()) {
pickOrder.status = PickOrderStatus.COMPLETED
pickOrderRepository.save(pickOrder)
completeDoForPickOrder(pickOrder.id!!)
completeDoIfAllPickOrdersCompleted(pickOrder.id!!)
}
// 4–5. 自动刷 pickOrderLine / pickOrder 状态(批次提交在结尾统一处理)
if (!deferAggregate) {
val pickOrderLine = savedStockOutLine.pickOrderLine
if (pickOrderLine != null) {
checkIsStockOutLineCompleted(pickOrderLine.id)
pickOrderLine.pickOrder?.id?.let { refreshPickOrderHeaderIfAllLinesCompleted(it) }
} }
} }
val mappedSavedStockOutLine = stockOutLineRepository.findStockOutLineInfoById(savedStockOutLine.id!!)

val mappedSavedStockOutLine =
if (deferAggregate) null
else stockOutLineRepository.findStockOutLineInfoById(savedStockOutLine.id!!)
return MessageResponse( return MessageResponse(
id = savedStockOutLine.id, id = savedStockOutLine.id,
name = savedStockOutLine.inventoryLotLine?.inventoryLot?.lotNo?: "", name = savedStockOutLine.inventoryLotLine?.inventoryLot?.lotNo?: "",
@@ -1231,140 +1273,120 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
// 先前預載的 inventories 從未使用(save 已註解),已移除以避免批量提交無故失敗。 // 先前預載的 inventories 從未使用(save 已註解),已移除以避免批量提交無故失敗。
val lotLineIds = request.lines.mapNotNull { it.inventoryLotLineId } val lotLineIds = request.lines.mapNotNull { it.inventoryLotLineId }
println("Loading ${lotLineIds.size} lot lines...") println("Loading ${lotLineIds.size} lot lines...")
val lotLines = if (lotLineIds.isNotEmpty()) {
inventoryLotLineRepository.findAllById(lotLineIds).associateBy { it.id }
val lotLinesById: MutableMap<Long, InventoryLotLine> = if (lotLineIds.isNotEmpty()) {
inventoryLotLineRepository.findAllById(lotLineIds).associateByTo(mutableMapOf()) { it.id!! }
} else { } else {
emptyMap()
mutableMapOf()
} }


// 2) Bulk load all stock out lines to get current quantities
// 2) Bulk load all stock out lines(批次內就地更新)
val stockOutLineIds = request.lines.map { it.stockOutLineId } val stockOutLineIds = request.lines.map { it.stockOutLineId }
println("Loading ${stockOutLineIds.size} stock out lines...") println("Loading ${stockOutLineIds.size} stock out lines...")
val stockOutLines = stockOutLineRepository.findAllById(stockOutLineIds).associateBy { it.id }
val stockOutLinesById =
stockOutLineRepository.findAllById(stockOutLineIds).associateByTo(mutableMapOf()) { it.id!! }


// 3) Process each request line
// 3) Process each request line(直接 applyStockOutLineDelta + 內聯扣庫存,避免 updateStatus 與雙重查詢)
request.lines.forEach { line: QrPickSubmitLineRequest -> request.lines.forEach { line: QrPickSubmitLineRequest ->
val lineTrace = "$traceId|SOL=${line.stockOutLineId}" val lineTrace = "$traceId|SOL=${line.stockOutLineId}"
try { try {
println("[$lineTrace] Processing line, noLot=${line.noLot}")
val solEntity = stockOutLinesById[line.stockOutLineId]
?: throw IllegalStateException("StockOutLine ${line.stockOutLineId} not found")

if (line.noLot) { if (line.noLot) {
// noLot branch
updateStatus(UpdateStockOutLineStatusRequest(
id = line.stockOutLineId,
status = "completed",
qty = 0.0
))
val updated = applyStockOutLineDelta(
stockOutLine = solEntity,
deltaQty = BigDecimal.ZERO,
newStatus = "completed",
skipInventoryWrite = true,
skipLedgerWrite = true,
skipTryCompletePickOrderLine = true,
deferPersistenceFlush = true
)
stockOutLinesById[line.stockOutLineId] = updated
processedIds += line.stockOutLineId processedIds += line.stockOutLineId
println("[$lineTrace] noLot processed (status->completed, qty=0)")
return@forEach return@forEach
} }


// 修复:从数据库获取当前实际数量
val stockOutLine = stockOutLines[line.stockOutLineId]
?: throw IllegalStateException("StockOutLine ${line.stockOutLineId} not found")
val currentStatus = stockOutLine.status?.trim()?.lowercase()
val currentStatus = solEntity.status?.trim()?.lowercase()
if (currentStatus == "completed" || currentStatus == "complete") { if (currentStatus == "completed" || currentStatus == "complete") {
println("[$lineTrace] Skip because current status is already completed")
return@forEach return@forEach
} }
val currentActual = (stockOutLine.qty ?: 0.0).toBigDecimal()
val targetActual = line.actualPickQty ?: BigDecimal.ZERO
val required = line.requiredQty ?: BigDecimal.ZERO
println("[$lineTrace] currentActual=$currentActual, targetActual=$targetActual, required=$required")
// 计算增量(前端发送的是目标累计值)
val submitQty = targetActual - currentActual
println("[$lineTrace] submitQty(increment)=$submitQty")
// 使用前端发送的状态,否则根据数量自动判断
val newStatus = line.stockOutLineStatus
?: if (targetActual >= required) "completed" else "partially_completed"
if (submitQty <= BigDecimal.ZERO) {
println("[$lineTrace] submitQty<=0, only update status, skip inventory+ledger")
updateStatus(
UpdateStockOutLineStatusRequest(
id = line.stockOutLineId,
status = newStatus, // 例如前端传来的 "completed"
qty = 0.0, // 不改变现有 qty
skipLedgerWrite = true,
skipInventoryWrite = true
)
)
// 直接跳过后面的库存扣减逻辑
return@forEach

val currentActual = (solEntity.qty ?: 0.0).toBigDecimal()
val targetActual = line.actualPickQty ?: BigDecimal.ZERO
val required = line.requiredQty ?: BigDecimal.ZERO
val submitQty = targetActual - currentActual
val newStatus = line.stockOutLineStatus
?: if (targetActual >= required) "completed" else "partially_completed"

if (submitQty <= BigDecimal.ZERO) {
val updated = applyStockOutLineDelta(
stockOutLine = solEntity,
deltaQty = BigDecimal.ZERO,
newStatus = newStatus,
skipInventoryWrite = true,
skipLedgerWrite = true,
skipTryCompletePickOrderLine = true,
deferPersistenceFlush = true
)
stockOutLinesById[line.stockOutLineId] = updated
return@forEach
}

val savedSol = applyStockOutLineDelta(
stockOutLine = solEntity,
deltaQty = submitQty,
newStatus = newStatus,
skipInventoryWrite = true,
skipLedgerWrite = true,
skipTryCompletePickOrderLine = true,
deferPersistenceFlush = true
)
stockOutLinesById[line.stockOutLineId] = savedSol

val actualInventoryLotLineId =
line.inventoryLotLineId ?: savedSol.inventoryLotLine?.id

if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) {
val ill = lotLinesById[actualInventoryLotLineId]
?: inventoryLotLineRepository.findById(actualInventoryLotLineId).orElseThrow().also {
lotLinesById[actualInventoryLotLineId] = it
} }
// 只有 submitQty > 0 时,才真正增加 qty 并触发库存扣减
updateStatus(
UpdateStockOutLineStatusRequest(
id = line.stockOutLineId,
status = newStatus,
qty = submitQty.toDouble(),
skipLedgerWrite = true,
skipInventoryWrite = true
)
val item = savedSol.item
val inventoryBeforeUpdate = item?.id?.let { itemId ->
itemUomService.findInventoryForItemBaseUom(itemId)
}
val onHandQtyBeforeUpdate =
(inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
applyPickToInventoryLotLineInBatch(ill, submitQty)
createStockLedgerForPickDelta(
stockOutLine = savedSol,
deltaQty = submitQty,
onHandQtyBeforeUpdate = onHandQtyBeforeUpdate,
traceTag = lineTrace,
flushAfterSave = false
) )
println("[$lineTrace] stock_out_line qty/status updated with delta=$submitQty (inventory+ledger deferred)")
} else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) {
val item = savedSol.item
val inventoryBeforeUpdate = item?.id?.let { itemId ->
itemUomService.findInventoryForItemBaseUom(itemId)
}
val onHandQtyBeforeUpdate =
(inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
createStockLedgerForPickDelta(
stockOutLine = savedSol,
deltaQty = submitQty,
onHandQtyBeforeUpdate = onHandQtyBeforeUpdate,
traceTag = lineTrace,
flushAfterSave = false
)
}


// Inventory updates - 修复:使用增量数量
// ✅ 修复:如果 inventoryLotLineId 为 null,从 stock_out_line 中获取
val actualInventoryLotLineId = line.inventoryLotLineId
?: stockOutLine.inventoryLotLine?.id
// 在 newBatchSubmit 方法中,修改这部分代码(大约在 1169-1185 行)
if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) {
println("[$lineTrace] Updating inventory lot line $actualInventoryLotLineId with qty=$submitQty")
// ✅ 修复:在更新 inventory_lot_line 之前获取 inventory 的当前 onHandQty
val item = stockOutLine.item
val inventoryBeforeUpdate = item?.id?.let { itemId ->
itemUomService.findInventoryForItemBaseUom(itemId)
}
val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
println("[$lineTrace] Inventory before update: onHandQty=$onHandQtyBeforeUpdate")
inventoryLotLineService.updateInventoryLotLineQuantities(
UpdateInventoryLotLineQuantitiesRequest(
inventoryLotLineId = actualInventoryLotLineId,
qty = submitQty,
operation = "pick"
)
)
if (submitQty > BigDecimal.ZERO) {
// ✅ 修复:传入更新前的 onHandQty,让 createStockLedgerForPickDelta 使用它
createStockLedgerForPickDelta(line.stockOutLineId, submitQty, onHandQtyBeforeUpdate, lineTrace)
}
} else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) {
// ✅ 修复:即使没有 inventoryLotLineId,也应该获取 inventory.onHandQty
val item = stockOutLine.item
val inventoryBeforeUpdate = item?.id?.let { itemId ->
itemUomService.findInventoryForItemBaseUom(itemId)
}
val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
println("[$lineTrace] Warning: No inventoryLotLineId, still trying ledger creation")
createStockLedgerForPickDelta(line.stockOutLineId, submitQty, onHandQtyBeforeUpdate, lineTrace)
}
try { try {
val stockOutLine = stockOutLines[line.stockOutLineId]
val item = stockOutLine?.item
val inventoryLotLine = line.inventoryLotLineId?.let { lotLines[it] }
val item = savedSol.item
val inventoryLotLine = line.inventoryLotLineId?.let { lid -> lotLinesById[lid] }
if (item?.isBag == true && inventoryLotLine != null && submitQty > BigDecimal.ZERO) { if (item?.isBag == true && inventoryLotLine != null && submitQty > BigDecimal.ZERO) {
println(" Item isBag=true, creating BagLotLine...")
// 根据 itemId 查找对应的 Bag
val bag = bagRepository.findByItemIdAndDeletedIsFalse(item.id!!) val bag = bagRepository.findByItemIdAndDeletedIsFalse(item.id!!)
if (bag != null) { if (bag != null) {
val lotNo = inventoryLotLine.inventoryLot?.lotNo val lotNo = inventoryLotLine.inventoryLot?.lotNo
if (lotNo != null) { if (lotNo != null) {
@@ -1372,29 +1394,21 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) {
bagId = bag.id!!, bagId = bag.id!!,
lotId = inventoryLotLine.inventoryLot?.id ?: 0L, lotId = inventoryLotLine.inventoryLot?.id ?: 0L,
itemId = item.id!!, itemId = item.id!!,
stockOutLineId = stockOutLine.id ,
stockOutLineId = savedSol.id,
lotNo = lotNo, lotNo = lotNo,
stockQty = submitQty.toInt(), // 转换为 Int
stockQty = submitQty.toInt(),
date = LocalDate.now(), date = LocalDate.now(),
time = LocalTime.now() time = LocalTime.now()
) )

bagService.createBagLotLinesByBagId(createBagLotLineRequest) bagService.createBagLotLinesByBagId(createBagLotLineRequest)
println(" ✓ BagLotLine created successfully for item ${item.code}")
} else {
println(" Warning: lotNo is null, skipping BagLotLine creation")
} }
} else {
println(" Warning: Bag not found for itemId ${item.id}, skipping BagLotLine creation")
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
println(" Error creating BagLotLine: ${e.message}") println(" Error creating BagLotLine: ${e.message}")
e.printStackTrace() e.printStackTrace()
// 不中断主流程,只记录错误
} }
processedIds += line.stockOutLineId processedIds += line.stockOutLineId
println("[$lineTrace] Line processed successfully")
} catch (e: Exception) { } catch (e: Exception) {
println("[$lineTrace] Error processing line: ${e.message}") println("[$lineTrace] Error processing line: ${e.message}")
e.printStackTrace() e.printStackTrace()
@@ -1402,39 +1416,39 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) {
} }
} }


// 4) 移除:不需要保存 lotLines,因为它们没有被修改
// inventoryLotLineRepository.saveAll(lotLines.values.toList())
entityManager.flush()


// ✅ 修复:批处理完成后,检查所有受影响的 pick order lines 是否应该标记为完成
val affectedPickOrderIds = request.lines
.mapNotNull { line ->
stockOutLines[line.stockOutLineId]?.pickOrderLine?.pickOrder?.id
}
.distinct()
// 批次內已 defer:此處只處理本批有碰到的 POL,再刷新所屬 pick order 表頭,最後每個 conso 只掃一次
val polIdsTouched = request.lines.mapNotNull { line ->
stockOutLinesById[line.stockOutLineId]?.pickOrderLine?.id
}.distinct()


val allPickOrderLineIdsToCheck = if (affectedPickOrderIds.isNotEmpty()) {
affectedPickOrderIds.flatMap { pickOrderId ->
pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(pickOrderId).mapNotNull { it.id }
}.distinct()
} else {
emptyList()
println("=== Checking ${polIdsTouched.size} pick order lines touched by batch ===")
polIdsTouched.forEach { pickOrderLineId ->
try {
checkIsStockOutLineCompleted(pickOrderLineId, quiet = true)
} catch (e: Exception) {
println("Error checking pick order line $pickOrderLineId: ${e.message}")
}
} }


println("=== Checking ${allPickOrderLineIdsToCheck.size} pick order lines (all lines of affected pick orders) after batch submit ===")
allPickOrderLineIdsToCheck.forEach { pickOrderLineId ->
val pickOrderIdsTouched = request.lines.mapNotNull { line ->
stockOutLinesById[line.stockOutLineId]?.pickOrderLine?.pickOrder?.id
}.distinct()

pickOrderIdsTouched.forEach { pickOrderId ->
try { try {
checkIsStockOutLineCompleted(pickOrderLineId)
refreshPickOrderHeaderIfAllLinesCompleted(pickOrderId)
} catch (e: Exception) { } catch (e: Exception) {
println("Error checking pick order line $pickOrderLineId: ${e.message}")
println("Error refreshing pick order header for pickOrderId=$pickOrderId: ${e.message}")
} }
} }
val affectedConsoCodes = affectedPickOrderIds
.mapNotNull { pickOrderId ->
val po = pickOrderRepository.findById(pickOrderId).orElse(null)
po?.consoCode
}
.filter { !it.isNullOrBlank() }
.distinct()

val affectedConsoCodes = pickOrderIdsTouched.mapNotNull { pickOrderId ->
pickOrderRepository.findById(pickOrderId).orElse(null)?.consoCode
}
.filter { !it.isNullOrBlank() }
.distinct()


println("=== Checking completion by consoCode for ${affectedConsoCodes.size} affected consoCodes after batch submit ===") println("=== Checking completion by consoCode for ${affectedConsoCodes.size} affected consoCodes after batch submit ===")
affectedConsoCodes.forEach { consoCode -> affectedConsoCodes.forEach { consoCode ->
@@ -1577,63 +1591,72 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) {




private fun createStockLedgerForPickDelta( private fun createStockLedgerForPickDelta(
stockOutLineId: Long,
stockOutLine: StockOutLine,
deltaQty: BigDecimal, deltaQty: BigDecimal,
onHandQtyBeforeUpdate: Double? = null, // ✅ 新增参数:更新前的 onHandQty
traceTag: String? = null
onHandQtyBeforeUpdate: Double? = null,
traceTag: String? = null,
flushAfterSave: Boolean = true
) { ) {
val tracePrefix = traceTag?.let { "[$it] " } ?: "[SOL=$stockOutLineId] "
val solId = stockOutLine.id
val tracePrefix = traceTag?.let { "[$it] " } ?: "[SOL=$solId] "
if (deltaQty <= BigDecimal.ZERO) { if (deltaQty <= BigDecimal.ZERO) {
println("${tracePrefix}Skip ledger creation: deltaQty <= 0 (deltaQty=$deltaQty)")
return
}
val sol = stockOutLineRepository.findById(stockOutLineId).orElse(null)
if (sol == null) {
println("${tracePrefix}Skip ledger creation: stockOutLine not found")
if (flushAfterSave) {
println("${tracePrefix}Skip ledger creation: deltaQty <= 0 (deltaQty=$deltaQty)")
}
return return
} }
val item = sol.item

val item = stockOutLine.item
if (item == null) { if (item == null) {
println("${tracePrefix}Skip ledger creation: stockOutLine.item is null")
if (flushAfterSave) {
println("${tracePrefix}Skip ledger creation: stockOutLine.item is null")
}
return return
} }
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!)
if (inventory == null) { if (inventory == null) {
println("${tracePrefix}Skip ledger creation: inventory not found by itemId=${item.id}")
if (flushAfterSave) {
println("${tracePrefix}Skip ledger creation: inventory not found by itemId=${item.id}")
}
return return
} }
val previousBalance = resolvePreviousBalance( val previousBalance = resolvePreviousBalance(
itemId = item.id!!, itemId = item.id!!,
inventory = inventory, inventory = inventory,
onHandQtyBeforeUpdate = onHandQtyBeforeUpdate onHandQtyBeforeUpdate = onHandQtyBeforeUpdate
) )
val newBalance = previousBalance - deltaQty.toDouble() val newBalance = previousBalance - deltaQty.toDouble()
println("${tracePrefix}Creating ledger: previousBalance=$previousBalance, deltaQty=$deltaQty, newBalance=$newBalance, inventoryId=${inventory.id}")
if (onHandQtyBeforeUpdate != null) {
println("${tracePrefix}Using onHandQtyBeforeUpdate: $onHandQtyBeforeUpdate")

if (flushAfterSave) {
println("${tracePrefix}Creating ledger: previousBalance=$previousBalance, deltaQty=$deltaQty, newBalance=$newBalance, inventoryId=${inventory.id}")
if (onHandQtyBeforeUpdate != null) {
println("${tracePrefix}Using onHandQtyBeforeUpdate: $onHandQtyBeforeUpdate")
}
} }
val ledger = StockLedger().apply { val ledger = StockLedger().apply {
this.stockOutLine = sol
this.stockOutLine = stockOutLine
this.inventory = inventory this.inventory = inventory
this.inQty = null this.inQty = null
this.outQty = deltaQty.toDouble()
this.outQty = deltaQty.toDouble()
this.balance = newBalance this.balance = newBalance
this.type = "NOR"
this.type = "NOR"
this.itemId = item.id this.itemId = item.id
this.itemCode = item.code this.itemCode = item.code
this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id
?: inventory.uom?.id ?: inventory.uom?.id
this.date = LocalDate.now() this.date = LocalDate.now()
} }
stockLedgerRepository.saveAndFlush(ledger)
println("${tracePrefix}Ledger created successfully for stockOutLineId=$stockOutLineId")

if (flushAfterSave) {
stockLedgerRepository.saveAndFlush(ledger)
println("${tracePrefix}Ledger created successfully for stockOutLineId=$solId")
} else {
stockLedgerRepository.save(ledger)
}
} }


private fun resolvePreviousBalance( private fun resolvePreviousBalance(
@@ -1904,20 +1927,20 @@ open fun batchScan(request: com.ffii.fpsms.modules.stock.web.model.BatchScanRequ
} }
@Transactional @Transactional
fun applyStockOutLineDelta( fun applyStockOutLineDelta(
stockOutLineId: Long,
stockOutLine: StockOutLine,
deltaQty: BigDecimal, deltaQty: BigDecimal,
newStatus: String?, newStatus: String?,
typeOverride: String? = null, typeOverride: String? = null,
skipInventoryWrite: Boolean = false, skipInventoryWrite: Boolean = false,
skipLedgerWrite: Boolean = false, skipLedgerWrite: Boolean = false,
operator: String? = null, operator: String? = null,
eventTime: LocalDateTime = LocalDateTime.now()
eventTime: LocalDateTime = LocalDateTime.now(),
skipTryCompletePickOrderLine: Boolean = false,
deferPersistenceFlush: Boolean = false
): StockOutLine { ): StockOutLine {
require(deltaQty >= BigDecimal.ZERO) { "deltaQty cannot be negative" } require(deltaQty >= BigDecimal.ZERO) { "deltaQty cannot be negative" }


val sol = stockOutLineRepository.findById(stockOutLineId).orElseThrow {
IllegalArgumentException("StockOutLine not found: $stockOutLineId")
}
val sol = stockOutLine


// 1) update stock_out_line qty/status/time // 1) update stock_out_line qty/status/time
val currentQty = BigDecimal(sol.qty?.toString() ?: "0") val currentQty = BigDecimal(sol.qty?.toString() ?: "0")
@@ -1941,7 +1964,9 @@ fun applyStockOutLineDelta(
if (!operator.isNullOrBlank()) { if (!operator.isNullOrBlank()) {
sol.modifiedBy = operator sol.modifiedBy = operator
} }
val savedSol = stockOutLineRepository.saveAndFlush(sol)
val savedSol =
if (deferPersistenceFlush) stockOutLineRepository.save(sol)
else stockOutLineRepository.saveAndFlush(sol)


// Nothing to post if no delta // Nothing to post if no delta
if (deltaQty == BigDecimal.ZERO || !isPickEnd) { if (deltaQty == BigDecimal.ZERO || !isPickEnd) {
@@ -1973,7 +1998,8 @@ fun applyStockOutLineDelta(
if (!operator.isNullOrBlank()) { if (!operator.isNullOrBlank()) {
latestLotLine.modifiedBy = operator latestLotLine.modifiedBy = operator
} }
inventoryLotLineRepository.saveAndFlush(latestLotLine)
if (deferPersistenceFlush) inventoryLotLineRepository.save(latestLotLine)
else inventoryLotLineRepository.saveAndFlush(latestLotLine)
} }
} else { } else {
val lotUpdateResult = inventoryLotLineService.updateInventoryLotLineQuantities( val lotUpdateResult = inventoryLotLineService.updateInventoryLotLineQuantities(
@@ -2035,11 +2061,12 @@ fun applyStockOutLineDelta(
this.modifiedBy = operator this.modifiedBy = operator
} }
} }
stockLedgerRepository.saveAndFlush(ledger)
if (deferPersistenceFlush) stockLedgerRepository.save(ledger)
else stockLedgerRepository.saveAndFlush(ledger)
} }


// 4) existing side-effects keep same behavior
if (isEndStatus(savedSol.status)) {
// 4) existing side-effects keep same behavior (batch submit defers to end of newBatchSubmit)
if (!skipTryCompletePickOrderLine && isEndStatus(savedSol.status)) {
savedSol.pickOrderLine?.id?.let { tryCompletePickOrderLine(it) } savedSol.pickOrderLine?.id?.let { tryCompletePickOrderLine(it) }
} }




+ 21
- 27
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt Datei anzeigen

@@ -2317,28 +2317,27 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch(






fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes<StockTransactionResponse> {
open fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes<StockTransactionResponse> {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
// 添加调试日志
println("Search request received: itemCode=${request.itemCode}, itemName=${request.itemName}, type=${request.type}, startDate=${request.startDate}, endDate=${request.endDate}")
// 验证:itemCode 或 itemName 至少一个不为 null 或空字符串

println(
"Search request received: itemCode=${request.itemCode}, itemName=${request.itemName}, " +
"type=${request.type}, startDate=${request.startDate}, endDate=${request.endDate}"
)

val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() } val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() }
val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() } val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() }
if (itemCode == null && itemName == null) { if (itemCode == null && itemName == null) {
println("Search validation failed: both itemCode and itemName are null/empty") println("Search validation failed: both itemCode and itemName are null/empty")
return RecordsRes(emptyList(), 0) return RecordsRes(emptyList(), 0)
} }
// request.startDate 和 request.endDate 已经是 LocalDate? 类型,不需要转换

val startDate = request.startDate val startDate = request.startDate
val endDate = request.endDate val endDate = request.endDate
println("Processed params: itemCode=$itemCode, itemName=$itemName, startDate=$startDate, endDate=$endDate") println("Processed params: itemCode=$itemCode, itemName=$itemName, startDate=$startDate, endDate=$endDate")
// 使用 Repository 查询(更简单、更快)

val total = stockLedgerRepository.countStockTransactions( val total = stockLedgerRepository.countStockTransactions(
itemCode = itemCode, itemCode = itemCode,
itemName = itemName, itemName = itemName,
@@ -2346,20 +2345,17 @@ fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes<
startDate = startDate, startDate = startDate,
endDate = endDate endDate = endDate
) )
println("Total count: $total") println("Total count: $total")
// 如果 pageSize 是默认值(100)或未设置,使用 total 作为 pageSize

val actualPageSize = if (request.pageSize == 100) { val actualPageSize = if (request.pageSize == 100) {
total.toInt().coerceAtLeast(1) total.toInt().coerceAtLeast(1)
} else { } else {
request.pageSize request.pageSize
} }
// 计算 offset

val offset = request.pageNum * actualPageSize val offset = request.pageNum * actualPageSize
// 查询所有符合条件的记录

val ledgers = stockLedgerRepository.findStockTransactions( val ledgers = stockLedgerRepository.findStockTransactions(
itemCode = itemCode, itemCode = itemCode,
itemName = itemName, itemName = itemName,
@@ -2367,13 +2363,13 @@ fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes<
startDate = startDate, startDate = startDate,
endDate = endDate endDate = endDate
) )
println("Found ${ledgers.size} ledgers") println("Found ${ledgers.size} ledgers")
val transactions = ledgers.map { ledger -> val transactions = ledgers.map { ledger ->
val stockInLine = ledger.stockInLine val stockInLine = ledger.stockInLine
val stockOutLine = ledger.stockOutLine val stockOutLine = ledger.stockOutLine
StockTransactionResponse( StockTransactionResponse(
id = stockInLine?.id ?: stockOutLine?.id ?: 0L, id = stockInLine?.id ?: stockOutLine?.id ?: 0L,
transactionType = if (ledger.inQty != null && ledger.inQty!! > 0) "IN" else "OUT", transactionType = if (ledger.inQty != null && ledger.inQty!! > 0) "IN" else "OUT",
@@ -2400,20 +2396,18 @@ fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes<
remarks = stockInLine?.remarks remarks = stockInLine?.remarks
) )
} }
// 按 date 排序(从旧到新),如果 date 为 null 则使用 transactionDate 的日期部分

val sortedTransactions = transactions.sortedWith( val sortedTransactions = transactions.sortedWith(
compareBy<StockTransactionResponse>( compareBy<StockTransactionResponse>(
{ it.date ?: it.transactionDate?.toLocalDate() }, { it.date ?: it.transactionDate?.toLocalDate() },
{ it.transactionDate } { it.transactionDate }
) )
) )
// 应用分页

val paginatedTransactions = sortedTransactions.drop(offset).take(actualPageSize) val paginatedTransactions = sortedTransactions.drop(offset).take(actualPageSize)
val totalTime = System.currentTimeMillis() - startTime val totalTime = System.currentTimeMillis() - startTime
println("Total time (Repository query): ${totalTime}ms, count: ${paginatedTransactions.size}, total: $total") println("Total time (Repository query): ${totalTime}ms, count: ${paginatedTransactions.size}, total: $total")
return RecordsRes(paginatedTransactions, total.toInt()) return RecordsRes(paginatedTransactions, total.toInt())
} }
} }

+ 3
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt Datei anzeigen

@@ -61,7 +61,9 @@ data class UpdateStockOutLineStatusRequest(
val qty: Double? = null, val qty: Double? = null,
val remarks: String? = null, val remarks: String? = null,
val skipLedgerWrite: Boolean? = false, val skipLedgerWrite: Boolean? = false,
val skipInventoryWrite: Boolean? = false
val skipInventoryWrite: Boolean? = false,
/** When true (batch submit path): skip per-line POL/PO rollup, conso completion scan, duplicate BagLotLine; caller runs these once at end. */
val deferAggregatePickOrderEffects: Boolean? = false
) )
data class UpdateStockOutLineStatusByQRCodeAndLotNoRequest( data class UpdateStockOutLineStatusByQRCodeAndLotNoRequest(
val pickOrderLineId: Long, val pickOrderLineId: Long,


+ 1
- 1
src/main/resources/jasper/SemiFGProductionAnalysisReport.jrxml Datei anzeigen

@@ -535,7 +535,7 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </textElement>
<text><![CDATA[總和]]></text>
<text><![CDATA[上架總計]]></text>
</staticText> </staticText>
<staticText> <staticText>
<reportElement x="560" y="0" width="45" height="20" uuid="85c77a9b-c044-4bc2-8cd9-3b0058e4b74e"> <reportElement x="560" y="0" width="45" height="20" uuid="85c77a9b-c044-4bc2-8cd9-3b0058e4b74e">


+ 2
- 2
src/main/resources/jasper/StockInTraceabilityReport.jrxml Datei anzeigen

@@ -87,7 +87,7 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/> <font fontName="微軟正黑體" size="10"/>
</textElement> </textElement>
<text><![CDATA[總入倉數量:]]></text>
<text><![CDATA[總上架數量:]]></text>
</staticText> </staticText>
<textField> <textField>
<reportElement x="280" y="0" width="50" height="18" uuid="d98c4478-22bd-4fd6-9be4-b3777f91de6d"> <reportElement x="280" y="0" width="50" height="18" uuid="d98c4478-22bd-4fd6-9be4-b3777f91de6d">
@@ -159,7 +159,7 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/> <font fontName="微軟正黑體" size="10"/>
</textElement> </textElement>
<text><![CDATA[入庫數量]]></text>
<text><![CDATA[上架數量]]></text>
</staticText> </staticText>
<staticText> <staticText>
<reportElement stretchType="RelativeToTallestObject" x="10" y="80" width="110" height="28" uuid="3fa7c301-1c2a-430b-8985-338ebf7aa6cf"> <reportElement stretchType="RelativeToTallestObject" x="10" y="80" width="110" height="28" uuid="3fa7c301-1c2a-430b-8985-338ebf7aa6cf">


Laden…
Abbrechen
Speichern