diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt index c36fdbb..eaa58d8 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt +++ b/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, ) \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt index 1945ba8..269f09d 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt @@ -1627,11 +1627,18 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List + +@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 } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index 6b66ea2..b4e049c 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/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( diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt index 148fbc9..6c70395 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt +++ b/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 { return pickExecutionIssueService.getPickExecutionIssuesByPickOrder(pickOrderId) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index 424625a..26fea5e 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/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 diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt index 3861af1..02c0dac 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt +++ b/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> { val args = mutableMapOf() - - // 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() 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() 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 != '' diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt index 31475a5..2362797 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt @@ -225,7 +225,7 @@ class SemiFGProductionAnalysisReportController( "十月", "十一月", "十二月", - "總和" + "上架總計" ) val headerRow = sheet.createRow(rowIndex++) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt index 75983ab..3ccd286 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt @@ -12,5 +12,9 @@ interface SuggestPickLotRepository : AbstractRepository fun findAllByPickOrderLineId(pickOrderLineId: Long): List + fun findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId: Long): List + + fun findAllByPickOrderLineIdInAndDeletedFalse(pickOrderLineIds: List): List + fun findFirstByStockOutLineId(stockOutLineId: Long): SuggestedPickLot? } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index c9bfda7..1a4d916 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/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 diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index 146cd97..50d5fec 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/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(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 { @@ -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 = 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) } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index 3cd7d97..3a25122 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -2317,28 +2317,27 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( -fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes { +open fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes { 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( { 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()) } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt index 65fd96f..b8aa0eb 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt +++ b/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, diff --git a/src/main/resources/jasper/SemiFGProductionAnalysisReport.jrxml b/src/main/resources/jasper/SemiFGProductionAnalysisReport.jrxml index fb5a959..67a25fe 100644 --- a/src/main/resources/jasper/SemiFGProductionAnalysisReport.jrxml +++ b/src/main/resources/jasper/SemiFGProductionAnalysisReport.jrxml @@ -535,7 +535,7 @@ - + diff --git a/src/main/resources/jasper/StockInTraceabilityReport.jrxml b/src/main/resources/jasper/StockInTraceabilityReport.jrxml index a3ce02c..6ee11df 100644 --- a/src/main/resources/jasper/StockInTraceabilityReport.jrxml +++ b/src/main/resources/jasper/StockInTraceabilityReport.jrxml @@ -87,7 +87,7 @@ - + @@ -159,7 +159,7 @@ - +