kelvin.yau 8 小時之前
父節點
當前提交
a6d2b67cd5
共有 15 個文件被更改,包括 574 次插入402 次删除
  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 查看文件

@@ -107,4 +107,10 @@ data class ReleasedDoPickOrderListItem(
data class AssignByDoPickOrderIdRequest(
val userId: 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 查看文件

@@ -1627,11 +1627,18 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str
if (completedDate != null) {
val from = completedDate.atStartOfDay()
val toExclusive = completedDate.plusDays(1).atStartOfDay()
/*
pickOrderRepository.findAllCompletedWithJobOrderPlanEndOnDay(
PickOrderStatus.COMPLETED,
from,
toExclusive,
)
*/
pickOrderRepository.findAllCompletedWithJobOrderPlanStartOnDay(
PickOrderStatus.COMPLETED,
from,
toExclusive,
)
} else {
pickOrderRepository
.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)}"
},
"pickOrderStatus" to pickOrder.status,
"completedDate" to jobOrder.planEnd,
"completedDate" to jobOrder.planStart,
"jobOrderId" to jobOrder.id,
"jobOrderCode" to jobOrder.code,
"jobOrderName" to jobOrder.bom?.name,


+ 17
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt 查看文件

@@ -112,4 +112,21 @@ fun findAllCompletedWithJobOrderPlanEndOnDay(
@Param("planEndFrom") planEndFrom: LocalDateTime,
@Param("planEndToExclusive") planEndToExclusive: LocalDateTime,
): 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 查看文件

@@ -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) {
val stockOutLines = stockOutLineRepository
.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse(


+ 6
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt 查看文件

@@ -20,6 +20,12 @@ class PickExecutionIssueController(
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}")
fun getPickExecutionIssuesByPickOrder(@PathVariable pickOrderId: Long): List<PickExecutionIssue> {
return pickExecutionIssueService.getPickExecutionIssuesByPickOrder(pickOrderId)


+ 11
- 4
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt 查看文件

@@ -652,6 +652,7 @@ return result
* 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.
* 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(
stockCategory: String?,
@@ -673,13 +674,13 @@ return result
val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) {
val formattedDate = lastInDateStart.replace("/", "-")
args["lastInDateStart"] = formattedDate
"AND DATE(sil.receiptDate) >= DATE(:lastInDateStart)"
"AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) >= DATE(:lastInDateStart)"
} else ""
val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) {
val formattedDate = lastInDateEnd.replace("/", "-")
args["lastInDateEnd"] = formattedDate
"AND DATE(sil.receiptDate) <= DATE(:lastInDateEnd)"
"AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) <= DATE(:lastInDateEnd)"
} else ""

val sql = """
@@ -691,7 +692,7 @@ return result
COALESCE(sil.lotNo, il.lotNo, '') as lotNo,
COALESCE(DATE_FORMAT(COALESCE(sil.expiryDate, il.expiryDate), '%Y-%m-%d'), '') as expiryDate,
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,
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,
@@ -706,7 +707,7 @@ return result
COALESCE(wh.code, '') as storeLocation,
COALESCE(sp_si.code, sp_po.code, '') as supplierID,
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
FROM stock_in_line sil
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 uom_conversion uc ON iu.uomId = uc.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 warehouse wh ON ill.warehouseId = wh.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 查看文件

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

/**
* 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(
stockCategory: String?,
@@ -72,82 +74,72 @@ class SemiFGProductionAnalysisReportService(
lastOutDateEnd: String?
): List<Map<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()) {
// When itemCode is provided, skip stockCategory filter
""
} 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 {
""
}
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") {
args["year"] = year
"AND YEAR(si.productionDate) = :year"
} else {
""
}
val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) {
val formattedDate = lastOutDateStart.replace("/", "-")
args["lastOutDateStart"] = formattedDate
"AND DATE(si.productionDate) >= DATE(:lastOutDateStart)"
"AND si.productionDate IS NOT NULL AND DATE(si.productionDate) >= DATE(:lastOutDateStart)"
} else ""
val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) {
val formattedDate = lastOutDateEnd.replace("/", "-")
args["lastOutDateEnd"] = formattedDate
"AND DATE(si.productionDate) <= DATE(:lastOutDateEnd)"
"AND si.productionDate IS NOT NULL AND DATE(si.productionDate) <= DATE(:lastOutDateEnd)"
} else ""

val sql = """
WITH base AS (
WITH qr_agg AS (
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
$stockSubCategorySql
$itemCodeSql
@@ -155,7 +147,6 @@ class SemiFGProductionAnalysisReportService(
$lastOutDateStartSql
$lastOutDateEndSql
),
-- Deduplicate: stock_in_line can join to multiple stock_ledger rows; acceptedQty must be counted once per stockInLineId.
dedup AS (
SELECT
itemNo,
@@ -164,36 +155,34 @@ class SemiFGProductionAnalysisReportService(
unitOfMeasure,
mon,
stockInLineId,
MAX(COALESCE(acceptedQty, 0)) as acceptedQty,
MAX(jobOrderId) as jobOrderId
MAX(linePutAwayQty) AS linePutAwayQty
FROM base
GROUP BY itemNo, itemName, stockSubCategory, unitOfMeasure, mon, stockInLineId
)
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
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
""".trimIndent()
return jdbcDao.queryForList(sql, args)
}

@@ -208,25 +197,15 @@ class SemiFGProductionAnalysisReportService(
val args = mutableMapOf<String, Any>()
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 {
""
}

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
INNER JOIN items it ON it.code = b.code AND it.deleted = false
WHERE b.deleted = false
AND b.code IS NOT NULL
AND b.code != ''
@@ -255,25 +234,15 @@ class SemiFGProductionAnalysisReportService(
val args = mutableMapOf<String, Any>()
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 {
""
}

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
INNER JOIN items it ON it.code = b.code AND it.deleted = false
WHERE b.deleted = false
AND b.code IS NOT NULL
AND b.code != ''


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt 查看文件

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

val headerRow = sheet.createRow(rowIndex++)


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt 查看文件

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

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

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

fun findFirstByStockOutLineId(stockOutLineId: Long): SuggestedPickLot?
}

+ 0
- 3
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt 查看文件

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

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

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


+ 283
- 256
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt 查看文件

@@ -47,6 +47,9 @@ import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssueRepository
import java.time.LocalTime
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
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
@Service
open class StockOutLineService(
@@ -76,6 +79,10 @@ private val inventoryLotLineService: InventoryLotLineService,
private val pickExecutionIssueRepository: PickExecutionIssueRepository,
private val itemUomService: ItemUomService,
): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) {

@PersistenceContext
private lateinit var entityManager: EntityManager

private fun isEndStatus(status: String?): Boolean {
val s = status?.trim()?.lowercase() ?: return false
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)
@Transactional
open fun findAllByStockOutId(stockOutId: Long): List<StockOutLine> {
@@ -379,7 +402,7 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
// })
// }
@Transactional
fun checkIsStockOutLineCompleted(pickOrderLineId: Long) {
fun checkIsStockOutLineCompleted(pickOrderLineId: Long, quiet: Boolean = false) {
val allStockOutLines = stockOutLineRepository
.findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId)
@@ -410,7 +433,9 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
acc + (issue.issueQty ?: BigDecimal.ZERO)
}
} 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
}

@@ -431,14 +456,16 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
// ✅ 现在的规则:这三类状态都算“已结束”
!(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()) {
// set pick order line status to complete
val pol = pickOrderLineRepository.findById(pickOrderLineId).orElseThrow()
@@ -448,11 +475,28 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
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")
}
}

/** 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) {
// 1) 先用 line 关联找 do_pick_order_id
val lines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(pickOrderId)
@@ -652,60 +696,66 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
println("Updating StockOutLine ID: ${request.id}")
println("Current status: ${stockOutLine.status}")
println("New status: ${request.status}")
val deferAggregate = request.deferAggregatePickOrderEffects == true
val savedStockOutLine = applyStockOutLineDelta(
stockOutLineId = request.id,
stockOutLine = stockOutLine,
deltaQty = BigDecimal((request.qty ?: 0.0).toString()),
newStatus = request.status,
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}")
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 {
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. 如果被拒绝,触发特殊处理
if (request.status == "rejected" || request.status == "REJECTED") {
@@ -713,26 +763,18 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
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(
id = savedStockOutLine.id,
name = savedStockOutLine.inventoryLotLine?.inventoryLot?.lotNo?: "",
@@ -1231,140 +1273,120 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
// 先前預載的 inventories 從未使用(save 已註解),已移除以避免批量提交無故失敗。
val lotLineIds = request.lines.mapNotNull { it.inventoryLotLineId }
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 {
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 }
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 ->
val lineTrace = "$traceId|SOL=${line.stockOutLineId}"
try {
println("[$lineTrace] Processing line, noLot=${line.noLot}")
val solEntity = stockOutLinesById[line.stockOutLineId]
?: throw IllegalStateException("StockOutLine ${line.stockOutLineId} not found")

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
println("[$lineTrace] noLot processed (status->completed, qty=0)")
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") {
println("[$lineTrace] Skip because current status is already completed")
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 {
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) {
println(" Item isBag=true, creating BagLotLine...")
// 根据 itemId 查找对应的 Bag
val bag = bagRepository.findByItemIdAndDeletedIsFalse(item.id!!)
if (bag != null) {
val lotNo = inventoryLotLine.inventoryLot?.lotNo
if (lotNo != null) {
@@ -1372,29 +1394,21 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) {
bagId = bag.id!!,
lotId = inventoryLotLine.inventoryLot?.id ?: 0L,
itemId = item.id!!,
stockOutLineId = stockOutLine.id ,
stockOutLineId = savedSol.id,
lotNo = lotNo,
stockQty = submitQty.toInt(), // 转换为 Int
stockQty = submitQty.toInt(),
date = LocalDate.now(),
time = LocalTime.now()
)

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) {
println(" Error creating BagLotLine: ${e.message}")
e.printStackTrace()
// 不中断主流程,只记录错误
}
processedIds += line.stockOutLineId
println("[$lineTrace] Line processed successfully")
} catch (e: Exception) {
println("[$lineTrace] Error processing line: ${e.message}")
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 {
checkIsStockOutLineCompleted(pickOrderLineId)
refreshPickOrderHeaderIfAllLinesCompleted(pickOrderId)
} 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 ===")
affectedConsoCodes.forEach { consoCode ->
@@ -1577,63 +1591,72 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) {


private fun createStockLedgerForPickDelta(
stockOutLineId: Long,
stockOutLine: StockOutLine,
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) {
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
}
val item = sol.item

val item = stockOutLine.item
if (item == null) {
println("${tracePrefix}Skip ledger creation: stockOutLine.item is null")
if (flushAfterSave) {
println("${tracePrefix}Skip ledger creation: stockOutLine.item is null")
}
return
}
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!)
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
}
val previousBalance = resolvePreviousBalance(
itemId = item.id!!,
inventory = inventory,
onHandQtyBeforeUpdate = onHandQtyBeforeUpdate
)
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 {
this.stockOutLine = sol
this.stockOutLine = stockOutLine
this.inventory = inventory
this.inQty = null
this.outQty = deltaQty.toDouble()
this.outQty = deltaQty.toDouble()
this.balance = newBalance
this.type = "NOR"
this.type = "NOR"
this.itemId = item.id
this.itemCode = item.code
this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id
?: inventory.uom?.id
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(
@@ -1904,20 +1927,20 @@ open fun batchScan(request: com.ffii.fpsms.modules.stock.web.model.BatchScanRequ
}
@Transactional
fun applyStockOutLineDelta(
stockOutLineId: Long,
stockOutLine: StockOutLine,
deltaQty: BigDecimal,
newStatus: String?,
typeOverride: String? = null,
skipInventoryWrite: Boolean = false,
skipLedgerWrite: Boolean = false,
operator: String? = null,
eventTime: LocalDateTime = LocalDateTime.now()
eventTime: LocalDateTime = LocalDateTime.now(),
skipTryCompletePickOrderLine: Boolean = false,
deferPersistenceFlush: Boolean = false
): StockOutLine {
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
val currentQty = BigDecimal(sol.qty?.toString() ?: "0")
@@ -1941,7 +1964,9 @@ fun applyStockOutLineDelta(
if (!operator.isNullOrBlank()) {
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
if (deltaQty == BigDecimal.ZERO || !isPickEnd) {
@@ -1973,7 +1998,8 @@ fun applyStockOutLineDelta(
if (!operator.isNullOrBlank()) {
latestLotLine.modifiedBy = operator
}
inventoryLotLineRepository.saveAndFlush(latestLotLine)
if (deferPersistenceFlush) inventoryLotLineRepository.save(latestLotLine)
else inventoryLotLineRepository.saveAndFlush(latestLotLine)
}
} else {
val lotUpdateResult = inventoryLotLineService.updateInventoryLotLineQuantities(
@@ -2035,11 +2061,12 @@ fun applyStockOutLineDelta(
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) }
}



+ 21
- 27
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt 查看文件

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



fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes<StockTransactionResponse> {
open fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes<StockTransactionResponse> {
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 itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() }
if (itemCode == null && itemName == null) {
println("Search validation failed: both itemCode and itemName are null/empty")
return RecordsRes(emptyList(), 0)
}
// request.startDate 和 request.endDate 已经是 LocalDate? 类型,不需要转换

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

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

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

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

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

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

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

+ 3
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt 查看文件

@@ -61,7 +61,9 @@ data class UpdateStockOutLineStatusRequest(
val qty: Double? = null,
val remarks: String? = null,
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(
val pickOrderLineId: Long,


+ 1
- 1
src/main/resources/jasper/SemiFGProductionAnalysisReport.jrxml 查看文件

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


+ 2
- 2
src/main/resources/jasper/StockInTraceabilityReport.jrxml 查看文件

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


Loading…
取消
儲存