diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index 46a4f2c..b24f9b2 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -11,13 +11,14 @@ import java.math.RoundingMode import kotlin.jvm.optionals.getOrNull import org.slf4j.Logger import org.slf4j.LoggerFactory - +import com.ffii.fpsms.modules.master.entity.ItemsRepository @Service open class ItemUomService( - val itemsService: ItemsService, + val uomConversionService: UomConversionService, val itemUomRespository: ItemUomRespository, - val currencyService: CurrencyService + val currencyService: CurrencyService, + val itemsRepository: ItemsRepository ) { val logger = org.slf4j.LoggerFactory.getLogger(this::class.java) @@ -86,6 +87,16 @@ open class ItemUomService( return stockQty; } + /** Inverse of convertPurchaseQtyToStockQty: stock qty -> purchase qty (for PO-origin StockInLine display). */ + open fun convertStockQtyToPurchaseQty(itemId: Long, stockQty: BigDecimal): BigDecimal { + val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty + val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty + val one = BigDecimal.ONE + val baseQty = stockQty.multiply(stockUnit.ratioN ?: one).divide(stockUnit.ratioD ?: one, 2, RoundingMode.UP) + val purchaseQty = baseQty.multiply(purchaseUnit.ratioD ?: one).divide(purchaseUnit.ratioN ?: one, 2, RoundingMode.UP) + return purchaseQty + } + open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty; @@ -107,7 +118,8 @@ open class ItemUomService( // return itemUom //} - val item = request.itemId?.let { itemsService.find(it).getOrNull() } + val item = request.itemId?.let { itemsRepository.findById(it).getOrNull() } + ?: request.itemId?.let { itemsRepository.findByIdAndDeletedFalse(it) } // 取決於你 repo 有沒有這種方法 val uom = request.m18UomId?.let { uomConversionService.findByM18Id(it) } ?: request.uomId?.let { uomConversionService.find(it).getOrNull() } val currency = request.currencyId?.let { currencyService.findById(it) } @@ -218,13 +230,25 @@ open class ItemUomService( } } + // var sourceItemUom = itemUomRespository.findFirstByItemIdAndUomIdAndDeletedIsFalse(request.itemId, request.uomId) + + // 這裡先查 items、uom_conversion,準備錯誤訊息要用的 code + val item = itemsRepository.findById(request.itemId).getOrNull() + val itemCode = item?.code ?: request.itemId.toString() + + val uom = uomConversionService.findById(request.uomId) + val uomCode = uom?.code ?: request.uomId.toString() + // If still no source ItemUom found, throw error - sourceItemUom ?: throw IllegalArgumentException("Source ItemUom not found for itemId=${request.itemId}, uomId=${request.uomId}") - - // Find target ItemUom by itemId and targetUnit + sourceItemUom ?: throw IllegalArgumentException( + "Source ItemUom not found for items.code=$itemCode, uom_conversion.code=$uomCode" + ) + + // Find target ItemUom by itemId and targetUnit(這段可視需求決定要不要也查 code) val targetItemUom = findTargetItemUom(request.itemId, request.targetUnit) - ?: throw IllegalArgumentException("Target ItemUom not found for itemId=${request.itemId}, targetUnit=${request.targetUnit}") - + ?: throw IllegalArgumentException( + "Target ItemUom not found for items.code=$itemCode, targetUnit=${request.targetUnit}" + ) // Convert quantity using ratioN/ratioD via base unit val one = BigDecimal.ONE val sourceRatioN = sourceItemUom.ratioN ?: one diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt index 1fa451e..ac7bbd4 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt @@ -58,8 +58,10 @@ open class ItemQcFailReportService( COALESCE(qic.description, qi.description, qi.name, '') AS qcDefectCriteria, /* Lot Qty / Defect Qty:按 jrxml 字段类型输出 String */ - TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE( - CASE WHEN sil.purchaseOrderId IS NOT NULL AND iu_purchase.id IS NOT NULL AND iu.id IS NOT NULL + TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( COALESCE( + CASE WHEN sil.purchaseOrderLineId IS NOT NULL + THEN sil.acceptedQty + WHEN iu_purchase.id IS NOT NULL AND iu.id IS NOT NULL THEN sil.acceptedQty * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu.ratioN / NULLIF(iu.ratioD, 0)) ELSE sil.acceptedQty END, 0), 2))) AS lotQty, TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(qr.failQty, 0), 2))) AS defectQty, diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt index 0adccd5..2986ec6 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt @@ -120,17 +120,17 @@ in_agg AS ( SELECT ill.id AS inventoryLotLineId, SUM(CASE WHEN DATE(sil.receiptDate) < :fromDate THEN - CASE WHEN sil.purchaseOrderId IS NOT NULL - AND iu_purchase.id IS NOT NULL - AND iu_stock.id IS NOT NULL + CASE WHEN sil.purchaseOrderLineId IS NOT NULL + THEN COALESCE(sil.acceptedQty, 0) + WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0)) ELSE COALESCE(sil.acceptedQty, 0) END ELSE 0 END) AS inBefore, SUM(CASE WHEN DATE(sil.receiptDate) BETWEEN :fromDate AND :toDate THEN - CASE WHEN sil.purchaseOrderId IS NOT NULL - AND iu_purchase.id IS NOT NULL - AND iu_stock.id IS NOT NULL + CASE WHEN sil.purchaseOrderLineId IS NOT NULL + THEN COALESCE(sil.acceptedQty, 0) + WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0)) ELSE COALESCE(sil.acceptedQty, 0) END diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt index 60192cd..40671be 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt @@ -28,6 +28,8 @@ interface StockInLineInfo { val receivedQty: BigDecimal? val demandQty: BigDecimal? val acceptedQty: BigDecimal + @get:Value("#{target.purchaseOrderLine != null && target.item != null && target.acceptedQty != null ? @itemUomService.convertStockQtyToPurchaseQty(target.item.id, target.acceptedQty) : null}") + val purchaseAcceptedQty: BigDecimal? @get:Value("#{target.purchaseOrderLine?.qty}") val qty: BigDecimal? val price: BigDecimal? @@ -44,6 +46,10 @@ interface StockInLineInfo { val supplier: String? @get:Value("#{target.item?.itemUoms.^[salesUnit == true && deleted == false]?.uom}") //TODO review val uom: UomConversion? + @get:Value("#{target.purchaseOrderLine?.uom?.udfudesc}") + val purchaseUomDesc: String? + @get:Value("#{target.item?.itemUoms.^[stockUnit == true && deleted == false]?.uom?.udfudesc}") + val stockUomDesc: String? @get:Value("#{target.stockIn?.purchaseOrder?.code}") val poCode: String? @get:Value("#{target.jobOrder?.code}") @@ -61,6 +67,9 @@ interface StockInLineInfo { interface PutAwayLineForSil { val id: Long?; + /** Stock qty (inventory lot line inQty) */ + @get:Value("#{target.inQty}") + val stockQty: BigDecimal?; @get:Value("#{target.inQty " + "* ((target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD))" + "/ ((target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioN / target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioD))}") diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index c633849..15c3073 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -27,6 +27,7 @@ import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus import com.ffii.fpsms.modules.master.entity.ItemUomRespository import com.ffii.fpsms.modules.master.entity.WarehouseRepository +import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.master.service.PrinterService import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine @@ -87,6 +88,7 @@ open class StockInLineService( private val itemRepository: ItemsRepository, private val warehouseRepository: WarehouseRepository, private val itemUomRepository: ItemUomRespository, + private val itemUomService: ItemUomService, private val printerService: PrinterService, private val stockTakeLineRepository: StockTakeLineRepository, private val inventoryLotLineService: InventoryLotLineService, @@ -194,15 +196,21 @@ open class StockInLineService( this.item = item itemNo = item.code this.stockIn = stockIn - acceptedQty = request.acceptedQty + // PO-origin: store in stock qty; others: store as received + acceptedQty = if (pol != null && item.id != null) { + itemUomService.convertPurchaseQtyToStockQty(item.id!!, request.acceptedQty ?: BigDecimal.ZERO) + } else { + request.acceptedQty + } // Set demandQty based on source if (jo != null && jo?.bom != null) { // For job orders, demandQty comes from BOM's outputQty this.demandQty = jo?.bom?.outputQty - } else if (pol != null) { - // For purchase orders, demandQty comes from PurchaseOrderLine's qty - this.demandQty = pol.qty + } else if (pol != null && item.id != null) { + pol.qty?.let { polQty -> + this.demandQty = itemUomService.convertPurchaseQtyToStockQty(item.id!!, polQty) + } } dnNo = request.dnNo receiptDate = request.receiptDate?.atStartOfDay() ?: LocalDateTime.now() @@ -264,7 +272,10 @@ open class StockInLineService( itemId = request.itemId ) val purchaseItemUom = itemUomRepository.findByItemIdAndPurchaseUnitIsTrueAndDeletedIsFalse(request.itemId) - val convertedBaseQty = if (request.stockTakeLineId == null && stockItemUom != null && purchaseItemUom != null) { + // PO-origin: frontend sends qty in stock; non-PO: treat as purchase and convert to stock + val convertedBaseQty = if (stockInLine.purchaseOrderLine != null) { + line.qty + } else if (request.stockTakeLineId == null && stockItemUom != null && purchaseItemUom != null) { (line.qty) * (purchaseItemUom.ratioN!! / purchaseItemUom.ratioD!!) / (stockItemUom.ratioN!! / stockItemUom.ratioD!!) } else { (line.qty) @@ -631,14 +642,21 @@ open class StockInLineService( this.productLotNo = request.productLotNo ?: this.productLotNo this.dnNo = request.dnNo ?: this.dnNo // this.dnDate = request.dnDate?.atStartOfDay() ?: this.dnDate - this.acceptedQty = request.acceptQty ?: request.acceptedQty ?: this.acceptedQty + // QC and PutAway should never overwrite acceptedQty (received qty). + 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) + } else { + requestQty ?: this.acceptedQty + } + } // Set demandQty based on source if (this.jobOrder != null && this.jobOrder?.bom != null) { // For job orders, demandQty comes from BOM's outputQty this.demandQty = this.jobOrder?.bom?.outputQty ?: this.demandQty - } else if (this.purchaseOrderLine != null) { - // For purchase orders, demandQty comes from PurchaseOrderLine's qty - this.demandQty = this.purchaseOrderLine?.qty ?: this.demandQty + } else if (this.purchaseOrderLine != null && this.item?.id != null && this.purchaseOrderLine?.qty != null) { + this.demandQty = itemUomService.convertPurchaseQtyToStockQty(this.item!!.id!!, this.purchaseOrderLine!!.qty!!) } // Don't overwrite demandQty with acceptQty from QC form this.invoiceNo = request.invoiceNo @@ -655,29 +673,58 @@ open class StockInLineService( val savedInventoryLotLines = saveInventoryLotLineWhenStockIn(request = request, stockInLine = stockInLine) val inventoryLotLines = stockInLine.inventoryLot?.let { it.id?.let { _id -> inventoryLotLineRepository.findAllByInventoryLotId(_id) } } ?: listOf() - val purchaseItemUom = itemUomRepository.findByItemIdAndPurchaseUnitIsTrueAndDeletedIsFalse(request.itemId) - val stockItemUom = itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(request.itemId) - val ratio = if (request.stockTakeLineId == null && stockItemUom != null && purchaseItemUom != null) { - (purchaseItemUom.ratioN!! / purchaseItemUom.ratioD!!) / (stockItemUom.ratioN!! / stockItemUom.ratioD!!) + // ✅ 每次上架都寫一筆 stock_ledger(inQty = 本次上架的庫存數量) + val putAwayDeltaStockQty = (request.inventoryLotLines ?: listOf()).sumOf { line -> + val stockItemUom = itemUomRepository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse( + itemId = request.itemId + ) + val purchaseItemUom = itemUomRepository.findByItemIdAndPurchaseUnitIsTrueAndDeletedIsFalse(request.itemId) + + val convertedBaseQty = if (stockInLine.purchaseOrderLine != null) { + // PO-origin: qty is already stock qty + line.qty + } else if (request.stockTakeLineId == null && stockItemUom != null && purchaseItemUom != null) { + // Legacy: treat as purchase qty, convert to stock qty + (line.qty) * (purchaseItemUom.ratioN!! / purchaseItemUom.ratioD!!) / (stockItemUom.ratioN!! / stockItemUom.ratioD!!) + } else { + line.qty + } + convertedBaseQty + } + if (putAwayDeltaStockQty > BigDecimal.ZERO) { + createStockLedgerForStockIn(stockInLine, putAwayDeltaStockQty.toDouble()) + } + + val putAwayStockQty = inventoryLotLines.sumOf { it.inQty ?: BigDecimal.ZERO } + + // PO-origin: acceptedQty is already STOCK qty; Non-PO: keep legacy rule (acceptQty is purchase qty) + val requiredStockQty = if (stockInLine.purchaseOrderLine != null) { + stockInLine.acceptedQty ?: BigDecimal.ZERO } else { - BigDecimal.ONE + val purchaseItemUom = itemUomRepository.findByItemIdAndPurchaseUnitIsTrueAndDeletedIsFalse(request.itemId) + val stockItemUom = itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(request.itemId) + val ratio = if (request.stockTakeLineId == null && stockItemUom != null && purchaseItemUom != null) { + (purchaseItemUom.ratioN!! / purchaseItemUom.ratioD!!) / (stockItemUom.ratioN!! / stockItemUom.ratioD!!) + } else { + BigDecimal.ONE + } + (request.acceptQty ?: request.acceptedQty)?.times(ratio) ?: BigDecimal.ZERO } - if (inventoryLotLines.sumOf { it.inQty ?: BigDecimal.ZERO } >= request.acceptQty?.times(ratio)) { + if (putAwayStockQty >= requiredStockQty) { stockInLine.apply { val isWipJobOrder = stockInLine.jobOrder?.bom?.description == "WIP" this.status = if (isWipJobOrder) { StockInLineStatus.COMPLETE.status } else { // For non-WIP, use original logic - if (request.acceptQty?.compareTo(request.acceptedQty) == 0) + if (putAwayStockQty.compareTo(requiredStockQty) == 0) StockInLineStatus.COMPLETE.status else StockInLineStatus.PARTIALLY_COMPLETE.status } // this.inventoryLotLine = savedInventoryLotLine } - createStockLedgerForStockIn(stockInLine) // Update JO Status if (stockInLine.jobOrder != null) { //TODO Improve val jo = stockInLine.jobOrder @@ -789,7 +836,10 @@ open class StockInLineService( info.itemId ) val purchaseItemUom = itemUomRepository.findByItemIdAndPurchaseUnitIsTrueAndDeletedIsFalse(info.itemId) - val acceptedQty = if (stockItemUom != null && purchaseItemUom != null) { + // PO-origin: acceptedQty is already stock qty; non-PO: convert purchase -> stock for display + val acceptedQty = if (info.purchaseOrderLineId != null) { + info.acceptedQty + } else if (stockItemUom != null && purchaseItemUom != null) { (info.acceptedQty) * (purchaseItemUom.ratioN!! / purchaseItemUom.ratioD!!) / (stockItemUom.ratioN!! / stockItemUom.ratioD!!) } else { (info.acceptedQty) @@ -936,10 +986,9 @@ open class StockInLineService( } @Transactional - private fun createStockLedgerForStockIn(stockInLine: StockInLine) { + private fun createStockLedgerForStockIn(stockInLine: StockInLine, inQty: Double) { val item = stockInLine.item ?: return val inventory = inventoryRepository.findFirstByItemIdAndDeletedIsFalseOrderByIdAsc(item.id!!) ?: return - val inQty = stockInLine.acceptedQty?.toDouble() ?: 0.0 // ✅ 修复:查询最新的 stock_ledger 记录,基于前一笔 balance 计算 val latestLedger = stockLedgerRepository.findLatestByItemId(item.id!!).firstOrNull() @@ -1047,7 +1096,7 @@ open class StockInLineService( saveAndFlush(savedStockInLine) // Step 5: Create Stock Ledger entry - createStockLedgerForStockIn(savedStockInLine) + createStockLedgerForStockIn(savedStockInLine, request.acceptedQty.toDouble()) return savedStockInLine } @@ -1095,7 +1144,7 @@ open class StockInLineService( val savedStockInLine = saveAndFlush(stockInLine) // Step 3: Create Stock Ledger entry - createStockLedgerForStockIn(savedStockInLine) + createStockLedgerForStockIn(savedStockInLine, request.acceptedQty.toDouble()) return savedStockInLine }