| @@ -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, | |||
| ) | |||
| @@ -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, | |||
| @@ -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> | |||
| } | |||
| @@ -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( | |||
| @@ -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) | |||
| @@ -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 | |||
| @@ -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 != '' | |||
| @@ -225,7 +225,7 @@ class SemiFGProductionAnalysisReportController( | |||
| "十月", | |||
| "十一月", | |||
| "十二月", | |||
| "總和" | |||
| "上架總計" | |||
| ) | |||
| val headerRow = sheet.createRow(rowIndex++) | |||
| @@ -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? | |||
| } | |||
| @@ -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 | |||
| @@ -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) } | |||
| } | |||
| @@ -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()) | |||
| } | |||
| } | |||
| @@ -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, | |||
| @@ -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"> | |||
| @@ -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"> | |||