| @@ -842,7 +842,7 @@ open class DeliveryOrderService( | |||
| this.item = pickOrderLine.item | |||
| this.status = StockOutLineStatus.PENDING.status | |||
| this.qty = 0.0 | |||
| this.type = "Nor" | |||
| this.type = "NOR" | |||
| } | |||
| stockOutLineRepository.save(line) | |||
| } | |||
| @@ -1524,7 +1524,7 @@ open class DeliveryOrderService( | |||
| StockOutLineStatus.PENDING.status // 有正常库存批次时使用 PENDING | |||
| } | |||
| this.qty = 0.0 | |||
| this.type = "Nor" | |||
| this.type = "NOR" | |||
| } | |||
| stockOutLineRepository.save(line) | |||
| } | |||
| @@ -159,7 +159,12 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> { | |||
| SELECT jo FROM JobOrder jo | |||
| WHERE jo.deleted = false | |||
| AND (:code IS NULL OR jo.code LIKE CONCAT('%', :code, '%')) | |||
| AND (:bomName IS NULL OR jo.bom.name LIKE CONCAT('%', :bomName, '%')) | |||
| AND ( | |||
| :bomName IS NULL | |||
| OR jo.bom.name LIKE CONCAT('%', :bomName, '%') | |||
| OR jo.bom.item.code LIKE CONCAT('%', :bomName, '%') | |||
| OR jo.bom.item.name LIKE CONCAT('%', :bomName, '%') | |||
| ) | |||
| AND (:planStartFrom IS NULL OR jo.planStart >= :planStartFrom) | |||
| AND (:planStartTo IS NULL OR jo.planStart <= :planStartTo) | |||
| AND ( | |||
| @@ -673,7 +673,7 @@ open class JobOrderService( | |||
| this.item = pickOrderLine.item | |||
| this.status = StockOutLineStatus.PENDING.status | |||
| this.qty = 0.0 | |||
| this.type = "Nor" | |||
| this.type = "NOR" | |||
| } | |||
| stockOutLineRepository.save(line) | |||
| } | |||
| @@ -203,6 +203,48 @@ open class ItemUomService( | |||
| return stockQty.setScale(0, RoundingMode.DOWN) | |||
| } | |||
| /** | |||
| * Convert purchase qty -> stock qty and keep decimal precision. | |||
| * Used for PO flows where decimal quantity must be preserved. | |||
| */ | |||
| open fun convertPurchaseQtyToStockQtyPrecise(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | |||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty | |||
| val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty | |||
| val one = BigDecimal.ONE | |||
| val calcScale = 10 | |||
| val baseQty = purchaseQty | |||
| .multiply(purchaseUnit.ratioN ?: one) | |||
| .divide(purchaseUnit.ratioD ?: one, calcScale, RoundingMode.HALF_UP) | |||
| val stockQty = baseQty | |||
| .multiply(stockUnit.ratioD ?: one) | |||
| .divide(stockUnit.ratioN ?: one, calcScale, RoundingMode.HALF_UP) | |||
| return stockQty.setScale(2, RoundingMode.HALF_UP) | |||
| } | |||
| /** | |||
| * Convert qty from a specific UOM -> stock qty and keep decimal precision. | |||
| * Used for PO (M18 UOM) conversion where round-down is not allowed. | |||
| */ | |||
| open fun convertQtyToStockQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | |||
| val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty | |||
| val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO | |||
| val one = BigDecimal.ONE | |||
| val calcScale = 10 | |||
| val baseQty = sourceQty | |||
| .multiply(itemUom.ratioN ?: one) | |||
| .divide(itemUom.ratioD ?: one, calcScale, RoundingMode.HALF_UP) | |||
| val stockQty = baseQty | |||
| .multiply(stockUnit.ratioD ?: one) | |||
| .divide(stockUnit.ratioN ?: one, calcScale, RoundingMode.HALF_UP) | |||
| return stockQty.setScale(2, RoundingMode.HALF_UP) | |||
| } | |||
| // See if need to update the response | |||
| open fun saveItemUom(request: ItemUomRequest): ItemUom { | |||
| val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } | |||
| @@ -1150,7 +1150,7 @@ open class PickOrderService( | |||
| this.status = | |||
| com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus.PENDING.status | |||
| this.qty = 0.0 | |||
| this.type = "Nor" | |||
| this.type = "NOR" | |||
| } | |||
| stockOutLIneRepository.save(line) | |||
| precreated++ | |||
| @@ -2110,7 +2110,7 @@ open class PickOrderService( | |||
| this.status = | |||
| com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus.PENDING.status | |||
| this.qty = 0.0 | |||
| this.type = "Nor" | |||
| this.type = "NOR" | |||
| } | |||
| stockOutLIneRepository.save(line) | |||
| precreated++ | |||
| @@ -269,7 +269,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||
| val qtyM18 = thisPol.qtyM18 | |||
| val uomM18Id = thisPol.uomM18?.id | |||
| val stockQtyFromM18 = if (itemId != null && qtyM18 != null && uomM18Id != null) { | |||
| itemUomService.convertQtyToStockQtyRoundDown(itemId, uomM18Id, qtyM18) | |||
| itemUomService.convertQtyToStockQtyPrecise(itemId, uomM18Id, qtyM18) | |||
| } else { | |||
| BigDecimal.ZERO | |||
| } | |||
| @@ -279,7 +279,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||
| stockUomDesc = iu?.uom?.udfudesc, | |||
| stockQty = if (stockQtyFromM18 > BigDecimal.ZERO) stockQtyFromM18 else { | |||
| // fallback to legacy behavior when M18 fields are missing | |||
| iu?.item?.id?.let { iId -> itemUomService.convertPurchaseQtyToStockQty(iId, (thisPol.qty ?: BigDecimal.ZERO)) } ?: BigDecimal.ZERO | |||
| iu?.item?.id?.let { iId -> itemUomService.convertPurchaseQtyToStockQtyPrecise(iId, (thisPol.qty ?: BigDecimal.ZERO)) } ?: BigDecimal.ZERO | |||
| }, | |||
| stockRatioN = iu?.ratioN, | |||
| stockRatioD = iu?.ratioD, | |||
| @@ -63,7 +63,7 @@ open class StockInLine : BaseEntity<Long>() { | |||
| open var acceptedQty: BigDecimal? = null | |||
| @Column(name = "acceptedQtyM18") | |||
| open var acceptedQtyM18: Int? = null | |||
| open var acceptedQtyM18: BigDecimal? = null | |||
| @Column(name = "price", precision = 14, scale = 2) | |||
| open var price: BigDecimal? = null | |||
| @@ -134,6 +134,6 @@ open class StockInLine : BaseEntity<Long>() { | |||
| */ | |||
| fun getReceivedQtyM18ForPol(): BigDecimal? = | |||
| purchaseOrderLine?.stockInLines?.sumOf { sil -> | |||
| sil.acceptedQtyM18?.let { BigDecimal.valueOf(it.toLong()) } ?: BigDecimal.ZERO | |||
| sil.acceptedQtyM18 ?: BigDecimal.ZERO | |||
| } | |||
| } | |||
| @@ -28,7 +28,7 @@ interface StockInLineInfo { | |||
| val receivedQty: BigDecimal? | |||
| val demandQty: BigDecimal? | |||
| val acceptedQty: BigDecimal | |||
| @get:Value("#{target.acceptedQtyM18 != null ? new java.math.BigDecimal(target.acceptedQtyM18) : null}") | |||
| @get:Value("#{target.acceptedQtyM18}") | |||
| val purchaseAcceptedQty: BigDecimal? | |||
| @get:Value("#{target.purchaseOrderLine?.qtyM18}") | |||
| val qty: BigDecimal? | |||
| @@ -352,67 +352,5 @@ open class InventoryService( | |||
| } | |||
| // @Throws(IOException::class) | |||
| // open fun updateInventory(request: SaveInventoryRequest): MessageResponse { | |||
| // // out need id | |||
| // // in not necessary | |||
| // var reqQty = request.qty | |||
| // if (request.type === "out") reqQty *= -1 | |||
| // if (request.id !== null) { // old record | |||
| // val inventory = inventoryRepository.findById(request.id).orElseThrow() | |||
| // val newStatus = request.status ?: inventory.status | |||
| // val newExpiry = request.expiryDate ?: inventory.expiryDate | |||
| // // uom should not be changing | |||
| // // stock in line should not be changing | |||
| // // item id should not be changing | |||
| // inventory.apply { | |||
| // qty = inventory.qty!! + reqQty | |||
| // expiryDate = newExpiry | |||
| // status = newStatus | |||
| // } | |||
| // val savedInventory = inventoryRepository.saveAndFlush(inventory) | |||
| // return MessageResponse( | |||
| // id = savedInventory.id, | |||
| // code = savedInventory.lotNo, | |||
| // name = "savedInventory.item!!.name", | |||
| // type = savedInventory.status, | |||
| // message = "update success", | |||
| // errorPosition = null | |||
| // ) | |||
| // } else { // new record | |||
| // val inventory = Inventory() | |||
| // val item = itemsRepository.findById(request.itemId).orElseThrow() | |||
| // val from = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE) | |||
| // val to = LocalDate.now().plusDays(1).format(DateTimeFormatter.ISO_LOCAL_DATE) | |||
| //// val stockInLine = .... | |||
| // val args = mapOf( | |||
| // "from" to from, | |||
| // "to" to to, | |||
| // "itemId" to item.id | |||
| // ) | |||
| // val prefix = "LOT" | |||
| // val count = jdbcDao.queryForInt(INVENTORY_COUNT.toString(), args) | |||
| // val newLotNo = CodeGenerator.generateCode(prefix, item.id!!, count) | |||
| // val newExpiry = request.expiryDate | |||
| // inventory.apply { | |||
| //// this.stockInLine = stockInline | |||
| //// this.item = item | |||
| // stockInLine = 0 | |||
| // qty = reqQty | |||
| // lotNo = newLotNo | |||
| // expiryDate = newExpiry | |||
| // uomId = 0 | |||
| // // status default "pending" in db | |||
| // } | |||
| // val savedInventory = inventoryRepository.saveAndFlush(inventory) | |||
| // return MessageResponse( | |||
| // id = savedInventory.id, | |||
| // code = savedInventory.lotNo, | |||
| // name = "savedInventory.item!!.name", | |||
| // type = savedInventory.status, | |||
| // message = "save success", | |||
| // errorPosition = null | |||
| // ) | |||
| // } | |||
| // } | |||
| } | |||
| @@ -254,20 +254,20 @@ open class StockInLineService( | |||
| itemNo = item.code | |||
| this.stockIn = stockIn | |||
| // PO-origin: | |||
| // 1) store user-input qty in acceptedQtyM18 (M18 unit) | |||
| // 2) calculate stock acceptedQty by converting from M18 unit | |||
| // 1) keep user-input qty in acceptedQtyM18 (M18 unit, decimal allowed) | |||
| // 2) calculate stock acceptedQty by converting from M18 unit without round-down | |||
| if (pol != null && item.id != null) { | |||
| acceptedQtyM18 = request.acceptedQty.toInt() | |||
| acceptedQtyM18 = request.acceptedQty | |||
| val m18UomId = pol.uomM18?.id | |||
| acceptedQty = if (m18UomId != null) { | |||
| itemUomService.convertQtyToStockQtyRoundDown( | |||
| itemUomService.convertQtyToStockQtyPrecise( | |||
| item.id!!, | |||
| m18UomId, | |||
| request.acceptedQty | |||
| ) | |||
| } else { | |||
| // fallback to legacy: treat request.acceptedQty as purchase unit qty | |||
| itemUomService.convertPurchaseQtyToStockQtyRoundDown( | |||
| itemUomService.convertPurchaseQtyToStockQtyPrecise( | |||
| item.id!!, | |||
| request.acceptedQty | |||
| ) | |||
| @@ -285,7 +285,7 @@ open class StockInLineService( | |||
| val m18UomId = pol.uomM18?.id | |||
| val qtyM18 = pol.qtyM18 | |||
| this.demandQty = if (m18UomId != null && qtyM18 != null) { | |||
| itemUomService.convertQtyToStockQtyRoundDown( | |||
| itemUomService.convertQtyToStockQtyPrecise( | |||
| item.id!!, | |||
| m18UomId, | |||
| qtyM18 | |||
| @@ -293,14 +293,14 @@ open class StockInLineService( | |||
| } else { | |||
| // fallback to legacy: treat pol.qty as purchase unit qty | |||
| pol.qty?.let { polQty -> | |||
| itemUomService.convertPurchaseQtyToStockQty(item.id!!, polQty) | |||
| itemUomService.convertPurchaseQtyToStockQtyPrecise(item.id!!, polQty) | |||
| } | |||
| } | |||
| } | |||
| dnNo = request.dnNo | |||
| receiptDate = request.receiptDate?.atStartOfDay() ?: LocalDateTime.now() | |||
| productLotNo = request.productLotNo | |||
| this.type = "Nor" | |||
| this.type = "NOR" | |||
| status = StockInLineStatus.PENDING.status | |||
| } | |||
| if (jo != null) { | |||
| @@ -598,7 +598,7 @@ open class StockInLineService( | |||
| val pol = sil.purchaseOrderLine!! | |||
| // For PO-origin GRN, M18 ant qty must use the M18 UOM and received M18 qty. | |||
| val totalQtyM18 = silList.sumOf { | |||
| it.acceptedQtyM18?.let { qty -> BigDecimal.valueOf(qty.toLong()) } ?: BigDecimal.ZERO | |||
| it.acceptedQtyM18 ?: BigDecimal.ZERO | |||
| } | |||
| val unitIdFromDataLog = (pol.m18DataLog?.dataLog?.get("unitId") as? Number)?.toLong()?.toInt() | |||
| val itemName = (sil.item?.name ?: pol.item?.name).orEmpty() // always non-null for M18 bDesc/bDesc_en | |||
| @@ -790,7 +790,7 @@ open class StockInLineService( | |||
| if (request.qcAccept != true && request.status != StockInLineStatus.RECEIVED.status) { | |||
| val requestQty = request.acceptQty ?: request.acceptedQty | |||
| this.acceptedQty = if (this.purchaseOrderLine != null && this.item?.id != null && requestQty != null) { | |||
| itemUomService.convertPurchaseQtyToStockQty(this.item!!.id!!, requestQty) | |||
| itemUomService.convertPurchaseQtyToStockQtyPrecise(this.item!!.id!!, requestQty) | |||
| } else { | |||
| requestQty ?: this.acceptedQty | |||
| } | |||
| @@ -804,7 +804,7 @@ open class StockInLineService( | |||
| val requestQty = request.acceptQty ?: request.acceptedQty | |||
| if (requestQty != null) { | |||
| this.acceptedQty = if (this.purchaseOrderLine != null && this.item?.id != null) { | |||
| itemUomService.convertPurchaseQtyToStockQty(this.item!!.id!!, requestQty) | |||
| itemUomService.convertPurchaseQtyToStockQtyPrecise(this.item!!.id!!, requestQty) | |||
| } else { | |||
| requestQty | |||
| } | |||
| @@ -820,10 +820,10 @@ open class StockInLineService( | |||
| val m18UomId = pol.uomM18?.id | |||
| val qtyM18 = pol.qtyM18 | |||
| this.demandQty = if (m18UomId != null && qtyM18 != null) { | |||
| itemUomService.convertQtyToStockQtyRoundDown(itemId, m18UomId, qtyM18) | |||
| itemUomService.convertQtyToStockQtyPrecise(itemId, m18UomId, qtyM18) | |||
| } else if (pol.qty != null) { | |||
| // fallback to legacy fields when M18 fields are missing | |||
| itemUomService.convertPurchaseQtyToStockQty(itemId, pol.qty!!) | |||
| itemUomService.convertPurchaseQtyToStockQtyPrecise(itemId, pol.qty!!) | |||
| } else { | |||
| this.demandQty | |||
| } | |||
| @@ -45,6 +45,7 @@ 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 java.util.UUID | |||
| @Service | |||
| open class StockOutLineService( | |||
| private val jdbcDao: JdbcDao, | |||
| @@ -184,7 +185,7 @@ val existingStockOutLine = stockOutLineRepository.findByPickOrderLineIdAndInvent | |||
| this.inventoryLotLine = inventoryLotLine | |||
| this.pickOrderLine = pickOrderLine | |||
| this.status = StockOutLineStatus.PENDING.status | |||
| this.type = "Nor" | |||
| this.type = "NOR" | |||
| } | |||
| val savedStockOutLine = saveAndFlush(stockOutLine) | |||
| createStockLedgerForStockOut(savedStockOutLine) | |||
| @@ -283,7 +284,7 @@ open fun createWithoutConso(request: CreateStockOutLineWithoutConsoRequest): Mes | |||
| this.inventoryLotLine = inventoryLotLine | |||
| this.pickOrderLine = updatedPickOrderLine | |||
| this.status = StockOutLineStatus.PENDING.status | |||
| this.type = "Nor" | |||
| this.type = "NOR" | |||
| } | |||
| println("Created stockOutLine with qty: ${request.qty}") | |||
| @@ -1167,7 +1168,9 @@ open fun updateStockOutLineStatusByQRCodeAndLotNo(request: UpdateStockOutLineSta | |||
| @Transactional(rollbackFor = [Exception::class]) | |||
| open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| val startTime = System.currentTimeMillis() | |||
| val traceId = "BATCH-${UUID.randomUUID().toString().substring(0, 8)}" | |||
| println("=== BATCH SUBMIT START ===") | |||
| println("[$traceId] Batch submit request received") | |||
| println("Start time: ${java.time.LocalDateTime.now()}") | |||
| println("Request lines count: ${request.lines.size}") | |||
| @@ -1208,8 +1211,9 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| // 3) Process each request line | |||
| request.lines.forEach { line: QrPickSubmitLineRequest -> | |||
| val lineTrace = "$traceId|SOL=${line.stockOutLineId}" | |||
| try { | |||
| println("Processing line: stockOutLineId=${line.stockOutLineId}, noLot=${line.noLot}") | |||
| println("[$lineTrace] Processing line, noLot=${line.noLot}") | |||
| if (line.noLot) { | |||
| // noLot branch | |||
| @@ -1219,7 +1223,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| qty = 0.0 | |||
| )) | |||
| processedIds += line.stockOutLineId | |||
| println(" ✓ noLot item processed") | |||
| println("[$lineTrace] noLot processed (status->completed, qty=0)") | |||
| return@forEach | |||
| } | |||
| @@ -1228,7 +1232,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| ?: throw IllegalStateException("StockOutLine ${line.stockOutLineId} not found") | |||
| val currentStatus = stockOutLine.status?.trim()?.lowercase() | |||
| if (currentStatus == "completed" || currentStatus == "complete") { | |||
| println(" Skipping already completed stockOutLineId=${line.stockOutLineId}") | |||
| println("[$lineTrace] Skip because current status is already completed") | |||
| return@forEach | |||
| } | |||
| @@ -1236,19 +1240,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| val targetActual = line.actualPickQty ?: BigDecimal.ZERO | |||
| val required = line.requiredQty ?: BigDecimal.ZERO | |||
| println(" Current qty: $currentActual, Target qty: $targetActual, Required: $required") | |||
| println("[$lineTrace] currentActual=$currentActual, targetActual=$targetActual, required=$required") | |||
| // 计算增量(前端发送的是目标累计值) | |||
| val submitQty = targetActual - currentActual | |||
| println(" Submit qty (increment): $submitQty") | |||
| println("[$lineTrace] submitQty(increment)=$submitQty") | |||
| // 使用前端发送的状态,否则根据数量自动判断 | |||
| val newStatus = line.stockOutLineStatus | |||
| ?: if (targetActual >= required) "completed" else "partially_completed" | |||
| if (submitQty <= BigDecimal.ZERO) { | |||
| println(" Submit qty <= 0, only update status for stockOutLineId=${line.stockOutLineId}") | |||
| println("[$lineTrace] submitQty<=0, only update status, skip inventory+ledger") | |||
| updateStatus( | |||
| UpdateStockOutLineStatusRequest( | |||
| @@ -1274,6 +1278,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| skipInventoryWrite = true | |||
| ) | |||
| ) | |||
| println("[$lineTrace] stock_out_line qty/status updated with delta=$submitQty (inventory+ledger deferred)") | |||
| // Inventory updates - 修复:使用增量数量 | |||
| // ✅ 修复:如果 inventoryLotLineId 为 null,从 stock_out_line 中获取 | |||
| @@ -1282,7 +1287,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| // 在 newBatchSubmit 方法中,修改这部分代码(大约在 1169-1185 行) | |||
| if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||
| println(" Updating inventory lot line ${actualInventoryLotLineId} with qty $submitQty") | |||
| println("[$lineTrace] Updating inventory lot line $actualInventoryLotLineId with qty=$submitQty") | |||
| // ✅ 修复:在更新 inventory_lot_line 之前获取 inventory 的当前 onHandQty | |||
| val item = stockOutLine.item | |||
| @@ -1291,7 +1296,7 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||
| } | |||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| println(" Inventory before update: onHandQty=$onHandQtyBeforeUpdate") | |||
| println("[$lineTrace] Inventory before update: onHandQty=$onHandQtyBeforeUpdate") | |||
| inventoryLotLineService.updateInventoryLotLineQuantities( | |||
| UpdateInventoryLotLineQuantitiesRequest( | |||
| @@ -1303,7 +1308,7 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||
| if (submitQty > BigDecimal.ZERO) { | |||
| // ✅ 修复:传入更新前的 onHandQty,让 createStockLedgerForPickDelta 使用它 | |||
| createStockLedgerForPickDelta(line.stockOutLineId, submitQty, onHandQtyBeforeUpdate) | |||
| createStockLedgerForPickDelta(line.stockOutLineId, submitQty, onHandQtyBeforeUpdate, lineTrace) | |||
| } | |||
| } else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) { | |||
| // ✅ 修复:即使没有 inventoryLotLineId,也应该获取 inventory.onHandQty | |||
| @@ -1313,8 +1318,8 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||
| } | |||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| println(" Warning: No inventoryLotLineId found, but creating stock ledger anyway for stockOutLineId ${line.stockOutLineId}") | |||
| createStockLedgerForPickDelta(line.stockOutLineId, submitQty, onHandQtyBeforeUpdate) | |||
| println("[$lineTrace] Warning: No inventoryLotLineId, still trying ledger creation") | |||
| createStockLedgerForPickDelta(line.stockOutLineId, submitQty, onHandQtyBeforeUpdate, lineTrace) | |||
| } | |||
| try { | |||
| val stockOutLine = stockOutLines[line.stockOutLineId] | |||
| @@ -1356,9 +1361,9 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||
| // 不中断主流程,只记录错误 | |||
| } | |||
| processedIds += line.stockOutLineId | |||
| println(" ✓ Line processed successfully") | |||
| println("[$lineTrace] Line processed successfully") | |||
| } catch (e: Exception) { | |||
| println(" ✗ Error processing line ${line.stockOutLineId}: ${e.message}") | |||
| println("[$lineTrace] Error processing line: ${e.message}") | |||
| e.printStackTrace() | |||
| errors += "stockOutLineId=${line.stockOutLineId}: ${e.message}" | |||
| } | |||
| @@ -1534,31 +1539,44 @@ affectedConsoCodes.forEach { consoCode -> | |||
| private fun createStockLedgerForPickDelta( | |||
| stockOutLineId: Long, | |||
| deltaQty: BigDecimal, | |||
| onHandQtyBeforeUpdate: Double? = null // ✅ 新增参数:更新前的 onHandQty | |||
| onHandQtyBeforeUpdate: Double? = null, // ✅ 新增参数:更新前的 onHandQty | |||
| traceTag: String? = null | |||
| ) { | |||
| if (deltaQty <= BigDecimal.ZERO) return | |||
| val tracePrefix = traceTag?.let { "[$it] " } ?: "[SOL=$stockOutLineId] " | |||
| if (deltaQty <= BigDecimal.ZERO) { | |||
| println("${tracePrefix}Skip ledger creation: deltaQty <= 0 (deltaQty=$deltaQty)") | |||
| return | |||
| } | |||
| val sol = stockOutLineRepository.findById(stockOutLineId).orElse(null) ?: return | |||
| val item = sol.item ?: return | |||
| val sol = stockOutLineRepository.findById(stockOutLineId).orElse(null) | |||
| if (sol == null) { | |||
| println("${tracePrefix}Skip ledger creation: stockOutLine not found") | |||
| return | |||
| } | |||
| val item = sol.item | |||
| if (item == null) { | |||
| println("${tracePrefix}Skip ledger creation: stockOutLine.item is null") | |||
| return | |||
| } | |||
| val inventory = inventoryRepository.findAllByItemIdAndDeletedIsFalse(item.id!!) | |||
| .firstOrNull() ?: return | |||
| // ✅ 修复:如果传入了 onHandQtyBeforeUpdate,使用它;否则回退到原来的逻辑 | |||
| val previousBalance = if (onHandQtyBeforeUpdate != null) { | |||
| // 使用更新前的 onHandQty 作为基础 | |||
| onHandQtyBeforeUpdate | |||
| } else { | |||
| // 回退逻辑:优先使用最新的 ledger balance,如果没有则使用 inventory.onHandQty | |||
| val latestLedger = stockLedgerRepository.findLatestByItemId(item.id!!).firstOrNull() | |||
| latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| .firstOrNull() | |||
| if (inventory == null) { | |||
| 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(" Creating stock ledger: previousBalance=$previousBalance, deltaQty=$deltaQty, newBalance=$newBalance") | |||
| println("${tracePrefix}Creating ledger: previousBalance=$previousBalance, deltaQty=$deltaQty, newBalance=$newBalance, inventoryId=${inventory.id}") | |||
| if (onHandQtyBeforeUpdate != null) { | |||
| println(" Using onHandQtyBeforeUpdate: $onHandQtyBeforeUpdate") | |||
| println("${tracePrefix}Using onHandQtyBeforeUpdate: $onHandQtyBeforeUpdate") | |||
| } | |||
| val ledger = StockLedger().apply { | |||
| @@ -1576,6 +1594,19 @@ affectedConsoCodes.forEach { consoCode -> | |||
| } | |||
| stockLedgerRepository.saveAndFlush(ledger) | |||
| println("${tracePrefix}Ledger created successfully for stockOutLineId=$stockOutLineId") | |||
| } | |||
| private fun resolvePreviousBalance( | |||
| itemId: Long, | |||
| inventory: Inventory, | |||
| onHandQtyBeforeUpdate: Double? = null | |||
| ): Double { | |||
| if (onHandQtyBeforeUpdate != null) { | |||
| return onHandQtyBeforeUpdate | |||
| } | |||
| val latestLedger = stockLedgerRepository.findLatestByItemId(itemId).firstOrNull() | |||
| return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| @Transactional(rollbackFor = [Exception::class]) | |||
| @@ -1769,7 +1800,7 @@ open fun batchScan(request: com.ffii.fpsms.modules.stock.web.model.BatchScanRequ | |||
| this.inventoryLotLine = inventoryLotLine | |||
| this.pickOrderLine = pickOrderLine | |||
| this.status = StockOutLineStatus.CHECKED.status | |||
| this.type = "Nor" | |||
| this.type = "NOR" | |||
| this.startTime = LocalDateTime.now() | |||
| } | |||
| @@ -1933,9 +1964,10 @@ fun applyStockOutLineDelta( | |||
| val item = savedSol.item ?: return savedSol | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return savedSol | |||
| // unified balance source: latest ledger first, fallback inventory.onHand | |||
| val latestLedger = stockLedgerRepository.findLatestByItemId(item.id!!).firstOrNull() | |||
| val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val previousBalance = resolvePreviousBalance( | |||
| itemId = item.id!!, | |||
| inventory = inventory | |||
| ) | |||
| val outQty = deltaQty.toDouble() | |||
| val newBalance = previousBalance - outQty | |||
| @@ -493,7 +493,7 @@ open class SuggestedPickLotService( | |||
| this.qty = 0.0 | |||
| this.status = StockOutLineStatus.PENDING.status | |||
| this.deleted = false | |||
| this.type = "Nor" | |||
| this.type = "NOR" | |||
| } | |||
| val savedStockOutLine = stockOutLIneRepository.save(stockOutLine) | |||
| @@ -543,7 +543,7 @@ open class SuggestedPickLotService( | |||
| this.inventoryLotLine = suggestedLotLine | |||
| this.pickOrderLine = updatedPickOrderLine | |||
| this.status = StockOutLineStatus.PENDING.status | |||
| this.type = "Nor" | |||
| this.type = "NOR" | |||
| } | |||
| val savedStockOutLine = stockOutLIneRepository.saveAndFlush(stockOutLine) | |||
| @@ -14,3 +14,16 @@ data class SaveEscalationLogResponse( | |||
| val status: String?, | |||
| val reason: String?, | |||
| ) | |||
| enum class LedgerFailFactor { | |||
| INVALID_DELTA_QTY, | |||
| STOCK_OUT_LINE_NOT_FOUND, | |||
| ITEM_NOT_FOUND, | |||
| INVENTORY_NOT_FOUND, | |||
| LEDGER_SAVE_EXCEPTION, | |||
| UNKNOWN | |||
| } | |||
| data class LedgerCreateResult( | |||
| val success: Boolean, | |||
| val failFactor: LedgerFailFactor? = null, | |||
| val message: String? = null | |||
| ) | |||
| @@ -0,0 +1,8 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset Enson:alter_stock_in_line_acceptedQtyM18 | |||
| ALTER TABLE `fpsmsdb`.`stock_in_line` | |||
| MODIFY COLUMN `acceptedQtyM18` DECIMAL(10,2); | |||