| @@ -842,7 +842,7 @@ open class DeliveryOrderService( | |||||
| this.item = pickOrderLine.item | this.item = pickOrderLine.item | ||||
| this.status = StockOutLineStatus.PENDING.status | this.status = StockOutLineStatus.PENDING.status | ||||
| this.qty = 0.0 | this.qty = 0.0 | ||||
| this.type = "Nor" | |||||
| this.type = "NOR" | |||||
| } | } | ||||
| stockOutLineRepository.save(line) | stockOutLineRepository.save(line) | ||||
| } | } | ||||
| @@ -1524,7 +1524,7 @@ open class DeliveryOrderService( | |||||
| StockOutLineStatus.PENDING.status // 有正常库存批次时使用 PENDING | StockOutLineStatus.PENDING.status // 有正常库存批次时使用 PENDING | ||||
| } | } | ||||
| this.qty = 0.0 | this.qty = 0.0 | ||||
| this.type = "Nor" | |||||
| this.type = "NOR" | |||||
| } | } | ||||
| stockOutLineRepository.save(line) | stockOutLineRepository.save(line) | ||||
| } | } | ||||
| @@ -159,7 +159,12 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> { | |||||
| SELECT jo FROM JobOrder jo | SELECT jo FROM JobOrder jo | ||||
| WHERE jo.deleted = false | WHERE jo.deleted = false | ||||
| AND (:code IS NULL OR jo.code LIKE CONCAT('%', :code, '%')) | 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 (:planStartFrom IS NULL OR jo.planStart >= :planStartFrom) | ||||
| AND (:planStartTo IS NULL OR jo.planStart <= :planStartTo) | AND (:planStartTo IS NULL OR jo.planStart <= :planStartTo) | ||||
| AND ( | AND ( | ||||
| @@ -673,7 +673,7 @@ open class JobOrderService( | |||||
| this.item = pickOrderLine.item | this.item = pickOrderLine.item | ||||
| this.status = StockOutLineStatus.PENDING.status | this.status = StockOutLineStatus.PENDING.status | ||||
| this.qty = 0.0 | this.qty = 0.0 | ||||
| this.type = "Nor" | |||||
| this.type = "NOR" | |||||
| } | } | ||||
| stockOutLineRepository.save(line) | stockOutLineRepository.save(line) | ||||
| } | } | ||||
| @@ -203,6 +203,48 @@ open class ItemUomService( | |||||
| return stockQty.setScale(0, RoundingMode.DOWN) | 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 | // See if need to update the response | ||||
| open fun saveItemUom(request: ItemUomRequest): ItemUom { | open fun saveItemUom(request: ItemUomRequest): ItemUom { | ||||
| val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } | val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } | ||||
| @@ -1150,7 +1150,7 @@ open class PickOrderService( | |||||
| this.status = | this.status = | ||||
| com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus.PENDING.status | com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus.PENDING.status | ||||
| this.qty = 0.0 | this.qty = 0.0 | ||||
| this.type = "Nor" | |||||
| this.type = "NOR" | |||||
| } | } | ||||
| stockOutLIneRepository.save(line) | stockOutLIneRepository.save(line) | ||||
| precreated++ | precreated++ | ||||
| @@ -2110,7 +2110,7 @@ open class PickOrderService( | |||||
| this.status = | this.status = | ||||
| com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus.PENDING.status | com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus.PENDING.status | ||||
| this.qty = 0.0 | this.qty = 0.0 | ||||
| this.type = "Nor" | |||||
| this.type = "NOR" | |||||
| } | } | ||||
| stockOutLIneRepository.save(line) | stockOutLIneRepository.save(line) | ||||
| precreated++ | precreated++ | ||||
| @@ -269,7 +269,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| val qtyM18 = thisPol.qtyM18 | val qtyM18 = thisPol.qtyM18 | ||||
| val uomM18Id = thisPol.uomM18?.id | val uomM18Id = thisPol.uomM18?.id | ||||
| val stockQtyFromM18 = if (itemId != null && qtyM18 != null && uomM18Id != null) { | val stockQtyFromM18 = if (itemId != null && qtyM18 != null && uomM18Id != null) { | ||||
| itemUomService.convertQtyToStockQtyRoundDown(itemId, uomM18Id, qtyM18) | |||||
| itemUomService.convertQtyToStockQtyPrecise(itemId, uomM18Id, qtyM18) | |||||
| } else { | } else { | ||||
| BigDecimal.ZERO | BigDecimal.ZERO | ||||
| } | } | ||||
| @@ -279,7 +279,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| stockUomDesc = iu?.uom?.udfudesc, | stockUomDesc = iu?.uom?.udfudesc, | ||||
| stockQty = if (stockQtyFromM18 > BigDecimal.ZERO) stockQtyFromM18 else { | stockQty = if (stockQtyFromM18 > BigDecimal.ZERO) stockQtyFromM18 else { | ||||
| // fallback to legacy behavior when M18 fields are missing | // 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, | stockRatioN = iu?.ratioN, | ||||
| stockRatioD = iu?.ratioD, | stockRatioD = iu?.ratioD, | ||||
| @@ -63,7 +63,7 @@ open class StockInLine : BaseEntity<Long>() { | |||||
| open var acceptedQty: BigDecimal? = null | open var acceptedQty: BigDecimal? = null | ||||
| @Column(name = "acceptedQtyM18") | @Column(name = "acceptedQtyM18") | ||||
| open var acceptedQtyM18: Int? = null | |||||
| open var acceptedQtyM18: BigDecimal? = null | |||||
| @Column(name = "price", precision = 14, scale = 2) | @Column(name = "price", precision = 14, scale = 2) | ||||
| open var price: BigDecimal? = null | open var price: BigDecimal? = null | ||||
| @@ -134,6 +134,6 @@ open class StockInLine : BaseEntity<Long>() { | |||||
| */ | */ | ||||
| fun getReceivedQtyM18ForPol(): BigDecimal? = | fun getReceivedQtyM18ForPol(): BigDecimal? = | ||||
| purchaseOrderLine?.stockInLines?.sumOf { sil -> | 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 receivedQty: BigDecimal? | ||||
| val demandQty: BigDecimal? | val demandQty: BigDecimal? | ||||
| val acceptedQty: BigDecimal | val acceptedQty: BigDecimal | ||||
| @get:Value("#{target.acceptedQtyM18 != null ? new java.math.BigDecimal(target.acceptedQtyM18) : null}") | |||||
| @get:Value("#{target.acceptedQtyM18}") | |||||
| val purchaseAcceptedQty: BigDecimal? | val purchaseAcceptedQty: BigDecimal? | ||||
| @get:Value("#{target.purchaseOrderLine?.qtyM18}") | @get:Value("#{target.purchaseOrderLine?.qtyM18}") | ||||
| val qty: BigDecimal? | 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 | itemNo = item.code | ||||
| this.stockIn = stockIn | this.stockIn = stockIn | ||||
| // PO-origin: | // 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) { | if (pol != null && item.id != null) { | ||||
| acceptedQtyM18 = request.acceptedQty.toInt() | |||||
| acceptedQtyM18 = request.acceptedQty | |||||
| val m18UomId = pol.uomM18?.id | val m18UomId = pol.uomM18?.id | ||||
| acceptedQty = if (m18UomId != null) { | acceptedQty = if (m18UomId != null) { | ||||
| itemUomService.convertQtyToStockQtyRoundDown( | |||||
| itemUomService.convertQtyToStockQtyPrecise( | |||||
| item.id!!, | item.id!!, | ||||
| m18UomId, | m18UomId, | ||||
| request.acceptedQty | request.acceptedQty | ||||
| ) | ) | ||||
| } else { | } else { | ||||
| // fallback to legacy: treat request.acceptedQty as purchase unit qty | // fallback to legacy: treat request.acceptedQty as purchase unit qty | ||||
| itemUomService.convertPurchaseQtyToStockQtyRoundDown( | |||||
| itemUomService.convertPurchaseQtyToStockQtyPrecise( | |||||
| item.id!!, | item.id!!, | ||||
| request.acceptedQty | request.acceptedQty | ||||
| ) | ) | ||||
| @@ -285,7 +285,7 @@ open class StockInLineService( | |||||
| val m18UomId = pol.uomM18?.id | val m18UomId = pol.uomM18?.id | ||||
| val qtyM18 = pol.qtyM18 | val qtyM18 = pol.qtyM18 | ||||
| this.demandQty = if (m18UomId != null && qtyM18 != null) { | this.demandQty = if (m18UomId != null && qtyM18 != null) { | ||||
| itemUomService.convertQtyToStockQtyRoundDown( | |||||
| itemUomService.convertQtyToStockQtyPrecise( | |||||
| item.id!!, | item.id!!, | ||||
| m18UomId, | m18UomId, | ||||
| qtyM18 | qtyM18 | ||||
| @@ -293,14 +293,14 @@ open class StockInLineService( | |||||
| } else { | } else { | ||||
| // fallback to legacy: treat pol.qty as purchase unit qty | // fallback to legacy: treat pol.qty as purchase unit qty | ||||
| pol.qty?.let { polQty -> | pol.qty?.let { polQty -> | ||||
| itemUomService.convertPurchaseQtyToStockQty(item.id!!, polQty) | |||||
| itemUomService.convertPurchaseQtyToStockQtyPrecise(item.id!!, polQty) | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| dnNo = request.dnNo | dnNo = request.dnNo | ||||
| receiptDate = request.receiptDate?.atStartOfDay() ?: LocalDateTime.now() | receiptDate = request.receiptDate?.atStartOfDay() ?: LocalDateTime.now() | ||||
| productLotNo = request.productLotNo | productLotNo = request.productLotNo | ||||
| this.type = "Nor" | |||||
| this.type = "NOR" | |||||
| status = StockInLineStatus.PENDING.status | status = StockInLineStatus.PENDING.status | ||||
| } | } | ||||
| if (jo != null) { | if (jo != null) { | ||||
| @@ -598,7 +598,7 @@ open class StockInLineService( | |||||
| val pol = sil.purchaseOrderLine!! | val pol = sil.purchaseOrderLine!! | ||||
| // For PO-origin GRN, M18 ant qty must use the M18 UOM and received M18 qty. | // For PO-origin GRN, M18 ant qty must use the M18 UOM and received M18 qty. | ||||
| val totalQtyM18 = silList.sumOf { | 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 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 | 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) { | if (request.qcAccept != true && request.status != StockInLineStatus.RECEIVED.status) { | ||||
| val requestQty = request.acceptQty ?: request.acceptedQty | val requestQty = request.acceptQty ?: request.acceptedQty | ||||
| this.acceptedQty = if (this.purchaseOrderLine != null && this.item?.id != null && requestQty != null) { | 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 { | } else { | ||||
| requestQty ?: this.acceptedQty | requestQty ?: this.acceptedQty | ||||
| } | } | ||||
| @@ -804,7 +804,7 @@ open class StockInLineService( | |||||
| val requestQty = request.acceptQty ?: request.acceptedQty | val requestQty = request.acceptQty ?: request.acceptedQty | ||||
| if (requestQty != null) { | if (requestQty != null) { | ||||
| this.acceptedQty = if (this.purchaseOrderLine != null && this.item?.id != null) { | this.acceptedQty = if (this.purchaseOrderLine != null && this.item?.id != null) { | ||||
| itemUomService.convertPurchaseQtyToStockQty(this.item!!.id!!, requestQty) | |||||
| itemUomService.convertPurchaseQtyToStockQtyPrecise(this.item!!.id!!, requestQty) | |||||
| } else { | } else { | ||||
| requestQty | requestQty | ||||
| } | } | ||||
| @@ -820,10 +820,10 @@ open class StockInLineService( | |||||
| val m18UomId = pol.uomM18?.id | val m18UomId = pol.uomM18?.id | ||||
| val qtyM18 = pol.qtyM18 | val qtyM18 = pol.qtyM18 | ||||
| this.demandQty = if (m18UomId != null && qtyM18 != null) { | this.demandQty = if (m18UomId != null && qtyM18 != null) { | ||||
| itemUomService.convertQtyToStockQtyRoundDown(itemId, m18UomId, qtyM18) | |||||
| itemUomService.convertQtyToStockQtyPrecise(itemId, m18UomId, qtyM18) | |||||
| } else if (pol.qty != null) { | } else if (pol.qty != null) { | ||||
| // fallback to legacy fields when M18 fields are missing | // fallback to legacy fields when M18 fields are missing | ||||
| itemUomService.convertPurchaseQtyToStockQty(itemId, pol.qty!!) | |||||
| itemUomService.convertPurchaseQtyToStockQtyPrecise(itemId, pol.qty!!) | |||||
| } else { | } else { | ||||
| this.demandQty | this.demandQty | ||||
| } | } | ||||
| @@ -45,6 +45,7 @@ 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 java.util.UUID | |||||
| @Service | @Service | ||||
| open class StockOutLineService( | open class StockOutLineService( | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| @@ -184,7 +185,7 @@ val existingStockOutLine = stockOutLineRepository.findByPickOrderLineIdAndInvent | |||||
| this.inventoryLotLine = inventoryLotLine | this.inventoryLotLine = inventoryLotLine | ||||
| this.pickOrderLine = pickOrderLine | this.pickOrderLine = pickOrderLine | ||||
| this.status = StockOutLineStatus.PENDING.status | this.status = StockOutLineStatus.PENDING.status | ||||
| this.type = "Nor" | |||||
| this.type = "NOR" | |||||
| } | } | ||||
| val savedStockOutLine = saveAndFlush(stockOutLine) | val savedStockOutLine = saveAndFlush(stockOutLine) | ||||
| createStockLedgerForStockOut(savedStockOutLine) | createStockLedgerForStockOut(savedStockOutLine) | ||||
| @@ -283,7 +284,7 @@ open fun createWithoutConso(request: CreateStockOutLineWithoutConsoRequest): Mes | |||||
| this.inventoryLotLine = inventoryLotLine | this.inventoryLotLine = inventoryLotLine | ||||
| this.pickOrderLine = updatedPickOrderLine | this.pickOrderLine = updatedPickOrderLine | ||||
| this.status = StockOutLineStatus.PENDING.status | this.status = StockOutLineStatus.PENDING.status | ||||
| this.type = "Nor" | |||||
| this.type = "NOR" | |||||
| } | } | ||||
| println("Created stockOutLine with qty: ${request.qty}") | println("Created stockOutLine with qty: ${request.qty}") | ||||
| @@ -1167,7 +1168,9 @@ open fun updateStockOutLineStatusByQRCodeAndLotNo(request: UpdateStockOutLineSta | |||||
| @Transactional(rollbackFor = [Exception::class]) | @Transactional(rollbackFor = [Exception::class]) | ||||
| open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | ||||
| val startTime = System.currentTimeMillis() | val startTime = System.currentTimeMillis() | ||||
| val traceId = "BATCH-${UUID.randomUUID().toString().substring(0, 8)}" | |||||
| println("=== BATCH SUBMIT START ===") | println("=== BATCH SUBMIT START ===") | ||||
| println("[$traceId] Batch submit request received") | |||||
| println("Start time: ${java.time.LocalDateTime.now()}") | println("Start time: ${java.time.LocalDateTime.now()}") | ||||
| println("Request lines count: ${request.lines.size}") | println("Request lines count: ${request.lines.size}") | ||||
| @@ -1208,8 +1211,9 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| // 3) Process each request line | // 3) Process each request line | ||||
| request.lines.forEach { line: QrPickSubmitLineRequest -> | request.lines.forEach { line: QrPickSubmitLineRequest -> | ||||
| val lineTrace = "$traceId|SOL=${line.stockOutLineId}" | |||||
| try { | try { | ||||
| println("Processing line: stockOutLineId=${line.stockOutLineId}, noLot=${line.noLot}") | |||||
| println("[$lineTrace] Processing line, noLot=${line.noLot}") | |||||
| if (line.noLot) { | if (line.noLot) { | ||||
| // noLot branch | // noLot branch | ||||
| @@ -1219,7 +1223,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| qty = 0.0 | qty = 0.0 | ||||
| )) | )) | ||||
| processedIds += line.stockOutLineId | processedIds += line.stockOutLineId | ||||
| println(" ✓ noLot item processed") | |||||
| println("[$lineTrace] noLot processed (status->completed, qty=0)") | |||||
| return@forEach | return@forEach | ||||
| } | } | ||||
| @@ -1228,7 +1232,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| ?: throw IllegalStateException("StockOutLine ${line.stockOutLineId} not found") | ?: throw IllegalStateException("StockOutLine ${line.stockOutLineId} not found") | ||||
| val currentStatus = stockOutLine.status?.trim()?.lowercase() | val currentStatus = stockOutLine.status?.trim()?.lowercase() | ||||
| if (currentStatus == "completed" || currentStatus == "complete") { | if (currentStatus == "completed" || currentStatus == "complete") { | ||||
| println(" Skipping already completed stockOutLineId=${line.stockOutLineId}") | |||||
| println("[$lineTrace] Skip because current status is already completed") | |||||
| return@forEach | return@forEach | ||||
| } | } | ||||
| @@ -1236,19 +1240,19 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| val targetActual = line.actualPickQty ?: BigDecimal.ZERO | val targetActual = line.actualPickQty ?: BigDecimal.ZERO | ||||
| val required = line.requiredQty ?: 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 | val submitQty = targetActual - currentActual | ||||
| println(" Submit qty (increment): $submitQty") | |||||
| println("[$lineTrace] submitQty(increment)=$submitQty") | |||||
| // 使用前端发送的状态,否则根据数量自动判断 | // 使用前端发送的状态,否则根据数量自动判断 | ||||
| val newStatus = line.stockOutLineStatus | val newStatus = line.stockOutLineStatus | ||||
| ?: if (targetActual >= required) "completed" else "partially_completed" | ?: if (targetActual >= required) "completed" else "partially_completed" | ||||
| if (submitQty <= BigDecimal.ZERO) { | 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( | updateStatus( | ||||
| UpdateStockOutLineStatusRequest( | UpdateStockOutLineStatusRequest( | ||||
| @@ -1274,6 +1278,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| skipInventoryWrite = true | skipInventoryWrite = true | ||||
| ) | ) | ||||
| ) | ) | ||||
| println("[$lineTrace] stock_out_line qty/status updated with delta=$submitQty (inventory+ledger deferred)") | |||||
| // Inventory updates - 修复:使用增量数量 | // Inventory updates - 修复:使用增量数量 | ||||
| // ✅ 修复:如果 inventoryLotLineId 为 null,从 stock_out_line 中获取 | // ✅ 修复:如果 inventoryLotLineId 为 null,从 stock_out_line 中获取 | ||||
| @@ -1282,7 +1287,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| // 在 newBatchSubmit 方法中,修改这部分代码(大约在 1169-1185 行) | // 在 newBatchSubmit 方法中,修改这部分代码(大约在 1169-1185 行) | ||||
| if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | 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 | // ✅ 修复:在更新 inventory_lot_line 之前获取 inventory 的当前 onHandQty | ||||
| val item = stockOutLine.item | val item = stockOutLine.item | ||||
| @@ -1291,7 +1296,7 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||||
| } | } | ||||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| println(" Inventory before update: onHandQty=$onHandQtyBeforeUpdate") | |||||
| println("[$lineTrace] Inventory before update: onHandQty=$onHandQtyBeforeUpdate") | |||||
| inventoryLotLineService.updateInventoryLotLineQuantities( | inventoryLotLineService.updateInventoryLotLineQuantities( | ||||
| UpdateInventoryLotLineQuantitiesRequest( | UpdateInventoryLotLineQuantitiesRequest( | ||||
| @@ -1303,7 +1308,7 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||||
| if (submitQty > BigDecimal.ZERO) { | if (submitQty > BigDecimal.ZERO) { | ||||
| // ✅ 修复:传入更新前的 onHandQty,让 createStockLedgerForPickDelta 使用它 | // ✅ 修复:传入更新前的 onHandQty,让 createStockLedgerForPickDelta 使用它 | ||||
| createStockLedgerForPickDelta(line.stockOutLineId, submitQty, onHandQtyBeforeUpdate) | |||||
| createStockLedgerForPickDelta(line.stockOutLineId, submitQty, onHandQtyBeforeUpdate, lineTrace) | |||||
| } | } | ||||
| } else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) { | } else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) { | ||||
| // ✅ 修复:即使没有 inventoryLotLineId,也应该获取 inventory.onHandQty | // ✅ 修复:即使没有 inventoryLotLineId,也应该获取 inventory.onHandQty | ||||
| @@ -1313,8 +1318,8 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||||
| } | } | ||||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | 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 { | try { | ||||
| val stockOutLine = stockOutLines[line.stockOutLineId] | val stockOutLine = stockOutLines[line.stockOutLineId] | ||||
| @@ -1356,9 +1361,9 @@ if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId != null) { | |||||
| // 不中断主流程,只记录错误 | // 不中断主流程,只记录错误 | ||||
| } | } | ||||
| processedIds += line.stockOutLineId | processedIds += line.stockOutLineId | ||||
| println(" ✓ Line processed successfully") | |||||
| println("[$lineTrace] Line processed successfully") | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| println(" ✗ Error processing line ${line.stockOutLineId}: ${e.message}") | |||||
| println("[$lineTrace] Error processing line: ${e.message}") | |||||
| e.printStackTrace() | e.printStackTrace() | ||||
| errors += "stockOutLineId=${line.stockOutLineId}: ${e.message}" | errors += "stockOutLineId=${line.stockOutLineId}: ${e.message}" | ||||
| } | } | ||||
| @@ -1534,31 +1539,44 @@ affectedConsoCodes.forEach { consoCode -> | |||||
| private fun createStockLedgerForPickDelta( | private fun createStockLedgerForPickDelta( | ||||
| stockOutLineId: Long, | stockOutLineId: Long, | ||||
| deltaQty: BigDecimal, | 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!!) | 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() | 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) { | if (onHandQtyBeforeUpdate != null) { | ||||
| println(" Using onHandQtyBeforeUpdate: $onHandQtyBeforeUpdate") | |||||
| println("${tracePrefix}Using onHandQtyBeforeUpdate: $onHandQtyBeforeUpdate") | |||||
| } | } | ||||
| val ledger = StockLedger().apply { | val ledger = StockLedger().apply { | ||||
| @@ -1576,6 +1594,19 @@ affectedConsoCodes.forEach { consoCode -> | |||||
| } | } | ||||
| stockLedgerRepository.saveAndFlush(ledger) | 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]) | @Transactional(rollbackFor = [Exception::class]) | ||||
| @@ -1769,7 +1800,7 @@ open fun batchScan(request: com.ffii.fpsms.modules.stock.web.model.BatchScanRequ | |||||
| this.inventoryLotLine = inventoryLotLine | this.inventoryLotLine = inventoryLotLine | ||||
| this.pickOrderLine = pickOrderLine | this.pickOrderLine = pickOrderLine | ||||
| this.status = StockOutLineStatus.CHECKED.status | this.status = StockOutLineStatus.CHECKED.status | ||||
| this.type = "Nor" | |||||
| this.type = "NOR" | |||||
| this.startTime = LocalDateTime.now() | this.startTime = LocalDateTime.now() | ||||
| } | } | ||||
| @@ -1933,9 +1964,10 @@ fun applyStockOutLineDelta( | |||||
| val item = savedSol.item ?: return savedSol | val item = savedSol.item ?: return savedSol | ||||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: 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 outQty = deltaQty.toDouble() | ||||
| val newBalance = previousBalance - outQty | val newBalance = previousBalance - outQty | ||||
| @@ -493,7 +493,7 @@ open class SuggestedPickLotService( | |||||
| this.qty = 0.0 | this.qty = 0.0 | ||||
| this.status = StockOutLineStatus.PENDING.status | this.status = StockOutLineStatus.PENDING.status | ||||
| this.deleted = false | this.deleted = false | ||||
| this.type = "Nor" | |||||
| this.type = "NOR" | |||||
| } | } | ||||
| val savedStockOutLine = stockOutLIneRepository.save(stockOutLine) | val savedStockOutLine = stockOutLIneRepository.save(stockOutLine) | ||||
| @@ -543,7 +543,7 @@ open class SuggestedPickLotService( | |||||
| this.inventoryLotLine = suggestedLotLine | this.inventoryLotLine = suggestedLotLine | ||||
| this.pickOrderLine = updatedPickOrderLine | this.pickOrderLine = updatedPickOrderLine | ||||
| this.status = StockOutLineStatus.PENDING.status | this.status = StockOutLineStatus.PENDING.status | ||||
| this.type = "Nor" | |||||
| this.type = "NOR" | |||||
| } | } | ||||
| val savedStockOutLine = stockOutLIneRepository.saveAndFlush(stockOutLine) | val savedStockOutLine = stockOutLIneRepository.saveAndFlush(stockOutLine) | ||||
| @@ -14,3 +14,16 @@ data class SaveEscalationLogResponse( | |||||
| val status: String?, | val status: String?, | ||||
| val reason: 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); | |||||