| @@ -107,4 +107,10 @@ data class ReleasedDoPickOrderListItem( | |||||
| data class AssignByDoPickOrderIdRequest( | data class AssignByDoPickOrderIdRequest( | ||||
| val userId: Long, | val userId: Long, | ||||
| val doPickOrderId: Long | val doPickOrderId: Long | ||||
| ) | |||||
| /** Workbench: assign a `delivery_order_pick_order` ticket + its linked pick orders. */ | |||||
| data class AssignByDeliveryOrderPickOrderIdRequest( | |||||
| val userId: Long, | |||||
| val deliveryOrderPickOrderId: Long, | |||||
| ) | ) | ||||
| @@ -1627,11 +1627,18 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| if (completedDate != null) { | if (completedDate != null) { | ||||
| val from = completedDate.atStartOfDay() | val from = completedDate.atStartOfDay() | ||||
| val toExclusive = completedDate.plusDays(1).atStartOfDay() | val toExclusive = completedDate.plusDays(1).atStartOfDay() | ||||
| /* | |||||
| pickOrderRepository.findAllCompletedWithJobOrderPlanEndOnDay( | pickOrderRepository.findAllCompletedWithJobOrderPlanEndOnDay( | ||||
| PickOrderStatus.COMPLETED, | PickOrderStatus.COMPLETED, | ||||
| from, | from, | ||||
| toExclusive, | toExclusive, | ||||
| ) | ) | ||||
| */ | |||||
| pickOrderRepository.findAllCompletedWithJobOrderPlanStartOnDay( | |||||
| PickOrderStatus.COMPLETED, | |||||
| from, | |||||
| toExclusive, | |||||
| ) | |||||
| } else { | } else { | ||||
| pickOrderRepository | pickOrderRepository | ||||
| .findAllByStatusIn(listOf(PickOrderStatus.COMPLETED)) | .findAllByStatusIn(listOf(PickOrderStatus.COMPLETED)) | ||||
| @@ -1669,7 +1676,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| "${it.year}-${"%02d".format(it.monthValue)}-${"%02d".format(it.dayOfMonth)}" | "${it.year}-${"%02d".format(it.monthValue)}-${"%02d".format(it.dayOfMonth)}" | ||||
| }, | }, | ||||
| "pickOrderStatus" to pickOrder.status, | "pickOrderStatus" to pickOrder.status, | ||||
| "completedDate" to jobOrder.planEnd, | |||||
| "completedDate" to jobOrder.planStart, | |||||
| "jobOrderId" to jobOrder.id, | "jobOrderId" to jobOrder.id, | ||||
| "jobOrderCode" to jobOrder.code, | "jobOrderCode" to jobOrder.code, | ||||
| "jobOrderName" to jobOrder.bom?.name, | "jobOrderName" to jobOrder.bom?.name, | ||||
| @@ -112,4 +112,21 @@ fun findAllCompletedWithJobOrderPlanEndOnDay( | |||||
| @Param("planEndFrom") planEndFrom: LocalDateTime, | @Param("planEndFrom") planEndFrom: LocalDateTime, | ||||
| @Param("planEndToExclusive") planEndToExclusive: LocalDateTime, | @Param("planEndToExclusive") planEndToExclusive: LocalDateTime, | ||||
| ): List<PickOrder> | ): List<PickOrder> | ||||
| @Query( | |||||
| """ | |||||
| SELECT po FROM PickOrder po | |||||
| WHERE po.status = :status | |||||
| AND po.deleted = false | |||||
| AND po.jobOrder IS NOT NULL | |||||
| AND po.jobOrder.planStart IS NOT NULL | |||||
| AND po.jobOrder.planStart >= :planStartFrom | |||||
| AND po.jobOrder.planStart < :planStartToExclusive | |||||
| """ | |||||
| ) | |||||
| fun findAllCompletedWithJobOrderPlanStartOnDay( | |||||
| @Param("status") status: PickOrderStatus, | |||||
| @Param("planStartFrom") planStartFrom: LocalDateTime, | |||||
| @Param("planStartToExclusive") planStartToExclusive: LocalDateTime, | |||||
| ): List<PickOrder> | |||||
| } | } | ||||
| @@ -401,6 +401,142 @@ open class PickExecutionIssueService( | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| /** | |||||
| * Jo / 无异常量场景:不写入 pick_execution_issue、不更新 inventory_lot_line.issue_qty。 | |||||
| * 仅 (1) 按 actualPickQty - requiredQty 调整 holdQty;(5) 将对应 stock_out_line 标为 checked(不改 qty)。 | |||||
| * 可重复调用,避免 DUPLICATE。 | |||||
| */ | |||||
| open fun applyPickHoldAndMarkSolChecked(request: PickExecutionIssueRequest): MessageResponse { | |||||
| try { | |||||
| println("=== applyPickHoldAndMarkSolChecked: START ===") | |||||
| val missQty = request.missQty ?: BigDecimal.ZERO | |||||
| val badItemQty = request.badItemQty ?: BigDecimal.ZERO | |||||
| if (missQty.compareTo(BigDecimal.ZERO) > 0 || badItemQty.compareTo(BigDecimal.ZERO) > 0) { | |||||
| return MessageResponse( | |||||
| id = null, | |||||
| name = "Invalid request for hold-only API", | |||||
| code = "ERROR", | |||||
| type = "pick_execution_adjustment", | |||||
| message = "This endpoint accepts only actual pick adjustment (miss and bad quantities must be zero). Use /recordIssue for issues.", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| if (request.lotId == null) { | |||||
| return MessageResponse( | |||||
| id = null, | |||||
| name = "lotId required", | |||||
| code = "ERROR", | |||||
| type = "pick_execution_adjustment", | |||||
| message = "inventory lot line id (lotId) is required", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| val inventoryLotLine = inventoryLotLineRepository.findById(request.lotId).orElse(null) | |||||
| val bookQty = if (inventoryLotLine != null) { | |||||
| val inQty = inventoryLotLine.inQty ?: BigDecimal.ZERO | |||||
| val outQty = inventoryLotLine.outQty ?: BigDecimal.ZERO | |||||
| inQty.subtract(outQty) | |||||
| } else { | |||||
| BigDecimal.ZERO | |||||
| } | |||||
| val requiredQty = request.requiredQty ?: BigDecimal.ZERO | |||||
| val actualPickQty = request.actualPickQty ?: BigDecimal.ZERO | |||||
| val lotRemainAvailable = bookQty | |||||
| val maxAllowed = requiredQty.add(lotRemainAvailable) | |||||
| if (actualPickQty > maxAllowed) { | |||||
| return MessageResponse( | |||||
| id = null, | |||||
| name = "Actual pick qty too large", | |||||
| code = "ERROR", | |||||
| type = "pick_execution_adjustment", | |||||
| message = "Actual pick qty cannot exceed required qty plus lot remaining available.", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| if (inventoryLotLine != null) { | |||||
| val deltaHold = actualPickQty.subtract(requiredQty) | |||||
| if (deltaHold.compareTo(BigDecimal.ZERO) != 0) { | |||||
| val latestLotLine = inventoryLotLineRepository.findById(request.lotId).orElse(null) | |||||
| ?: throw IllegalArgumentException("Inventory lot line not found: ${request.lotId}") | |||||
| val currentHold = latestLotLine.holdQty ?: BigDecimal.ZERO | |||||
| val currentOut = latestLotLine.outQty ?: BigDecimal.ZERO | |||||
| val currentIn = latestLotLine.inQty ?: BigDecimal.ZERO | |||||
| val newHold = currentHold.add(deltaHold) | |||||
| if (newHold < BigDecimal.ZERO) { | |||||
| return MessageResponse( | |||||
| id = null, | |||||
| name = "Invalid hold quantity adjustment", | |||||
| code = "ERROR", | |||||
| type = "pick_execution_adjustment", | |||||
| message = "Cannot adjust holdQty by $deltaHold. Current holdQty=$currentHold, requiredQty=$requiredQty, actualPickQty=$actualPickQty", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| if (deltaHold > BigDecimal.ZERO) { | |||||
| val remaining = currentIn.subtract(currentOut).subtract(currentHold) | |||||
| if (deltaHold > remaining) { | |||||
| return MessageResponse( | |||||
| id = null, | |||||
| name = "Insufficient remaining quantity", | |||||
| code = "ERROR", | |||||
| type = "pick_execution_adjustment", | |||||
| message = "Cannot reserve additional $deltaHold. Remaining=$remaining (in=$currentIn, out=$currentOut, hold=$currentHold)", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| } | |||||
| latestLotLine.holdQty = newHold | |||||
| latestLotLine.modified = LocalDateTime.now() | |||||
| latestLotLine.modifiedBy = "system" | |||||
| inventoryLotLineRepository.saveAndFlush(latestLotLine) | |||||
| println("✅ [hold-only] Adjusted inventory_lot_line ${request.lotId} holdQty: $currentHold -> $newHold (delta=$deltaHold)") | |||||
| } | |||||
| } | |||||
| val stockOutLines = stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( | |||||
| request.pickOrderLineId, | |||||
| request.lotId | |||||
| ) | |||||
| stockOutLines.forEach { sol -> | |||||
| sol.status = "checked" | |||||
| sol.modified = LocalDateTime.now() | |||||
| sol.modifiedBy = "system" | |||||
| stockOutLineRepository.save(sol) | |||||
| } | |||||
| stockOutLineRepository.flush() | |||||
| println("=== applyPickHoldAndMarkSolChecked: SUCCESS (${stockOutLines.size} SOL checked) ===") | |||||
| return MessageResponse( | |||||
| id = stockOutLines.firstOrNull()?.id, | |||||
| name = "Pick hold adjusted and lines marked checked", | |||||
| code = "SUCCESS", | |||||
| type = "pick_execution_adjustment", | |||||
| message = "Pick hold adjusted and stock out lines marked checked", | |||||
| errorPosition = null | |||||
| ) | |||||
| } catch (e: Exception) { | |||||
| println("=== applyPickHoldAndMarkSolChecked: ERROR === ${e.message}") | |||||
| e.printStackTrace() | |||||
| return MessageResponse( | |||||
| id = null, | |||||
| name = "Failed to apply pick hold adjustment", | |||||
| code = "ERROR", | |||||
| type = "pick_execution_adjustment", | |||||
| message = "Error: ${e.message}", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| } | |||||
| private fun handleAllZeroMarkCompleted(request: PickExecutionIssueRequest) { | private fun handleAllZeroMarkCompleted(request: PickExecutionIssueRequest) { | ||||
| val stockOutLines = stockOutLineRepository | val stockOutLines = stockOutLineRepository | ||||
| .findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( | .findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( | ||||
| @@ -20,6 +20,12 @@ class PickExecutionIssueController( | |||||
| return pickExecutionIssueService.recordPickExecutionIssue(request) | return pickExecutionIssueService.recordPickExecutionIssue(request) | ||||
| } | } | ||||
| /** 无 miss/bad:仅调整 hold + SOL 标 checked,不写 pick_execution_issue(可重复提交)。 */ | |||||
| @PostMapping("/applyHoldAndChecked") | |||||
| fun applyPickHoldAndMarkSolChecked(@RequestBody request: PickExecutionIssueRequest): MessageResponse { | |||||
| return pickExecutionIssueService.applyPickHoldAndMarkSolChecked(request) | |||||
| } | |||||
| @GetMapping("/issues/pickOrder/{pickOrderId}") | @GetMapping("/issues/pickOrder/{pickOrderId}") | ||||
| fun getPickExecutionIssuesByPickOrder(@PathVariable pickOrderId: Long): List<PickExecutionIssue> { | fun getPickExecutionIssuesByPickOrder(@PathVariable pickOrderId: Long): List<PickExecutionIssue> { | ||||
| return pickExecutionIssueService.getPickExecutionIssuesByPickOrder(pickOrderId) | return pickExecutionIssueService.getPickExecutionIssuesByPickOrder(pickOrderId) | ||||
| @@ -652,6 +652,7 @@ return result | |||||
| * Queries the database for Stock In Traceability Report data (入倉追蹤 PDF). | * Queries the database for Stock In Traceability Report data (入倉追蹤 PDF). | ||||
| * Joins stock_in_line, stock_in, items, qc_result, inventory_lot, inventory_lot_line, warehouse, and shop tables. | * Joins stock_in_line, stock_in, items, qc_result, inventory_lot, inventory_lot_line, warehouse, and shop tables. | ||||
| * Supports comma-separated values for stockCategory (items.type) and itemCode. | * Supports comma-separated values for stockCategory (items.type) and itemCode. | ||||
| * Date range [lastInDateStart, lastInDateEnd] filters on stock_in_line.productionDate (完成生產日期), same basis as 成品/半成品生產分析報告. | |||||
| */ | */ | ||||
| fun searchStockInTraceabilityReport( | fun searchStockInTraceabilityReport( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| @@ -673,13 +674,13 @@ return result | |||||
| val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { | val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { | ||||
| val formattedDate = lastInDateStart.replace("/", "-") | val formattedDate = lastInDateStart.replace("/", "-") | ||||
| args["lastInDateStart"] = formattedDate | args["lastInDateStart"] = formattedDate | ||||
| "AND DATE(sil.receiptDate) >= DATE(:lastInDateStart)" | |||||
| "AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) >= DATE(:lastInDateStart)" | |||||
| } else "" | } else "" | ||||
| val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) { | val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) { | ||||
| val formattedDate = lastInDateEnd.replace("/", "-") | val formattedDate = lastInDateEnd.replace("/", "-") | ||||
| args["lastInDateEnd"] = formattedDate | args["lastInDateEnd"] = formattedDate | ||||
| "AND DATE(sil.receiptDate) <= DATE(:lastInDateEnd)" | |||||
| "AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) <= DATE(:lastInDateEnd)" | |||||
| } else "" | } else "" | ||||
| val sql = """ | val sql = """ | ||||
| @@ -691,7 +692,7 @@ return result | |||||
| COALESCE(sil.lotNo, il.lotNo, '') as lotNo, | COALESCE(sil.lotNo, il.lotNo, '') as lotNo, | ||||
| COALESCE(DATE_FORMAT(COALESCE(sil.expiryDate, il.expiryDate), '%Y-%m-%d'), '') as expiryDate, | COALESCE(DATE_FORMAT(COALESCE(sil.expiryDate, il.expiryDate), '%Y-%m-%d'), '') as expiryDate, | ||||
| CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN '0' | CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN '0' | ||||
| ELSE TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(sil.acceptedQty, 0), 2))) | |||||
| ELSE TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(pa_sil.putAwayQtySum, 0), 2))) | |||||
| END as stockInQty, | END as stockInQty, | ||||
| TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(sil.acceptedQty, 0), 2))) as iqcSampleQty, | TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(sil.acceptedQty, 0), 2))) as iqcSampleQty, | ||||
| TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(qr_agg.failQtySum, 0), 2))) as iqcDefectQty, | TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(qr_agg.failQtySum, 0), 2))) as iqcDefectQty, | ||||
| @@ -706,7 +707,7 @@ return result | |||||
| COALESCE(wh.code, '') as storeLocation, | COALESCE(wh.code, '') as storeLocation, | ||||
| COALESCE(sp_si.code, sp_po.code, '') as supplierID, | COALESCE(sp_si.code, sp_po.code, '') as supplierID, | ||||
| COALESCE(sp_si.name, sp_po.name, '') as supplierName, | COALESCE(sp_si.name, sp_po.name, '') as supplierName, | ||||
| TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id), 2))) as totalStockInQty, | |||||
| TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(pa_sil.putAwayQtySum, 0)) OVER (PARTITION BY it.id), 2))) as totalStockInQty, | |||||
| TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id), 2))) as totalIqcSampleQty | TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id), 2))) as totalIqcSampleQty | ||||
| FROM stock_in_line sil | FROM stock_in_line sil | ||||
| LEFT JOIN stock_in si ON sil.stockInId = si.id | LEFT JOIN stock_in si ON sil.stockInId = si.id | ||||
| @@ -715,6 +716,12 @@ return result | |||||
| LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true | LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true | ||||
| LEFT JOIN uom_conversion uc ON iu.uomId = uc.id | LEFT JOIN uom_conversion uc ON iu.uomId = uc.id | ||||
| LEFT JOIN inventory_lot il ON sil.inventoryLotId = il.id | LEFT JOIN inventory_lot il ON sil.inventoryLotId = il.id | ||||
| LEFT JOIN ( | |||||
| SELECT inventoryLotId, SUM(COALESCE(inQty, 0)) AS putAwayQtySum | |||||
| FROM inventory_lot_line | |||||
| WHERE deleted = false | |||||
| GROUP BY inventoryLotId | |||||
| ) pa_sil ON pa_sil.inventoryLotId = sil.inventoryLotId | |||||
| LEFT JOIN inventory_lot_line ill ON il.id = ill.inventoryLotId | LEFT JOIN inventory_lot_line ill ON il.id = ill.inventoryLotId | ||||
| LEFT JOIN warehouse wh ON ill.warehouseId = wh.id | LEFT JOIN warehouse wh ON ill.warehouseId = wh.id | ||||
| LEFT JOIN shop sp_si ON si.supplierId = sp_si.id | LEFT JOIN shop sp_si ON si.supplierId = sp_si.id | ||||
| @@ -57,11 +57,13 @@ class SemiFGProductionAnalysisReportService( | |||||
| /** | /** | ||||
| * Queries the database for Semi FG Production Analysis Report data. | * Queries the database for Semi FG Production Analysis Report data. | ||||
| * Flow: | |||||
| * 1. Filter bom by description (FG/WIP) to get bom.code values | |||||
| * 2. Match bom.code with stock_ledger.itemCode | |||||
| * 3. Join stock_in_line; aggregate by calendar month of stock_in_line.productionDate (完成生產日期), not stock_ledger.modified | |||||
| * Supports comma-separated values for stockCategory, stockSubCategory, and itemCode. | |||||
| * Aligned with [ReportService.searchStockInTraceabilityReport] totals for the same filters: | |||||
| * - stock_in_line driven (no stock_ledger gate); INNER JOIN bom so only items that exist as BOM rows appear | |||||
| * - stockCategory → items.type (exact, comma-separated); itemCode → items.code (LIKE, comma-separated) | |||||
| * - Date range / year on productionDate (with IS NOT NULL when range bound is set) | |||||
| * - Put-away qty: SUM(inventory_lot_line.inQty) by sil.inventoryLotId (same as traceability pa_sil) | |||||
| * - QC any fail → line qty 0 (same as traceability stockInQty) | |||||
| * - One row per stockInLineId per month before pivot; all lines counted (not only job orders) | |||||
| */ | */ | ||||
| fun searchSemiFGProductionAnalysisReport( | fun searchSemiFGProductionAnalysisReport( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| @@ -72,82 +74,72 @@ class SemiFGProductionAnalysisReportService( | |||||
| lastOutDateEnd: String? | lastOutDateEnd: String? | ||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| // Filter by stockCategory from bom.description (FG/WIP) - this finds which bom.code values match | |||||
| // Supports multiple categories separated by comma (e.g., "FG,WIP") | |||||
| // If "All" is selected or contains "All", don't filter by description | |||||
| val stockCategorySql = if (!itemCode.isNullOrBlank()) { | val stockCategorySql = if (!itemCode.isNullOrBlank()) { | ||||
| // When itemCode is provided, skip stockCategory filter | |||||
| "" | "" | ||||
| } else if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { | } else if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { | ||||
| // Handle multiple categories (comma-separated) | |||||
| val categories = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() && it != "All" } | |||||
| if (categories.isNotEmpty()) { | |||||
| val conditions = categories.mapIndexed { index, cat -> | |||||
| val paramName = "stockCategory_$index" | |||||
| args[paramName] = cat | |||||
| "b.description = :$paramName" | |||||
| } | |||||
| "AND (${conditions.joinToString(" OR ")})" | |||||
| } else { | |||||
| "" | |||||
| } | |||||
| buildMultiValueExactClause(stockCategory, "it.type", "semiSc", args) | |||||
| } else { | } else { | ||||
| "" | "" | ||||
| } | } | ||||
| val stockSubCategorySql = buildMultiValueLikeClause(stockSubCategory, "ic.sub", "stockSubCategory", args) | val stockSubCategorySql = buildMultiValueLikeClause(stockSubCategory, "ic.sub", "stockSubCategory", args) | ||||
| // Filter by itemCode - match bom.code (user input should match bom.code, which then matches stock_ledger.itemCode) | |||||
| val itemCodeSql = buildMultiValueExactClause(itemCode, "b.code", "itemCode", args) | |||||
| val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "semiItem", args) | |||||
| val yearSql = if (!year.isNullOrBlank() && year != "All") { | val yearSql = if (!year.isNullOrBlank() && year != "All") { | ||||
| args["year"] = year | args["year"] = year | ||||
| "AND YEAR(si.productionDate) = :year" | "AND YEAR(si.productionDate) = :year" | ||||
| } else { | } else { | ||||
| "" | "" | ||||
| } | } | ||||
| val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) { | val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) { | ||||
| val formattedDate = lastOutDateStart.replace("/", "-") | val formattedDate = lastOutDateStart.replace("/", "-") | ||||
| args["lastOutDateStart"] = formattedDate | args["lastOutDateStart"] = formattedDate | ||||
| "AND DATE(si.productionDate) >= DATE(:lastOutDateStart)" | |||||
| "AND si.productionDate IS NOT NULL AND DATE(si.productionDate) >= DATE(:lastOutDateStart)" | |||||
| } else "" | } else "" | ||||
| val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) { | val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) { | ||||
| val formattedDate = lastOutDateEnd.replace("/", "-") | val formattedDate = lastOutDateEnd.replace("/", "-") | ||||
| args["lastOutDateEnd"] = formattedDate | args["lastOutDateEnd"] = formattedDate | ||||
| "AND DATE(si.productionDate) <= DATE(:lastOutDateEnd)" | |||||
| "AND si.productionDate IS NOT NULL AND DATE(si.productionDate) <= DATE(:lastOutDateEnd)" | |||||
| } else "" | } else "" | ||||
| val sql = """ | val sql = """ | ||||
| WITH base AS ( | |||||
| WITH qr_agg AS ( | |||||
| SELECT | SELECT | ||||
| COALESCE(sl.itemCode, '') as itemNo, | |||||
| COALESCE(b.name, '') as itemName, | |||||
| COALESCE(ic.sub, '') as stockSubCategory, | |||||
| COALESCE(uc.udfudesc, '') as unitOfMeasure, | |||||
| MONTH(si.productionDate) as mon, | |||||
| si.id as stockInLineId, | |||||
| si.acceptedQty as acceptedQty, | |||||
| si.jobOrderId as jobOrderId | |||||
| FROM stock_ledger sl | |||||
| INNER JOIN bom b | |||||
| ON sl.itemCode = b.code AND b.deleted = false | |||||
| INNER JOIN stock_in_line si | |||||
| ON si.id = sl.stockInLineId | |||||
| AND si.deleted = false | |||||
| AND si.productionDate IS NOT NULL | |||||
| LEFT JOIN items it | |||||
| ON sl.itemId = it.id | |||||
| LEFT JOIN item_category ic | |||||
| ON it.categoryId = ic.id | |||||
| LEFT JOIN item_uom iu | |||||
| ON it.id = iu.itemId | |||||
| AND iu.stockUnit = true | |||||
| LEFT JOIN uom_conversion uc | |||||
| ON iu.uomId = uc.id | |||||
| WHERE sl.deleted = false | |||||
| AND sl.inQty IS NOT NULL | |||||
| AND sl.inQty > 0 | |||||
| qr.stockInLineId, | |||||
| MAX(CASE WHEN qr.qcPassed = 0 THEN 1 ELSE 0 END) AS qcFailed | |||||
| FROM qc_result qr | |||||
| WHERE qr.deleted = 0 | |||||
| GROUP BY qr.stockInLineId | |||||
| ), | |||||
| pa_sil AS ( | |||||
| SELECT inventoryLotId, SUM(COALESCE(inQty, 0)) AS putAwayQtySum | |||||
| FROM inventory_lot_line | |||||
| WHERE deleted = false | |||||
| GROUP BY inventoryLotId | |||||
| ), | |||||
| base AS ( | |||||
| SELECT | |||||
| COALESCE(it.code, '') AS itemNo, | |||||
| COALESCE(it.name, '') AS itemName, | |||||
| COALESCE(ic.sub, '') AS stockSubCategory, | |||||
| COALESCE(uc.udfudesc, '') AS unitOfMeasure, | |||||
| MONTH(si.productionDate) AS mon, | |||||
| si.id AS stockInLineId, | |||||
| CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0 | |||||
| ELSE COALESCE(pa_sil.putAwayQtySum, 0) | |||||
| END AS linePutAwayQty | |||||
| FROM stock_in_line si | |||||
| INNER JOIN items it ON si.itemId = it.id | |||||
| INNER JOIN bom b ON b.code = it.code AND b.deleted = false | |||||
| LEFT JOIN qr_agg ON qr_agg.stockInLineId = si.id | |||||
| LEFT JOIN pa_sil ON pa_sil.inventoryLotId = si.inventoryLotId | |||||
| LEFT JOIN item_category ic ON it.categoryId = ic.id | |||||
| LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true | |||||
| LEFT JOIN uom_conversion uc ON iu.uomId = uc.id | |||||
| WHERE si.deleted = false | |||||
| AND si.productionDate IS NOT NULL | |||||
| $stockCategorySql | $stockCategorySql | ||||
| $stockSubCategorySql | $stockSubCategorySql | ||||
| $itemCodeSql | $itemCodeSql | ||||
| @@ -155,7 +147,6 @@ class SemiFGProductionAnalysisReportService( | |||||
| $lastOutDateStartSql | $lastOutDateStartSql | ||||
| $lastOutDateEndSql | $lastOutDateEndSql | ||||
| ), | ), | ||||
| -- Deduplicate: stock_in_line can join to multiple stock_ledger rows; acceptedQty must be counted once per stockInLineId. | |||||
| dedup AS ( | dedup AS ( | ||||
| SELECT | SELECT | ||||
| itemNo, | itemNo, | ||||
| @@ -164,36 +155,34 @@ class SemiFGProductionAnalysisReportService( | |||||
| unitOfMeasure, | unitOfMeasure, | ||||
| mon, | mon, | ||||
| stockInLineId, | stockInLineId, | ||||
| MAX(COALESCE(acceptedQty, 0)) as acceptedQty, | |||||
| MAX(jobOrderId) as jobOrderId | |||||
| MAX(linePutAwayQty) AS linePutAwayQty | |||||
| FROM base | FROM base | ||||
| GROUP BY itemNo, itemName, stockSubCategory, unitOfMeasure, mon, stockInLineId | GROUP BY itemNo, itemName, stockSubCategory, unitOfMeasure, mon, stockInLineId | ||||
| ) | ) | ||||
| SELECT | SELECT | ||||
| MAX(d.stockSubCategory) as stockSubCategory, | |||||
| d.itemNo as itemNo, | |||||
| MAX(d.itemName) as itemName, | |||||
| MAX(d.unitOfMeasure) as unitOfMeasure, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 1 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyJan, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 2 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyFeb, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 3 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyMar, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 4 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyApr, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 5 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyMay, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 6 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyJun, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 7 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyJul, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 8 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyAug, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 9 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtySep, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 10 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyOct, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 11 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyNov, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 12 AND d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS DECIMAL(18,2)) as qtyDec, | |||||
| -- Keep as CHAR for Jasper compatibility (previous template expects String). | |||||
| CAST(COALESCE(SUM(CASE WHEN d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) AS CHAR) as totalProductionQty | |||||
| MAX(d.stockSubCategory) AS stockSubCategory, | |||||
| d.itemNo AS itemNo, | |||||
| MAX(d.itemName) AS itemName, | |||||
| MAX(d.unitOfMeasure) AS unitOfMeasure, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 1 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyJan, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 2 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyFeb, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 3 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyMar, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 4 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyApr, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 5 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyMay, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 6 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyJun, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 7 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyJul, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 8 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyAug, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 9 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtySep, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 10 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyOct, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 11 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyNov, | |||||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 12 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyDec, | |||||
| CAST(COALESCE(SUM(d.linePutAwayQty), 0) AS CHAR) AS totalProductionQty | |||||
| FROM dedup d | FROM dedup d | ||||
| GROUP BY d.itemNo | GROUP BY d.itemNo | ||||
| HAVING COALESCE(SUM(CASE WHEN d.jobOrderId IS NOT NULL AND TRIM(CAST(d.jobOrderId AS CHAR)) <> '' THEN d.acceptedQty ELSE 0 END), 0) > 0 | |||||
| HAVING COALESCE(SUM(d.linePutAwayQty), 0) > 0 | |||||
| ORDER BY d.itemNo | ORDER BY d.itemNo | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| } | } | ||||
| @@ -208,25 +197,15 @@ class SemiFGProductionAnalysisReportService( | |||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { | val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { | ||||
| // Handle multiple categories (comma-separated) | |||||
| val categories = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() && it != "All" } | |||||
| if (categories.isNotEmpty()) { | |||||
| val conditions = categories.mapIndexed { index, cat -> | |||||
| val paramName = "stockCategory_$index" | |||||
| args[paramName] = cat | |||||
| "b.description = :$paramName" | |||||
| } | |||||
| "AND (${conditions.joinToString(" OR ")})" | |||||
| } else { | |||||
| "" | |||||
| } | |||||
| buildMultiValueExactClause(stockCategory, "it.type", "semiFgCodesSc", args) | |||||
| } else { | } else { | ||||
| "" | "" | ||||
| } | } | ||||
| val sql = """ | val sql = """ | ||||
| SELECT DISTINCT b.code, COALESCE(b.name, '') as name | |||||
| SELECT DISTINCT b.code, COALESCE(it.name, b.name, '') AS name | |||||
| FROM bom b | FROM bom b | ||||
| INNER JOIN items it ON it.code = b.code AND it.deleted = false | |||||
| WHERE b.deleted = false | WHERE b.deleted = false | ||||
| AND b.code IS NOT NULL | AND b.code IS NOT NULL | ||||
| AND b.code != '' | AND b.code != '' | ||||
| @@ -255,25 +234,15 @@ class SemiFGProductionAnalysisReportService( | |||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { | val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { | ||||
| // Handle multiple categories (comma-separated) | |||||
| val categories = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() && it != "All" } | |||||
| if (categories.isNotEmpty()) { | |||||
| val conditions = categories.mapIndexed { index, cat -> | |||||
| val paramName = "stockCategory_$index" | |||||
| args[paramName] = cat | |||||
| "b.description = :$paramName" | |||||
| } | |||||
| "AND (${conditions.joinToString(" OR ")})" | |||||
| } else { | |||||
| "" | |||||
| } | |||||
| buildMultiValueExactClause(stockCategory, "it.type", "semiFgCodesCatSc", args) | |||||
| } else { | } else { | ||||
| "" | "" | ||||
| } | } | ||||
| val sql = """ | val sql = """ | ||||
| SELECT DISTINCT b.code, COALESCE(b.description, '') as category, COALESCE(b.name, '') as name | |||||
| SELECT DISTINCT b.code, COALESCE(it.type, '') AS category, COALESCE(it.name, b.name, '') AS name | |||||
| FROM bom b | FROM bom b | ||||
| INNER JOIN items it ON it.code = b.code AND it.deleted = false | |||||
| WHERE b.deleted = false | WHERE b.deleted = false | ||||
| AND b.code IS NOT NULL | AND b.code IS NOT NULL | ||||
| AND b.code != '' | AND b.code != '' | ||||
| @@ -225,7 +225,7 @@ class SemiFGProductionAnalysisReportController( | |||||
| "十月", | "十月", | ||||
| "十一月", | "十一月", | ||||
| "十二月", | "十二月", | ||||
| "總和" | |||||
| "上架總計" | |||||
| ) | ) | ||||
| val headerRow = sheet.createRow(rowIndex++) | val headerRow = sheet.createRow(rowIndex++) | ||||
| @@ -12,5 +12,9 @@ interface SuggestPickLotRepository : AbstractRepository<SuggestedPickLot, Long> | |||||
| fun findAllByPickOrderLineId(pickOrderLineId: Long): List<SuggestedPickLot> | fun findAllByPickOrderLineId(pickOrderLineId: Long): List<SuggestedPickLot> | ||||
| fun findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId: Long): List<SuggestedPickLot> | |||||
| fun findAllByPickOrderLineIdInAndDeletedFalse(pickOrderLineIds: List<Long>): List<SuggestedPickLot> | |||||
| fun findFirstByStockOutLineId(stockOutLineId: Long): SuggestedPickLot? | fun findFirstByStockOutLineId(stockOutLineId: Long): SuggestedPickLot? | ||||
| } | } | ||||
| @@ -115,9 +115,6 @@ open class InventoryLotLineService( | |||||
| val stockUom = request.stockUomId?.let { itemUomRespository.findById(it).getOrNull() } | val stockUom = request.stockUomId?.let { itemUomRespository.findById(it).getOrNull() } | ||||
| val status = request.status?.let { _status -> InventoryLotLineStatus.entries.find { it.value == _status } } | val status = request.status?.let { _status -> InventoryLotLineStatus.entries.find { it.value == _status } } | ||||
| println("status: ${request.status}") | |||||
| println("status123: ${status?.value}") | |||||
| inventoryLotLine.apply { | inventoryLotLine.apply { | ||||
| this.inventoryLot = inventoryLot | this.inventoryLot = inventoryLot | ||||
| this.warehouse = warehouse | this.warehouse = warehouse | ||||
| @@ -47,6 +47,9 @@ import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssueRepository | |||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | |||||
| import jakarta.persistence.EntityManager | |||||
| import jakarta.persistence.PersistenceContext | |||||
| import java.util.UUID | import java.util.UUID | ||||
| @Service | @Service | ||||
| open class StockOutLineService( | open class StockOutLineService( | ||||
| @@ -76,6 +79,10 @@ private val inventoryLotLineService: InventoryLotLineService, | |||||
| private val pickExecutionIssueRepository: PickExecutionIssueRepository, | private val pickExecutionIssueRepository: PickExecutionIssueRepository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| ): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) { | ): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) { | ||||
| @PersistenceContext | |||||
| private lateinit var entityManager: EntityManager | |||||
| private fun isEndStatus(status: String?): Boolean { | private fun isEndStatus(status: String?): Boolean { | ||||
| val s = status?.trim()?.lowercase() ?: return false | val s = status?.trim()?.lowercase() ?: return false | ||||
| return s == "completed" || s == "rejected" || s == "partially_completed" | return s == "completed" || s == "rejected" || s == "partially_completed" | ||||
| @@ -105,6 +112,22 @@ private val inventoryLotLineService: InventoryLotLineService, | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| /** When every POL on this pick order is COMPLETED or PARTIALLY_COMPLETE, mark pick order completed and cascade DO completion. */ | |||||
| private fun refreshPickOrderHeaderIfAllLinesCompleted(pickOrderId: Long) { | |||||
| val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return | |||||
| val allLines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(pickOrderId) | |||||
| val allCompleted = allLines.all { | |||||
| it.status == PickOrderLineStatus.COMPLETED || it.status == PickOrderLineStatus.PARTIALLY_COMPLETE | |||||
| } | |||||
| if (allCompleted && allLines.isNotEmpty()) { | |||||
| pickOrder.status = PickOrderStatus.COMPLETED | |||||
| pickOrderRepository.save(pickOrder) | |||||
| completeDoForPickOrder(pickOrderId) | |||||
| completeDoIfAllPickOrdersCompleted(pickOrderId) | |||||
| } | |||||
| } | |||||
| @Throws(IOException::class) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| open fun findAllByStockOutId(stockOutId: Long): List<StockOutLine> { | open fun findAllByStockOutId(stockOutId: Long): List<StockOutLine> { | ||||
| @@ -379,7 +402,7 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { | |||||
| // }) | // }) | ||||
| // } | // } | ||||
| @Transactional | @Transactional | ||||
| fun checkIsStockOutLineCompleted(pickOrderLineId: Long) { | |||||
| fun checkIsStockOutLineCompleted(pickOrderLineId: Long, quiet: Boolean = false) { | |||||
| val allStockOutLines = stockOutLineRepository | val allStockOutLines = stockOutLineRepository | ||||
| .findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId) | .findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId) | ||||
| @@ -410,7 +433,9 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { | |||||
| acc + (issue.issueQty ?: BigDecimal.ZERO) | acc + (issue.issueQty ?: BigDecimal.ZERO) | ||||
| } | } | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| println(" Error fetching issues for pickOrderLineId $pickOrderLineId: ${e.message}") | |||||
| if (!quiet) { | |||||
| println(" Error fetching issues for pickOrderLineId $pickOrderLineId: ${e.message}") | |||||
| } | |||||
| BigDecimal.ZERO | BigDecimal.ZERO | ||||
| } | } | ||||
| @@ -431,14 +456,16 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { | |||||
| // ✅ 现在的规则:这三类状态都算“已结束” | // ✅ 现在的规则:这三类状态都算“已结束” | ||||
| !(isComplete || isRejected || isPartiallyComplete) | !(isComplete || isRejected || isPartiallyComplete) | ||||
| } | } | ||||
| println("Unfinished lines: ${unfinishedLine.size}") | |||||
| if (unfinishedLine.isNotEmpty()) { | |||||
| unfinishedLine.forEach { sol -> | |||||
| println(" - StockOutLine ${sol.id}: status=${sol.status}, qty=${sol.qty}") | |||||
| if (!quiet) { | |||||
| println("Unfinished lines: ${unfinishedLine.size}") | |||||
| if (unfinishedLine.isNotEmpty()) { | |||||
| unfinishedLine.forEach { sol -> | |||||
| println(" - StockOutLine ${sol.id}: status=${sol.status}, qty=${sol.qty}") | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| if (unfinishedLine.isEmpty()) { | if (unfinishedLine.isEmpty()) { | ||||
| // set pick order line status to complete | // set pick order line status to complete | ||||
| val pol = pickOrderLineRepository.findById(pickOrderLineId).orElseThrow() | val pol = pickOrderLineRepository.findById(pickOrderLineId).orElseThrow() | ||||
| @@ -448,11 +475,28 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { | |||||
| this.status = PickOrderLineStatus.COMPLETED | this.status = PickOrderLineStatus.COMPLETED | ||||
| } | } | ||||
| ) | ) | ||||
| println("✅ Updated pick order line $pickOrderLineId from $previousStatus to COMPLETED") | |||||
| } else { | |||||
| if (!quiet) { | |||||
| println("✅ Updated pick order line $pickOrderLineId from $previousStatus to COMPLETED") | |||||
| } | |||||
| } else if (!quiet) { | |||||
| println("⏳ Pick order line $pickOrderLineId not completed yet - has ${unfinishedLine.size} unfinished stock out lines") | println("⏳ Pick order line $pickOrderLineId not completed yet - has ${unfinishedLine.size} unfinished stock out lines") | ||||
| } | } | ||||
| } | } | ||||
| /** Batch pick: same qty rules as [InventoryLotLineService.updateInventoryLotLineQuantities] pick, without double findById / extra service layers. */ | |||||
| private fun applyPickToInventoryLotLineInBatch(ill: InventoryLotLine, submitQty: BigDecimal) { | |||||
| val zero = BigDecimal.ZERO | |||||
| val newHold = (ill.holdQty ?: zero).minus(submitQty) | |||||
| val newOut = (ill.outQty ?: zero).plus(submitQty) | |||||
| if (newHold < zero || newOut < zero) { | |||||
| throw IllegalArgumentException("Invalid pick quantities for lotLine ${ill.id}: holdQty=$newHold, outQty=$newOut") | |||||
| } | |||||
| val prevStatus = ill.status | |||||
| ill.holdQty = newHold | |||||
| ill.outQty = newOut | |||||
| ill.status = inventoryLotLineService.deriveInventoryLotLineStatus(prevStatus, ill.inQty, ill.outQty, ill.holdQty) | |||||
| inventoryLotLineRepository.save(ill) | |||||
| } | |||||
| private fun completeDoIfAllPickOrdersCompleted(pickOrderId: Long) { | private fun completeDoIfAllPickOrdersCompleted(pickOrderId: Long) { | ||||
| // 1) 先用 line 关联找 do_pick_order_id | // 1) 先用 line 关联找 do_pick_order_id | ||||
| val lines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(pickOrderId) | val lines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(pickOrderId) | ||||
| @@ -652,60 +696,66 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { | |||||
| println("Updating StockOutLine ID: ${request.id}") | println("Updating StockOutLine ID: ${request.id}") | ||||
| println("Current status: ${stockOutLine.status}") | println("Current status: ${stockOutLine.status}") | ||||
| println("New status: ${request.status}") | println("New status: ${request.status}") | ||||
| val deferAggregate = request.deferAggregatePickOrderEffects == true | |||||
| val savedStockOutLine = applyStockOutLineDelta( | val savedStockOutLine = applyStockOutLineDelta( | ||||
| stockOutLineId = request.id, | |||||
| stockOutLine = stockOutLine, | |||||
| deltaQty = BigDecimal((request.qty ?: 0.0).toString()), | deltaQty = BigDecimal((request.qty ?: 0.0).toString()), | ||||
| newStatus = request.status, | newStatus = request.status, | ||||
| skipInventoryWrite = request.skipInventoryWrite == true, | skipInventoryWrite = request.skipInventoryWrite == true, | ||||
| skipLedgerWrite = request.skipLedgerWrite == true | |||||
| skipLedgerWrite = request.skipLedgerWrite == true, | |||||
| skipTryCompletePickOrderLine = deferAggregate, | |||||
| deferPersistenceFlush = deferAggregate | |||||
| ) | ) | ||||
| println("Updated StockOutLine: ${savedStockOutLine.id} with status: ${savedStockOutLine.status}") | println("Updated StockOutLine: ${savedStockOutLine.id} with status: ${savedStockOutLine.status}") | ||||
| try { | |||||
| val item = savedStockOutLine.item | |||||
| val inventoryLotLine = savedStockOutLine.inventoryLotLine | |||||
| val reqDeltaQty = request.qty ?: 0.0 | |||||
| // 只在状态为 completed 或 partially_completed,且数量增加时创建 BagLotLine | |||||
| val isCompletedOrPartiallyCompleted = request.status == "completed" || | |||||
| request.status == "partially_completed" || | |||||
| request.status == "PARTIALLY_COMPLETE" | |||||
| if (item?.isBag == true && | |||||
| inventoryLotLine != null && | |||||
| isCompletedOrPartiallyCompleted && | |||||
| reqDeltaQty > 0) { | |||||
| println(" Item isBag=true, creating BagLotLine...") | |||||
| val bag = bagRepository.findByItemIdAndDeletedIsFalse(item.id!!) | |||||
| if (bag != null) { | |||||
| val lotNo = inventoryLotLine.inventoryLot?.lotNo | |||||
| if (lotNo != null) { | |||||
| val createBagLotLineRequest = CreateBagLotLineRequest( | |||||
| bagId = bag.id!!, | |||||
| lotId = inventoryLotLine.inventoryLot?.id ?: 0L, | |||||
| itemId = item.id!!, | |||||
| lotNo = lotNo, | |||||
| stockQty = reqDeltaQty.toInt(), | |||||
| date = LocalDate.now(), | |||||
| time = LocalTime.now(), | |||||
| stockOutLineId = savedStockOutLine.id | |||||
| ) | |||||
| bagService.createBagLotLinesByBagId(createBagLotLineRequest) | |||||
| println(" ✓ BagLotLine created successfully for item ${item.code}") | |||||
| if (!deferAggregate) { | |||||
| try { | |||||
| val item = savedStockOutLine.item | |||||
| val inventoryLotLine = savedStockOutLine.inventoryLotLine | |||||
| val reqDeltaQty = request.qty ?: 0.0 | |||||
| // 只在状态为 completed 或 partially_completed,且数量增加时创建 BagLotLine | |||||
| val isCompletedOrPartiallyCompleted = request.status == "completed" || | |||||
| request.status == "partially_completed" || | |||||
| request.status == "PARTIALLY_COMPLETE" | |||||
| if (item?.isBag == true && | |||||
| inventoryLotLine != null && | |||||
| isCompletedOrPartiallyCompleted && | |||||
| reqDeltaQty > 0 | |||||
| ) { | |||||
| println(" Item isBag=true, creating BagLotLine...") | |||||
| val bag = bagRepository.findByItemIdAndDeletedIsFalse(item.id!!) | |||||
| if (bag != null) { | |||||
| val lotNo = inventoryLotLine.inventoryLot?.lotNo | |||||
| if (lotNo != null) { | |||||
| val createBagLotLineRequest = CreateBagLotLineRequest( | |||||
| bagId = bag.id!!, | |||||
| lotId = inventoryLotLine.inventoryLot?.id ?: 0L, | |||||
| itemId = item.id!!, | |||||
| lotNo = lotNo, | |||||
| stockQty = reqDeltaQty.toInt(), | |||||
| date = LocalDate.now(), | |||||
| time = LocalTime.now(), | |||||
| stockOutLineId = savedStockOutLine.id | |||||
| ) | |||||
| bagService.createBagLotLinesByBagId(createBagLotLineRequest) | |||||
| println(" ✓ BagLotLine created successfully for item ${item.code}") | |||||
| } else { | |||||
| println(" Warning: lotNo is null, skipping BagLotLine creation") | |||||
| } | |||||
| } else { | } else { | ||||
| println(" Warning: lotNo is null, skipping BagLotLine creation") | |||||
| println(" Warning: Bag not found for itemId ${item.id}, skipping BagLotLine creation") | |||||
| } | } | ||||
| } else { | |||||
| println(" Warning: Bag not found for itemId ${item.id}, skipping BagLotLine creation") | |||||
| } | } | ||||
| } catch (e: Exception) { | |||||
| println(" Error creating BagLotLine: ${e.message}") | |||||
| e.printStackTrace() | |||||
| // 不中断主流程,只记录错误 | |||||
| } | } | ||||
| } catch (e: Exception) { | |||||
| println(" Error creating BagLotLine: ${e.message}") | |||||
| e.printStackTrace() | |||||
| // 不中断主流程,只记录错误 | |||||
| } | } | ||||
| // 3. 如果被拒绝,触发特殊处理 | // 3. 如果被拒绝,触发特殊处理 | ||||
| if (request.status == "rejected" || request.status == "REJECTED") { | if (request.status == "rejected" || request.status == "REJECTED") { | ||||
| @@ -713,26 +763,18 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { | |||||
| handleLotRejectionFromStockOutLine(savedStockOutLine) | handleLotRejectionFromStockOutLine(savedStockOutLine) | ||||
| } | } | ||||
| // 4. 自动刷 pickOrderLine 状态 | |||||
| val pickOrderLine = savedStockOutLine.pickOrderLine | |||||
| if (pickOrderLine != null) { | |||||
| checkIsStockOutLineCompleted(pickOrderLine.id) | |||||
| // 5. 自动刷 pickOrder 状态 | |||||
| val pickOrder = pickOrderLine.pickOrder | |||||
| if (pickOrder != null && pickOrder.id != null) { | |||||
| // ✅ 修复:使用 repository 查询所有 lines,避免懒加载问题 | |||||
| val allLines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(pickOrder.id!!) | |||||
| val allCompleted = allLines.all { it.status == PickOrderLineStatus.COMPLETED || it.status == PickOrderLineStatus.PARTIALLY_COMPLETE } | |||||
| if (allCompleted && allLines.isNotEmpty()) { | |||||
| pickOrder.status = PickOrderStatus.COMPLETED | |||||
| pickOrderRepository.save(pickOrder) | |||||
| completeDoForPickOrder(pickOrder.id!!) | |||||
| completeDoIfAllPickOrdersCompleted(pickOrder.id!!) | |||||
| } | |||||
| // 4–5. 自动刷 pickOrderLine / pickOrder 状态(批次提交在结尾统一处理) | |||||
| if (!deferAggregate) { | |||||
| val pickOrderLine = savedStockOutLine.pickOrderLine | |||||
| if (pickOrderLine != null) { | |||||
| checkIsStockOutLineCompleted(pickOrderLine.id) | |||||
| pickOrderLine.pickOrder?.id?.let { refreshPickOrderHeaderIfAllLinesCompleted(it) } | |||||
| } | } | ||||
| } | } | ||||
| val mappedSavedStockOutLine = stockOutLineRepository.findStockOutLineInfoById(savedStockOutLine.id!!) | |||||
| val mappedSavedStockOutLine = | |||||
| if (deferAggregate) null | |||||
| else stockOutLineRepository.findStockOutLineInfoById(savedStockOutLine.id!!) | |||||
| return MessageResponse( | return MessageResponse( | ||||
| id = savedStockOutLine.id, | id = savedStockOutLine.id, | ||||
| name = savedStockOutLine.inventoryLotLine?.inventoryLot?.lotNo?: "", | name = savedStockOutLine.inventoryLotLine?.inventoryLot?.lotNo?: "", | ||||
| @@ -1231,140 +1273,120 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| // 先前預載的 inventories 從未使用(save 已註解),已移除以避免批量提交無故失敗。 | // 先前預載的 inventories 從未使用(save 已註解),已移除以避免批量提交無故失敗。 | ||||
| val lotLineIds = request.lines.mapNotNull { it.inventoryLotLineId } | val lotLineIds = request.lines.mapNotNull { it.inventoryLotLineId } | ||||
| println("Loading ${lotLineIds.size} lot lines...") | println("Loading ${lotLineIds.size} lot lines...") | ||||
| val lotLines = if (lotLineIds.isNotEmpty()) { | |||||
| inventoryLotLineRepository.findAllById(lotLineIds).associateBy { it.id } | |||||
| val lotLinesById: MutableMap<Long, InventoryLotLine> = if (lotLineIds.isNotEmpty()) { | |||||
| inventoryLotLineRepository.findAllById(lotLineIds).associateByTo(mutableMapOf()) { it.id!! } | |||||
| } else { | } else { | ||||
| emptyMap() | |||||
| mutableMapOf() | |||||
| } | } | ||||
| // 2) Bulk load all stock out lines to get current quantities | |||||
| // 2) Bulk load all stock out lines(批次內就地更新) | |||||
| val stockOutLineIds = request.lines.map { it.stockOutLineId } | val stockOutLineIds = request.lines.map { it.stockOutLineId } | ||||
| println("Loading ${stockOutLineIds.size} stock out lines...") | println("Loading ${stockOutLineIds.size} stock out lines...") | ||||
| val stockOutLines = stockOutLineRepository.findAllById(stockOutLineIds).associateBy { it.id } | |||||
| val stockOutLinesById = | |||||
| stockOutLineRepository.findAllById(stockOutLineIds).associateByTo(mutableMapOf()) { it.id!! } | |||||
| // 3) Process each request line | |||||
| // 3) Process each request line(直接 applyStockOutLineDelta + 內聯扣庫存,避免 updateStatus 與雙重查詢) | |||||
| request.lines.forEach { line: QrPickSubmitLineRequest -> | request.lines.forEach { line: QrPickSubmitLineRequest -> | ||||
| val lineTrace = "$traceId|SOL=${line.stockOutLineId}" | val lineTrace = "$traceId|SOL=${line.stockOutLineId}" | ||||
| try { | try { | ||||
| println("[$lineTrace] Processing line, noLot=${line.noLot}") | |||||
| val solEntity = stockOutLinesById[line.stockOutLineId] | |||||
| ?: throw IllegalStateException("StockOutLine ${line.stockOutLineId} not found") | |||||
| if (line.noLot) { | if (line.noLot) { | ||||
| // noLot branch | |||||
| updateStatus(UpdateStockOutLineStatusRequest( | |||||
| id = line.stockOutLineId, | |||||
| status = "completed", | |||||
| qty = 0.0 | |||||
| )) | |||||
| val updated = applyStockOutLineDelta( | |||||
| stockOutLine = solEntity, | |||||
| deltaQty = BigDecimal.ZERO, | |||||
| newStatus = "completed", | |||||
| skipInventoryWrite = true, | |||||
| skipLedgerWrite = true, | |||||
| skipTryCompletePickOrderLine = true, | |||||
| deferPersistenceFlush = true | |||||
| ) | |||||
| stockOutLinesById[line.stockOutLineId] = updated | |||||
| processedIds += line.stockOutLineId | processedIds += line.stockOutLineId | ||||
| println("[$lineTrace] noLot processed (status->completed, qty=0)") | |||||
| return@forEach | return@forEach | ||||
| } | } | ||||
| // 修复:从数据库获取当前实际数量 | |||||
| val stockOutLine = stockOutLines[line.stockOutLineId] | |||||
| ?: throw IllegalStateException("StockOutLine ${line.stockOutLineId} not found") | |||||
| val currentStatus = stockOutLine.status?.trim()?.lowercase() | |||||
| val currentStatus = solEntity.status?.trim()?.lowercase() | |||||
| if (currentStatus == "completed" || currentStatus == "complete") { | if (currentStatus == "completed" || currentStatus == "complete") { | ||||
| println("[$lineTrace] Skip because current status is already completed") | |||||
| return@forEach | return@forEach | ||||
| } | } | ||||
| val currentActual = (stockOutLine.qty ?: 0.0).toBigDecimal() | |||||
| val targetActual = line.actualPickQty ?: BigDecimal.ZERO | |||||
| val required = line.requiredQty ?: BigDecimal.ZERO | |||||
| println("[$lineTrace] currentActual=$currentActual, targetActual=$targetActual, required=$required") | |||||
| // 计算增量(前端发送的是目标累计值) | |||||
| val submitQty = targetActual - currentActual | |||||
| println("[$lineTrace] submitQty(increment)=$submitQty") | |||||
| // 使用前端发送的状态,否则根据数量自动判断 | |||||
| val newStatus = line.stockOutLineStatus | |||||
| ?: if (targetActual >= required) "completed" else "partially_completed" | |||||
| if (submitQty <= BigDecimal.ZERO) { | |||||
| println("[$lineTrace] submitQty<=0, only update status, skip inventory+ledger") | |||||
| updateStatus( | |||||
| UpdateStockOutLineStatusRequest( | |||||
| id = line.stockOutLineId, | |||||
| status = newStatus, // 例如前端传来的 "completed" | |||||
| qty = 0.0, // 不改变现有 qty | |||||
| skipLedgerWrite = true, | |||||
| skipInventoryWrite = true | |||||
| ) | |||||
| ) | |||||
| // 直接跳过后面的库存扣减逻辑 | |||||
| return@forEach | |||||
| val currentActual = (solEntity.qty ?: 0.0).toBigDecimal() | |||||
| val targetActual = line.actualPickQty ?: BigDecimal.ZERO | |||||
| val required = line.requiredQty ?: BigDecimal.ZERO | |||||
| val submitQty = targetActual - currentActual | |||||
| val newStatus = line.stockOutLineStatus | |||||
| ?: if (targetActual >= required) "completed" else "partially_completed" | |||||
| if (submitQty <= BigDecimal.ZERO) { | |||||
| val updated = applyStockOutLineDelta( | |||||
| stockOutLine = solEntity, | |||||
| deltaQty = BigDecimal.ZERO, | |||||
| newStatus = newStatus, | |||||
| skipInventoryWrite = true, | |||||
| skipLedgerWrite = true, | |||||
| skipTryCompletePickOrderLine = true, | |||||
| deferPersistenceFlush = true | |||||
| ) | |||||
| stockOutLinesById[line.stockOutLineId] = updated | |||||
| return@forEach | |||||
| } | |||||
| val savedSol = applyStockOutLineDelta( | |||||
| stockOutLine = solEntity, | |||||
| deltaQty = submitQty, | |||||
| newStatus = newStatus, | |||||
| skipInventoryWrite = true, | |||||
| skipLedgerWrite = true, | |||||
| skipTryCompletePickOrderLine = true, | |||||
| deferPersistenceFlush = true | |||||
| ) | |||||
| stockOutLinesById[line.stockOutLineId] = savedSol | |||||
| val actualInventoryLotLineId = | |||||
| line.inventoryLotLineId ?: savedSol.inventoryLotLine?.id | |||||
| if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||||
| val ill = lotLinesById[actualInventoryLotLineId] | |||||
| ?: inventoryLotLineRepository.findById(actualInventoryLotLineId).orElseThrow().also { | |||||
| lotLinesById[actualInventoryLotLineId] = it | |||||
| } | } | ||||
| // 只有 submitQty > 0 时,才真正增加 qty 并触发库存扣减 | |||||
| updateStatus( | |||||
| UpdateStockOutLineStatusRequest( | |||||
| id = line.stockOutLineId, | |||||
| status = newStatus, | |||||
| qty = submitQty.toDouble(), | |||||
| skipLedgerWrite = true, | |||||
| skipInventoryWrite = true | |||||
| ) | |||||
| val item = savedSol.item | |||||
| val inventoryBeforeUpdate = item?.id?.let { itemId -> | |||||
| itemUomService.findInventoryForItemBaseUom(itemId) | |||||
| } | |||||
| val onHandQtyBeforeUpdate = | |||||
| (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||||
| applyPickToInventoryLotLineInBatch(ill, submitQty) | |||||
| createStockLedgerForPickDelta( | |||||
| stockOutLine = savedSol, | |||||
| deltaQty = submitQty, | |||||
| onHandQtyBeforeUpdate = onHandQtyBeforeUpdate, | |||||
| traceTag = lineTrace, | |||||
| flushAfterSave = false | |||||
| ) | ) | ||||
| println("[$lineTrace] stock_out_line qty/status updated with delta=$submitQty (inventory+ledger deferred)") | |||||
| } else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) { | |||||
| val item = savedSol.item | |||||
| val inventoryBeforeUpdate = item?.id?.let { itemId -> | |||||
| itemUomService.findInventoryForItemBaseUom(itemId) | |||||
| } | |||||
| val onHandQtyBeforeUpdate = | |||||
| (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||||
| createStockLedgerForPickDelta( | |||||
| stockOutLine = savedSol, | |||||
| deltaQty = submitQty, | |||||
| onHandQtyBeforeUpdate = onHandQtyBeforeUpdate, | |||||
| traceTag = lineTrace, | |||||
| flushAfterSave = false | |||||
| ) | |||||
| } | |||||
| // Inventory updates - 修复:使用增量数量 | |||||
| // ✅ 修复:如果 inventoryLotLineId 为 null,从 stock_out_line 中获取 | |||||
| val actualInventoryLotLineId = line.inventoryLotLineId | |||||
| ?: stockOutLine.inventoryLotLine?.id | |||||
| // 在 newBatchSubmit 方法中,修改这部分代码(大约在 1169-1185 行) | |||||
| if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||||
| println("[$lineTrace] Updating inventory lot line $actualInventoryLotLineId with qty=$submitQty") | |||||
| // ✅ 修复:在更新 inventory_lot_line 之前获取 inventory 的当前 onHandQty | |||||
| val item = stockOutLine.item | |||||
| val inventoryBeforeUpdate = item?.id?.let { itemId -> | |||||
| itemUomService.findInventoryForItemBaseUom(itemId) | |||||
| } | |||||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||||
| println("[$lineTrace] Inventory before update: onHandQty=$onHandQtyBeforeUpdate") | |||||
| inventoryLotLineService.updateInventoryLotLineQuantities( | |||||
| UpdateInventoryLotLineQuantitiesRequest( | |||||
| inventoryLotLineId = actualInventoryLotLineId, | |||||
| qty = submitQty, | |||||
| operation = "pick" | |||||
| ) | |||||
| ) | |||||
| if (submitQty > BigDecimal.ZERO) { | |||||
| // ✅ 修复:传入更新前的 onHandQty,让 createStockLedgerForPickDelta 使用它 | |||||
| createStockLedgerForPickDelta(line.stockOutLineId, submitQty, onHandQtyBeforeUpdate, lineTrace) | |||||
| } | |||||
| } else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) { | |||||
| // ✅ 修复:即使没有 inventoryLotLineId,也应该获取 inventory.onHandQty | |||||
| val item = stockOutLine.item | |||||
| val inventoryBeforeUpdate = item?.id?.let { itemId -> | |||||
| itemUomService.findInventoryForItemBaseUom(itemId) | |||||
| } | |||||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||||
| println("[$lineTrace] Warning: No inventoryLotLineId, still trying ledger creation") | |||||
| createStockLedgerForPickDelta(line.stockOutLineId, submitQty, onHandQtyBeforeUpdate, lineTrace) | |||||
| } | |||||
| try { | try { | ||||
| val stockOutLine = stockOutLines[line.stockOutLineId] | |||||
| val item = stockOutLine?.item | |||||
| val inventoryLotLine = line.inventoryLotLineId?.let { lotLines[it] } | |||||
| val item = savedSol.item | |||||
| val inventoryLotLine = line.inventoryLotLineId?.let { lid -> lotLinesById[lid] } | |||||
| if (item?.isBag == true && inventoryLotLine != null && submitQty > BigDecimal.ZERO) { | if (item?.isBag == true && inventoryLotLine != null && submitQty > BigDecimal.ZERO) { | ||||
| println(" Item isBag=true, creating BagLotLine...") | |||||
| // 根据 itemId 查找对应的 Bag | |||||
| val bag = bagRepository.findByItemIdAndDeletedIsFalse(item.id!!) | val bag = bagRepository.findByItemIdAndDeletedIsFalse(item.id!!) | ||||
| if (bag != null) { | if (bag != null) { | ||||
| val lotNo = inventoryLotLine.inventoryLot?.lotNo | val lotNo = inventoryLotLine.inventoryLot?.lotNo | ||||
| if (lotNo != null) { | if (lotNo != null) { | ||||
| @@ -1372,29 +1394,21 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||||
| bagId = bag.id!!, | bagId = bag.id!!, | ||||
| lotId = inventoryLotLine.inventoryLot?.id ?: 0L, | lotId = inventoryLotLine.inventoryLot?.id ?: 0L, | ||||
| itemId = item.id!!, | itemId = item.id!!, | ||||
| stockOutLineId = stockOutLine.id , | |||||
| stockOutLineId = savedSol.id, | |||||
| lotNo = lotNo, | lotNo = lotNo, | ||||
| stockQty = submitQty.toInt(), // 转换为 Int | |||||
| stockQty = submitQty.toInt(), | |||||
| date = LocalDate.now(), | date = LocalDate.now(), | ||||
| time = LocalTime.now() | time = LocalTime.now() | ||||
| ) | ) | ||||
| bagService.createBagLotLinesByBagId(createBagLotLineRequest) | bagService.createBagLotLinesByBagId(createBagLotLineRequest) | ||||
| println(" ✓ BagLotLine created successfully for item ${item.code}") | |||||
| } else { | |||||
| println(" Warning: lotNo is null, skipping BagLotLine creation") | |||||
| } | } | ||||
| } else { | |||||
| println(" Warning: Bag not found for itemId ${item.id}, skipping BagLotLine creation") | |||||
| } | } | ||||
| } | } | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| println(" Error creating BagLotLine: ${e.message}") | println(" Error creating BagLotLine: ${e.message}") | ||||
| e.printStackTrace() | e.printStackTrace() | ||||
| // 不中断主流程,只记录错误 | |||||
| } | } | ||||
| processedIds += line.stockOutLineId | processedIds += line.stockOutLineId | ||||
| println("[$lineTrace] Line processed successfully") | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| println("[$lineTrace] Error processing line: ${e.message}") | println("[$lineTrace] Error processing line: ${e.message}") | ||||
| e.printStackTrace() | e.printStackTrace() | ||||
| @@ -1402,39 +1416,39 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||||
| } | } | ||||
| } | } | ||||
| // 4) 移除:不需要保存 lotLines,因为它们没有被修改 | |||||
| // inventoryLotLineRepository.saveAll(lotLines.values.toList()) | |||||
| entityManager.flush() | |||||
| // ✅ 修复:批处理完成后,检查所有受影响的 pick order lines 是否应该标记为完成 | |||||
| val affectedPickOrderIds = request.lines | |||||
| .mapNotNull { line -> | |||||
| stockOutLines[line.stockOutLineId]?.pickOrderLine?.pickOrder?.id | |||||
| } | |||||
| .distinct() | |||||
| // 批次內已 defer:此處只處理本批有碰到的 POL,再刷新所屬 pick order 表頭,最後每個 conso 只掃一次 | |||||
| val polIdsTouched = request.lines.mapNotNull { line -> | |||||
| stockOutLinesById[line.stockOutLineId]?.pickOrderLine?.id | |||||
| }.distinct() | |||||
| val allPickOrderLineIdsToCheck = if (affectedPickOrderIds.isNotEmpty()) { | |||||
| affectedPickOrderIds.flatMap { pickOrderId -> | |||||
| pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(pickOrderId).mapNotNull { it.id } | |||||
| }.distinct() | |||||
| } else { | |||||
| emptyList() | |||||
| println("=== Checking ${polIdsTouched.size} pick order lines touched by batch ===") | |||||
| polIdsTouched.forEach { pickOrderLineId -> | |||||
| try { | |||||
| checkIsStockOutLineCompleted(pickOrderLineId, quiet = true) | |||||
| } catch (e: Exception) { | |||||
| println("Error checking pick order line $pickOrderLineId: ${e.message}") | |||||
| } | |||||
| } | } | ||||
| println("=== Checking ${allPickOrderLineIdsToCheck.size} pick order lines (all lines of affected pick orders) after batch submit ===") | |||||
| allPickOrderLineIdsToCheck.forEach { pickOrderLineId -> | |||||
| val pickOrderIdsTouched = request.lines.mapNotNull { line -> | |||||
| stockOutLinesById[line.stockOutLineId]?.pickOrderLine?.pickOrder?.id | |||||
| }.distinct() | |||||
| pickOrderIdsTouched.forEach { pickOrderId -> | |||||
| try { | try { | ||||
| checkIsStockOutLineCompleted(pickOrderLineId) | |||||
| refreshPickOrderHeaderIfAllLinesCompleted(pickOrderId) | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| println("Error checking pick order line $pickOrderLineId: ${e.message}") | |||||
| println("Error refreshing pick order header for pickOrderId=$pickOrderId: ${e.message}") | |||||
| } | } | ||||
| } | } | ||||
| val affectedConsoCodes = affectedPickOrderIds | |||||
| .mapNotNull { pickOrderId -> | |||||
| val po = pickOrderRepository.findById(pickOrderId).orElse(null) | |||||
| po?.consoCode | |||||
| } | |||||
| .filter { !it.isNullOrBlank() } | |||||
| .distinct() | |||||
| val affectedConsoCodes = pickOrderIdsTouched.mapNotNull { pickOrderId -> | |||||
| pickOrderRepository.findById(pickOrderId).orElse(null)?.consoCode | |||||
| } | |||||
| .filter { !it.isNullOrBlank() } | |||||
| .distinct() | |||||
| println("=== Checking completion by consoCode for ${affectedConsoCodes.size} affected consoCodes after batch submit ===") | println("=== Checking completion by consoCode for ${affectedConsoCodes.size} affected consoCodes after batch submit ===") | ||||
| affectedConsoCodes.forEach { consoCode -> | affectedConsoCodes.forEach { consoCode -> | ||||
| @@ -1577,63 +1591,72 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||||
| private fun createStockLedgerForPickDelta( | private fun createStockLedgerForPickDelta( | ||||
| stockOutLineId: Long, | |||||
| stockOutLine: StockOutLine, | |||||
| deltaQty: BigDecimal, | deltaQty: BigDecimal, | ||||
| onHandQtyBeforeUpdate: Double? = null, // ✅ 新增参数:更新前的 onHandQty | |||||
| traceTag: String? = null | |||||
| onHandQtyBeforeUpdate: Double? = null, | |||||
| traceTag: String? = null, | |||||
| flushAfterSave: Boolean = true | |||||
| ) { | ) { | ||||
| val tracePrefix = traceTag?.let { "[$it] " } ?: "[SOL=$stockOutLineId] " | |||||
| val solId = stockOutLine.id | |||||
| val tracePrefix = traceTag?.let { "[$it] " } ?: "[SOL=$solId] " | |||||
| if (deltaQty <= BigDecimal.ZERO) { | if (deltaQty <= BigDecimal.ZERO) { | ||||
| println("${tracePrefix}Skip ledger creation: deltaQty <= 0 (deltaQty=$deltaQty)") | |||||
| return | |||||
| } | |||||
| val sol = stockOutLineRepository.findById(stockOutLineId).orElse(null) | |||||
| if (sol == null) { | |||||
| println("${tracePrefix}Skip ledger creation: stockOutLine not found") | |||||
| if (flushAfterSave) { | |||||
| println("${tracePrefix}Skip ledger creation: deltaQty <= 0 (deltaQty=$deltaQty)") | |||||
| } | |||||
| return | return | ||||
| } | } | ||||
| val item = sol.item | |||||
| val item = stockOutLine.item | |||||
| if (item == null) { | if (item == null) { | ||||
| println("${tracePrefix}Skip ledger creation: stockOutLine.item is null") | |||||
| if (flushAfterSave) { | |||||
| println("${tracePrefix}Skip ledger creation: stockOutLine.item is null") | |||||
| } | |||||
| return | return | ||||
| } | } | ||||
| val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) | val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) | ||||
| if (inventory == null) { | if (inventory == null) { | ||||
| println("${tracePrefix}Skip ledger creation: inventory not found by itemId=${item.id}") | |||||
| if (flushAfterSave) { | |||||
| println("${tracePrefix}Skip ledger creation: inventory not found by itemId=${item.id}") | |||||
| } | |||||
| return | return | ||||
| } | } | ||||
| val previousBalance = resolvePreviousBalance( | val previousBalance = resolvePreviousBalance( | ||||
| itemId = item.id!!, | itemId = item.id!!, | ||||
| inventory = inventory, | inventory = inventory, | ||||
| onHandQtyBeforeUpdate = onHandQtyBeforeUpdate | onHandQtyBeforeUpdate = onHandQtyBeforeUpdate | ||||
| ) | ) | ||||
| val newBalance = previousBalance - deltaQty.toDouble() | val newBalance = previousBalance - deltaQty.toDouble() | ||||
| println("${tracePrefix}Creating ledger: previousBalance=$previousBalance, deltaQty=$deltaQty, newBalance=$newBalance, inventoryId=${inventory.id}") | |||||
| if (onHandQtyBeforeUpdate != null) { | |||||
| println("${tracePrefix}Using onHandQtyBeforeUpdate: $onHandQtyBeforeUpdate") | |||||
| if (flushAfterSave) { | |||||
| println("${tracePrefix}Creating ledger: previousBalance=$previousBalance, deltaQty=$deltaQty, newBalance=$newBalance, inventoryId=${inventory.id}") | |||||
| if (onHandQtyBeforeUpdate != null) { | |||||
| println("${tracePrefix}Using onHandQtyBeforeUpdate: $onHandQtyBeforeUpdate") | |||||
| } | |||||
| } | } | ||||
| val ledger = StockLedger().apply { | val ledger = StockLedger().apply { | ||||
| this.stockOutLine = sol | |||||
| this.stockOutLine = stockOutLine | |||||
| this.inventory = inventory | this.inventory = inventory | ||||
| this.inQty = null | this.inQty = null | ||||
| this.outQty = deltaQty.toDouble() | |||||
| this.outQty = deltaQty.toDouble() | |||||
| this.balance = newBalance | this.balance = newBalance | ||||
| this.type = "NOR" | |||||
| this.type = "NOR" | |||||
| this.itemId = item.id | this.itemId = item.id | ||||
| this.itemCode = item.code | this.itemCode = item.code | ||||
| this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id | this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id | ||||
| ?: inventory.uom?.id | ?: inventory.uom?.id | ||||
| this.date = LocalDate.now() | this.date = LocalDate.now() | ||||
| } | } | ||||
| stockLedgerRepository.saveAndFlush(ledger) | |||||
| println("${tracePrefix}Ledger created successfully for stockOutLineId=$stockOutLineId") | |||||
| if (flushAfterSave) { | |||||
| stockLedgerRepository.saveAndFlush(ledger) | |||||
| println("${tracePrefix}Ledger created successfully for stockOutLineId=$solId") | |||||
| } else { | |||||
| stockLedgerRepository.save(ledger) | |||||
| } | |||||
| } | } | ||||
| private fun resolvePreviousBalance( | private fun resolvePreviousBalance( | ||||
| @@ -1904,20 +1927,20 @@ open fun batchScan(request: com.ffii.fpsms.modules.stock.web.model.BatchScanRequ | |||||
| } | } | ||||
| @Transactional | @Transactional | ||||
| fun applyStockOutLineDelta( | fun applyStockOutLineDelta( | ||||
| stockOutLineId: Long, | |||||
| stockOutLine: StockOutLine, | |||||
| deltaQty: BigDecimal, | deltaQty: BigDecimal, | ||||
| newStatus: String?, | newStatus: String?, | ||||
| typeOverride: String? = null, | typeOverride: String? = null, | ||||
| skipInventoryWrite: Boolean = false, | skipInventoryWrite: Boolean = false, | ||||
| skipLedgerWrite: Boolean = false, | skipLedgerWrite: Boolean = false, | ||||
| operator: String? = null, | operator: String? = null, | ||||
| eventTime: LocalDateTime = LocalDateTime.now() | |||||
| eventTime: LocalDateTime = LocalDateTime.now(), | |||||
| skipTryCompletePickOrderLine: Boolean = false, | |||||
| deferPersistenceFlush: Boolean = false | |||||
| ): StockOutLine { | ): StockOutLine { | ||||
| require(deltaQty >= BigDecimal.ZERO) { "deltaQty cannot be negative" } | require(deltaQty >= BigDecimal.ZERO) { "deltaQty cannot be negative" } | ||||
| val sol = stockOutLineRepository.findById(stockOutLineId).orElseThrow { | |||||
| IllegalArgumentException("StockOutLine not found: $stockOutLineId") | |||||
| } | |||||
| val sol = stockOutLine | |||||
| // 1) update stock_out_line qty/status/time | // 1) update stock_out_line qty/status/time | ||||
| val currentQty = BigDecimal(sol.qty?.toString() ?: "0") | val currentQty = BigDecimal(sol.qty?.toString() ?: "0") | ||||
| @@ -1941,7 +1964,9 @@ fun applyStockOutLineDelta( | |||||
| if (!operator.isNullOrBlank()) { | if (!operator.isNullOrBlank()) { | ||||
| sol.modifiedBy = operator | sol.modifiedBy = operator | ||||
| } | } | ||||
| val savedSol = stockOutLineRepository.saveAndFlush(sol) | |||||
| val savedSol = | |||||
| if (deferPersistenceFlush) stockOutLineRepository.save(sol) | |||||
| else stockOutLineRepository.saveAndFlush(sol) | |||||
| // Nothing to post if no delta | // Nothing to post if no delta | ||||
| if (deltaQty == BigDecimal.ZERO || !isPickEnd) { | if (deltaQty == BigDecimal.ZERO || !isPickEnd) { | ||||
| @@ -1973,7 +1998,8 @@ fun applyStockOutLineDelta( | |||||
| if (!operator.isNullOrBlank()) { | if (!operator.isNullOrBlank()) { | ||||
| latestLotLine.modifiedBy = operator | latestLotLine.modifiedBy = operator | ||||
| } | } | ||||
| inventoryLotLineRepository.saveAndFlush(latestLotLine) | |||||
| if (deferPersistenceFlush) inventoryLotLineRepository.save(latestLotLine) | |||||
| else inventoryLotLineRepository.saveAndFlush(latestLotLine) | |||||
| } | } | ||||
| } else { | } else { | ||||
| val lotUpdateResult = inventoryLotLineService.updateInventoryLotLineQuantities( | val lotUpdateResult = inventoryLotLineService.updateInventoryLotLineQuantities( | ||||
| @@ -2035,11 +2061,12 @@ fun applyStockOutLineDelta( | |||||
| this.modifiedBy = operator | this.modifiedBy = operator | ||||
| } | } | ||||
| } | } | ||||
| stockLedgerRepository.saveAndFlush(ledger) | |||||
| if (deferPersistenceFlush) stockLedgerRepository.save(ledger) | |||||
| else stockLedgerRepository.saveAndFlush(ledger) | |||||
| } | } | ||||
| // 4) existing side-effects keep same behavior | |||||
| if (isEndStatus(savedSol.status)) { | |||||
| // 4) existing side-effects keep same behavior (batch submit defers to end of newBatchSubmit) | |||||
| if (!skipTryCompletePickOrderLine && isEndStatus(savedSol.status)) { | |||||
| savedSol.pickOrderLine?.id?.let { tryCompletePickOrderLine(it) } | savedSol.pickOrderLine?.id?.let { tryCompletePickOrderLine(it) } | ||||
| } | } | ||||
| @@ -2317,28 +2317,27 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( | |||||
| fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes<StockTransactionResponse> { | |||||
| open fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes<StockTransactionResponse> { | |||||
| val startTime = System.currentTimeMillis() | val startTime = System.currentTimeMillis() | ||||
| // 添加调试日志 | |||||
| println("Search request received: itemCode=${request.itemCode}, itemName=${request.itemName}, type=${request.type}, startDate=${request.startDate}, endDate=${request.endDate}") | |||||
| // 验证:itemCode 或 itemName 至少一个不为 null 或空字符串 | |||||
| println( | |||||
| "Search request received: itemCode=${request.itemCode}, itemName=${request.itemName}, " + | |||||
| "type=${request.type}, startDate=${request.startDate}, endDate=${request.endDate}" | |||||
| ) | |||||
| val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() } | val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() } | ||||
| val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() } | val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() } | ||||
| if (itemCode == null && itemName == null) { | if (itemCode == null && itemName == null) { | ||||
| println("Search validation failed: both itemCode and itemName are null/empty") | println("Search validation failed: both itemCode and itemName are null/empty") | ||||
| return RecordsRes(emptyList(), 0) | return RecordsRes(emptyList(), 0) | ||||
| } | } | ||||
| // request.startDate 和 request.endDate 已经是 LocalDate? 类型,不需要转换 | |||||
| val startDate = request.startDate | val startDate = request.startDate | ||||
| val endDate = request.endDate | val endDate = request.endDate | ||||
| println("Processed params: itemCode=$itemCode, itemName=$itemName, startDate=$startDate, endDate=$endDate") | println("Processed params: itemCode=$itemCode, itemName=$itemName, startDate=$startDate, endDate=$endDate") | ||||
| // 使用 Repository 查询(更简单、更快) | |||||
| val total = stockLedgerRepository.countStockTransactions( | val total = stockLedgerRepository.countStockTransactions( | ||||
| itemCode = itemCode, | itemCode = itemCode, | ||||
| itemName = itemName, | itemName = itemName, | ||||
| @@ -2346,20 +2345,17 @@ fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes< | |||||
| startDate = startDate, | startDate = startDate, | ||||
| endDate = endDate | endDate = endDate | ||||
| ) | ) | ||||
| println("Total count: $total") | println("Total count: $total") | ||||
| // 如果 pageSize 是默认值(100)或未设置,使用 total 作为 pageSize | |||||
| val actualPageSize = if (request.pageSize == 100) { | val actualPageSize = if (request.pageSize == 100) { | ||||
| total.toInt().coerceAtLeast(1) | total.toInt().coerceAtLeast(1) | ||||
| } else { | } else { | ||||
| request.pageSize | request.pageSize | ||||
| } | } | ||||
| // 计算 offset | |||||
| val offset = request.pageNum * actualPageSize | val offset = request.pageNum * actualPageSize | ||||
| // 查询所有符合条件的记录 | |||||
| val ledgers = stockLedgerRepository.findStockTransactions( | val ledgers = stockLedgerRepository.findStockTransactions( | ||||
| itemCode = itemCode, | itemCode = itemCode, | ||||
| itemName = itemName, | itemName = itemName, | ||||
| @@ -2367,13 +2363,13 @@ fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes< | |||||
| startDate = startDate, | startDate = startDate, | ||||
| endDate = endDate | endDate = endDate | ||||
| ) | ) | ||||
| println("Found ${ledgers.size} ledgers") | println("Found ${ledgers.size} ledgers") | ||||
| val transactions = ledgers.map { ledger -> | val transactions = ledgers.map { ledger -> | ||||
| val stockInLine = ledger.stockInLine | val stockInLine = ledger.stockInLine | ||||
| val stockOutLine = ledger.stockOutLine | val stockOutLine = ledger.stockOutLine | ||||
| StockTransactionResponse( | StockTransactionResponse( | ||||
| id = stockInLine?.id ?: stockOutLine?.id ?: 0L, | id = stockInLine?.id ?: stockOutLine?.id ?: 0L, | ||||
| transactionType = if (ledger.inQty != null && ledger.inQty!! > 0) "IN" else "OUT", | transactionType = if (ledger.inQty != null && ledger.inQty!! > 0) "IN" else "OUT", | ||||
| @@ -2400,20 +2396,18 @@ fun searchStockTransactions(request: SearchStockTransactionRequest): RecordsRes< | |||||
| remarks = stockInLine?.remarks | remarks = stockInLine?.remarks | ||||
| ) | ) | ||||
| } | } | ||||
| // 按 date 排序(从旧到新),如果 date 为 null 则使用 transactionDate 的日期部分 | |||||
| val sortedTransactions = transactions.sortedWith( | val sortedTransactions = transactions.sortedWith( | ||||
| compareBy<StockTransactionResponse>( | compareBy<StockTransactionResponse>( | ||||
| { it.date ?: it.transactionDate?.toLocalDate() }, | { it.date ?: it.transactionDate?.toLocalDate() }, | ||||
| { it.transactionDate } | { it.transactionDate } | ||||
| ) | ) | ||||
| ) | ) | ||||
| // 应用分页 | |||||
| val paginatedTransactions = sortedTransactions.drop(offset).take(actualPageSize) | val paginatedTransactions = sortedTransactions.drop(offset).take(actualPageSize) | ||||
| val totalTime = System.currentTimeMillis() - startTime | val totalTime = System.currentTimeMillis() - startTime | ||||
| println("Total time (Repository query): ${totalTime}ms, count: ${paginatedTransactions.size}, total: $total") | println("Total time (Repository query): ${totalTime}ms, count: ${paginatedTransactions.size}, total: $total") | ||||
| return RecordsRes(paginatedTransactions, total.toInt()) | return RecordsRes(paginatedTransactions, total.toInt()) | ||||
| } | } | ||||
| } | } | ||||
| @@ -61,7 +61,9 @@ data class UpdateStockOutLineStatusRequest( | |||||
| val qty: Double? = null, | val qty: Double? = null, | ||||
| val remarks: String? = null, | val remarks: String? = null, | ||||
| val skipLedgerWrite: Boolean? = false, | val skipLedgerWrite: Boolean? = false, | ||||
| val skipInventoryWrite: Boolean? = false | |||||
| val skipInventoryWrite: Boolean? = false, | |||||
| /** When true (batch submit path): skip per-line POL/PO rollup, conso completion scan, duplicate BagLotLine; caller runs these once at end. */ | |||||
| val deferAggregatePickOrderEffects: Boolean? = false | |||||
| ) | ) | ||||
| data class UpdateStockOutLineStatusByQRCodeAndLotNoRequest( | data class UpdateStockOutLineStatusByQRCodeAndLotNoRequest( | ||||
| val pickOrderLineId: Long, | val pickOrderLineId: Long, | ||||
| @@ -535,7 +535,7 @@ | |||||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | <textElement textAlignment="Right" verticalAlignment="Middle"> | ||||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | <font fontName="微軟正黑體" size="10" isBold="true"/> | ||||
| </textElement> | </textElement> | ||||
| <text><![CDATA[總和]]></text> | |||||
| <text><![CDATA[上架總計]]></text> | |||||
| </staticText> | </staticText> | ||||
| <staticText> | <staticText> | ||||
| <reportElement x="560" y="0" width="45" height="20" uuid="85c77a9b-c044-4bc2-8cd9-3b0058e4b74e"> | <reportElement x="560" y="0" width="45" height="20" uuid="85c77a9b-c044-4bc2-8cd9-3b0058e4b74e"> | ||||
| @@ -87,7 +87,7 @@ | |||||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | <textElement textAlignment="Right" verticalAlignment="Middle"> | ||||
| <font fontName="微軟正黑體" size="10"/> | <font fontName="微軟正黑體" size="10"/> | ||||
| </textElement> | </textElement> | ||||
| <text><![CDATA[總入倉數量:]]></text> | |||||
| <text><![CDATA[總上架數量:]]></text> | |||||
| </staticText> | </staticText> | ||||
| <textField> | <textField> | ||||
| <reportElement x="280" y="0" width="50" height="18" uuid="d98c4478-22bd-4fd6-9be4-b3777f91de6d"> | <reportElement x="280" y="0" width="50" height="18" uuid="d98c4478-22bd-4fd6-9be4-b3777f91de6d"> | ||||
| @@ -159,7 +159,7 @@ | |||||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | <textElement textAlignment="Right" verticalAlignment="Middle"> | ||||
| <font fontName="微軟正黑體" size="10"/> | <font fontName="微軟正黑體" size="10"/> | ||||
| </textElement> | </textElement> | ||||
| <text><![CDATA[入庫數量]]></text> | |||||
| <text><![CDATA[上架數量]]></text> | |||||
| </staticText> | </staticText> | ||||
| <staticText> | <staticText> | ||||
| <reportElement stretchType="RelativeToTallestObject" x="10" y="80" width="110" height="28" uuid="3fa7c301-1c2a-430b-8985-338ebf7aa6cf"> | <reportElement stretchType="RelativeToTallestObject" x="10" y="80" width="110" height="28" uuid="3fa7c301-1c2a-430b-8985-338ebf7aa6cf"> | ||||