diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/projections/JobOrderInfo.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/projections/JobOrderInfo.kt index 4e7a77cb..3bbcf154 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/projections/JobOrderInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/projections/JobOrderInfo.kt @@ -22,15 +22,15 @@ interface JobOrderInfo { val item: JobOrderItemInfo; // TODO pack below as StockInLineInfo - @get:Value("#{target.stockInLines?.size() > 0 ? target.stockInLines[0].id : null}") + @get:Value("#{(target.stockInLines.?[type == null or type != 'BYPRODUCT']).size() > 0 ? target.stockInLines.?[type == null or type != 'BYPRODUCT'][0].id : null}") val stockInLineId: Long?; - @get:Value("#{target.stockInLines?.size() > 0 ? target.stockInLines[0].status : null}") + @get:Value("#{(target.stockInLines.?[type == null or type != 'BYPRODUCT']).size() > 0 ? target.stockInLines.?[type == null or type != 'BYPRODUCT'][0].status : null}") val stockInLineStatus: String?; - @get:Value("#{target.stockInLines?.size() > 0 ? target.stockInLines[0].escalationLog.^[status.value == 'pending']?.handler?.id : null}") + @get:Value("#{(target.stockInLines.?[type == null or type != 'BYPRODUCT']).size() > 0 ? target.stockInLines.?[type == null or type != 'BYPRODUCT'][0].escalationLog.^[status.value == 'pending']?.handler?.id : null}") val silHandlerId: Long?; - @get:Value("#{target.stockInLines?.size() > 0 ? target.stockInLines[0].lotNo : null}") + @get:Value("#{(target.stockInLines.?[type == null or type != 'BYPRODUCT']).size() > 0 ? target.stockInLines.?[type == null or type != 'BYPRODUCT'][0].lotNo : null}") val lotNo: String? val planStart: LocalDateTime?; // @get:Value("#{target.bom.item.itemUoms.^[salesUnit == true && deleted == false]?.uom}") diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt index f5172056..f78a1795 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt @@ -48,6 +48,7 @@ import com.ffii.fpsms.modules.jobOrder.web.model.LotDetailResponse import com.ffii.fpsms.modules.jobOrder.web.model.StockOutLineDetailResponse import com.ffii.fpsms.modules.stock.entity.enum.StockInLineStatus +import com.ffii.fpsms.modules.stock.entity.StockInLine import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import java.time.LocalDate import com.ffii.fpsms.modules.jobOrder.web.model.MaterialPickStatusItem @@ -1336,7 +1337,7 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List, jobOrder: JobOrder?, ): OnPackBmpExportItem? { - val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(order.jobOrderId) + val stockInLine = stockInLineRepository.findFirstOutputByJobOrderId(order.jobOrderId) ?: return null val itemId = stockInLine.item?.id ?: return null val stockInLineId = stockInLine.id ?: return null @@ -1021,7 +1021,7 @@ class PlasticBagPrinterService( .groupBy { it.itemCode.trim().lowercase() } .mapNotNull { (codeLower, orders) -> val order = orders.firstOrNull() ?: return@mapNotNull null - val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(order.jobOrderId) + val stockInLine = stockInLineRepository.findFirstOutputByJobOrderId(order.jobOrderId) ?: return@mapNotNull null val itemId = stockInLine.item?.id ?: return@mapNotNull null val stockInLineId = stockInLine.id ?: return@mapNotNull null diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/BomProcess.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/BomProcess.kt index 3b47146c..26bbbdb8 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/BomProcess.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/BomProcess.kt @@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonManagedReference import com.ffii.core.entity.BaseEntity import jakarta.persistence.* import jakarta.validation.constraints.NotNull +import java.math.BigDecimal @Table(name = "bom_process") @Entity @@ -47,6 +48,16 @@ open class BomProcess : BaseEntity() { @JoinColumn(name = "bomId", nullable = false) open var bom: Bom? = null + /** Excel 副產品名稱. First 6 characters are the item code. Blank means this step has no byproduct. */ + @Column(name = "byProduct") + open var byProduct: String? = null + + @Column(name = "byProductUom") + open var byProductUom: String? = null + + @Column(name = "byProductQty", precision = 16, scale = 2) + open var byProductQty: BigDecimal? = null + @OneToMany(mappedBy = "bomProcess", cascade = [CascadeType.ALL], orphanRemoval = true) @JsonManagedReference("bomProcess-material") @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index c0da6bd5..d9b1c0d6 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt @@ -1001,6 +1001,9 @@ open class BomService( this.prepTimeInMinute = req.prepTimeInMinute this.postProdTimeInMinute = req.postProdTimeInMinute this.bom = req.bom + this.byProduct = req.byProductName?.trim()?.takeIf { it.isNotEmpty() } + this.byProductUom = req.byProductUom?.trim()?.takeIf { it.isNotEmpty() } + this.byProductQty = req.byProductQty } return bomProcessRepository.saveAndFlush(bomProcess) } @@ -1372,7 +1375,8 @@ open class BomService( var startRowIndex = 30 var endRowIndex = 70 var startColumnIndex = 0 - val endColumnIndex = 11 + // Column M (index 12) is 生產後轉換時間. K is the byproduct unit and L is prep time. + val endColumnIndex = 12 while (startRowIndex < endRowIndex) { val tempRow = sheet.getRow(startRowIndex) ?: throw Exception() val tempCell = tempRow.getCell(startColumnIndex) @@ -1476,35 +1480,27 @@ open class BomService( bomProcessRequest.byProductUnit = byProductUnit } */ - 9 -> { //scrapRate - // val scrapRate = tempCell.stringCellValue.trim() - // bomProcessRequest.scrapRate = scrapRate - } - 10 -> { //prepTimeInMinute - when (tempCell.cellType) { - CellType.NUMERIC -> { - bomProcessRequest.prepTimeInMinute = tempCell.numericCellValue.toInt() - } - CellType.STRING -> { - val prepTimeInMinuteString = tempCell.stringCellValue.trim() - bomProcessRequest.prepTimeInMinute = extractDurationStringToMinutes(prepTimeInMinuteString) - } - - else -> {} + 8 -> { // byproduct name; item code is the first 6 characters + val fullName = excelStringCell(tempCell) + if (fullName.length >= 6) { + bomProcessRequest.byProductName = fullName } } - 11 -> { //postProdTimeInMinute - when (tempCell.cellType) { - CellType.NUMERIC -> { - bomProcessRequest.postProdTimeInMinute = tempCell.numericCellValue.toInt() - } - CellType.STRING -> { - val postProdTimeInMinuteString = tempCell.stringCellValue.trim() - bomProcessRequest.postProdTimeInMinute = extractDurationStringToMinutes(postProdTimeInMinuteString) - } - else -> {} + 9 -> { // 副產品數量 + bomProcessRequest.byProductQty = excelDecimalCell(tempCell) + } + 10 -> { // 副產品單位 + val unit = excelStringCell(tempCell) + if (unit.isNotEmpty()) { + bomProcessRequest.byProductUom = unit } } + 11 -> { // 生產前預備時間 + readMinuteCell(tempCell)?.let { bomProcessRequest.prepTimeInMinute = it } + } + 12 -> { // 生產後轉換時間 + readMinuteCell(tempCell)?.let { bomProcessRequest.postProdTimeInMinute = it } + } } // println("req:") // println(bomProcessRequest) @@ -1837,6 +1833,9 @@ open class BomService( durationInMinute = p.durationInMinute prepTimeInMinute = p.prepTimeInMinute postProdTimeInMinute = p.postProdTimeInMinute + byProduct = p.byProduct + byProductUom = p.byProductUom + byProductQty = p.byProductQty bom = wipBom } bomProcessRepository.saveAndFlush(newP) @@ -1994,6 +1993,51 @@ open class BomService( return regex.matches(trimmed) } + private fun excelDecimalCell(cell: org.apache.poi.ss.usermodel.Cell?): BigDecimal? { + if (cell == null || cell.cellType == CellType.BLANK) return null + return when (cell.cellType) { + CellType.NUMERIC -> BigDecimal.valueOf(cell.numericCellValue) + CellType.STRING -> cell.stringCellValue.trim().toBigDecimalOrNull() + CellType.FORMULA -> when (cell.cachedFormulaResultType) { + CellType.NUMERIC -> BigDecimal.valueOf(cell.numericCellValue) + CellType.STRING -> cell.stringCellValue.trim().toBigDecimalOrNull() + else -> null + } + else -> null + } + } + + private fun readMinuteCell(cell: org.apache.poi.ss.usermodel.Cell?): Int? { + if (cell == null || cell.cellType == CellType.BLANK) return null + return when (cell.cellType) { + CellType.NUMERIC -> cell.numericCellValue.toInt() + CellType.STRING -> parseMinuteText(cell.stringCellValue) + CellType.FORMULA -> when (cell.cachedFormulaResultType) { + CellType.NUMERIC -> cell.numericCellValue.toInt() + CellType.STRING -> parseMinuteText(cell.stringCellValue) + else -> null + } + else -> null + } + } + + private fun parseMinuteText(raw: String): Int? { + val text = raw.trim() + if (text.isEmpty()) return null + text.toIntOrNull()?.let { return it } + text.toDoubleOrNull()?.let { return it.toInt() } + return runCatching { extractDurationStringToMinutes(text) }.getOrNull() + } + + private fun excelStringCell(cell: org.apache.poi.ss.usermodel.Cell?): String { + if (cell == null || cell.cellType == CellType.BLANK) return "" + return when (cell.cellType) { + CellType.STRING -> cell.stringCellValue.trim() + CellType.FORMULA -> if (cell.cachedFormulaResultType == CellType.STRING) cell.stringCellValue.trim() else "" + else -> "" + } + } + /** Cell 是否為非空字串(含 STRING / FORMULA 結果為字串) */ private fun isNonEmptyStringCell(cell: org.apache.poi.ss.usermodel.Cell?): Boolean { if (cell == null || cell.cellType == CellType.BLANK) return false @@ -2103,7 +2147,12 @@ open class BomService( issues += BomFormatIssue(fileName, "工序區:第${rowNum}行『步驟時間(mins)』不可為空且須為數值") } } - // 4, 6~11 可 null,不檢查 + // 4, 6~8, 10~11 可 null,不檢查 + 9 -> { + if (tempCell.cellType != CellType.BLANK && !isNumericLike(tempCell)) { + issues += BomFormatIssue(fileName, "工序區:第${rowNum}行『副產品數量』須為數值") + } + } 8 -> { val rowNum = startRowIndex + 1 diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/SaveBomRequest.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/SaveBomRequest.kt index 4bf0dc5f..52a4b807 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/SaveBomRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/SaveBomRequest.kt @@ -65,6 +65,10 @@ data class ImportBomProcessRequest( var prepTimeInMinute: Int? = 0, var postProdTimeInMinute: Int? = 0, var bom: Bom? = null, + /** Full Excel cell. Item code is the first 6 characters. */ + var byProductName: String? = null, + var byProductUom: String? = null, + var byProductQty: BigDecimal? = null, ) data class ImportBomProcessMaterialRequest( var bomProcess: BomProcess? = null, diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index 235e5334..bd274731 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt @@ -53,6 +53,7 @@ import com.ffii.fpsms.modules.master.web.models.* import java.math.RoundingMode import java.time.LocalDate import java.time.format.DateTimeFormatter +import com.ffii.fpsms.modules.stock.entity.StockInLine import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import com.ffii.fpsms.modules.qc.entity.QcResultRepository import org.springframework.dao.DataIntegrityViolationException @@ -153,6 +154,7 @@ open class ProductProcessService( this.description = bomProcess.description this.equipmentType = bomProcess.equipment?.name this.isOriginal = true + applyBomByproduct(this, bomProcess) } productProcessLineRepository.save(line) println("➕ Service: Created line ${index + 1} - seq: ${line.seqNo}, name: ${line.name}") @@ -167,6 +169,24 @@ open class ProductProcessService( ) } + /** Copies the BOM step byproduct onto the production step. Item code is the first 6 characters of the Excel name. */ + private fun applyBomByproduct(line: ProductProcessLine, bomProcess: BomProcess) { + val raw = bomProcess.byProduct?.trim().orEmpty() + if (raw.length < 6) return + val item = itemsRepository.findByCodeAndDeletedFalse(raw.take(6)) ?: return + line.byproduct = item + line.byproductName = item.name?.takeIf { it.isNotBlank() } ?: raw + val uomCode = bomProcess.byProductUom?.trim()?.takeIf { it.isNotEmpty() } + line.byproductUom = uomCode + ?: item.id?.let { itemUomService.findStockUnitByItemId(it)?.uom?.code } + line.byproductQty = bomProcess.byProductQty?.toInt() + } + + /** Job order output receipt. A step byproduct stock-in must not stand in for it. */ + private fun outputStockInLine(lines: Iterable?): StockInLine? = + lines?.filter { it.type != StockInLine.TYPE_BYPRODUCT } + ?.minByOrNull { it.id ?: Long.MAX_VALUE } + private fun generateProductProcessCode(datePrefix: String, bomId: Long): String { val searchPattern = "PP-$datePrefix-$bomId-" val latestCode = productProcessRepository @@ -705,7 +725,7 @@ open class ProductProcessService( prepTimeInMinutes = line.setupTime ?: 0, postProdTimeInMinutes = line.changeoverTime ?: 0, byproductId = line.byproduct?.id ?: 0, - byproductName = line.byproduct?.name ?: "", + byproductName = line.byproduct?.name?.takeIf { it.isNotBlank() } ?: line.byproductName ?: "", byproductQty = line.byproductQty ?: 0, byproductUom = line.byproductUom ?: "", scrapQty = line.scrapQty ?: 0, @@ -966,6 +986,7 @@ open class ProductProcessService( this.setupTime = bomProcess.prepTimeInMinute this.changeoverTime = bomProcess.postProdTimeInMinute this.isOriginal = true + applyBomByproduct(this, bomProcess) } productProcessLineRepository.save(productProcessLine) } @@ -1244,7 +1265,8 @@ open class ProductProcessService( scrapQty = productProcessLine.scrapQty ?: 0, scrapUom = productProcessLine.scrapUom ?: "", byproductId = productProcessLine.byproduct?.id ?: 0, - byproductName = productProcessLine.byproduct?.name ?: "", + byproductName = productProcessLine.byproduct?.name?.takeIf { it.isNotBlank() } + ?: productProcessLine.byproductName ?: "", byproductQty = productProcessLine.byproductQty ?: 0, byproductUom = productProcessLine.byproductUom ?: "" ) @@ -1399,16 +1421,28 @@ open class ProductProcessService( if (scrapUom != null) { productProcessLine.scrapUom = scrapUom } - if (byproductName != null) { + if (!byproductName.isNullOrBlank()) { productProcessLine.byproductName = byproductName } if (byproductQty != null) { productProcessLine.byproductQty = byproductQty } - if (byproductUom != null) { + if (!byproductUom.isNullOrBlank()) { productProcessLine.byproductUom = byproductUom } productProcessLineRepository.save(productProcessLine) + val byproductItemId = productProcessLine.byproduct?.id + val jobOrderId = productProcessLine.productProcess.jobOrder?.id + val lineId = productProcessLine.id + val qty = productProcessLine.byproductQty ?: 0 + if (byproductItemId != null && jobOrderId != null && lineId != null && qty > 0) { + stockInLineService.stockInByproduct( + jobOrderId = jobOrderId, + itemId = byproductItemId, + qty = BigDecimal(qty), + productProcessLineId = lineId, + ) + } CompleteProductProcessLine(request.productProcessLineId) return MessageResponse( id = request.productProcessLineId, @@ -1434,7 +1468,7 @@ open class ProductProcessService( val FinishedProductProcessLineCount = productProcessLineRepository.findByProductProcess_Id(productProcesses.id ?: 0L) .count { it.status == "Completed" } - val stockInLine = jobOrder?.stockInLines?.firstOrNull() + val stockInLine = outputStockInLine(jobOrder?.stockInLines) val stockInLineId = stockInLine?.id val pickOrder = pickOrderRepository.findAllByJobOrder_Id(jobOrder?.id ?: 0L).firstOrNull() val joPickOrdersList = joPickOrderRepository.findByPickOrderId(pickOrder?.id ?: 0L) @@ -1499,7 +1533,7 @@ open class ProductProcessService( }.filter { response -> // 过滤掉已完成上架的 job order val jobOrder = jobOrderRepository.findById(response.jobOrderId ?: 0L).orElse(null) - val stockInLineStatus = jobOrder?.stockInLines?.firstOrNull()?.status + val stockInLineStatus = outputStockInLine(jobOrder?.stockInLines)?.status stockInLineStatus != "completed" // && stockInLineStatus != "escalated" // 你已經註解掉,會把 escalated 顯示出來 && stockInLineStatus != "rejected" @@ -1635,7 +1669,8 @@ open class ProductProcessService( .findAllByJobOrder_IdInAndDeletedFalse(candidateJobOrderIds) .groupBy { it.jobOrder?.id } .mapNotNull { (jobOrderId, rows) -> - if (jobOrderId == null) null else jobOrderId to rows.firstOrNull() + val output = outputStockInLine(rows) ?: return@mapNotNull null + if (jobOrderId == null) null else jobOrderId to output } .toMap() @@ -1994,7 +2029,7 @@ open class ProductProcessService( val jobOrder = jobOrderById[jobOrderId] ?: continue if (jobOrder.status == JobOrderStatus.PLANNING) continue - val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(jobOrderId) ?: continue + val stockInLine = stockInLineRepository.findFirstOutputByJobOrderId(jobOrderId) ?: continue if (stockInLine.purchaseOrderLine != null) continue val statusNorm = (stockInLine.status ?: "").trim().lowercase() @@ -2262,7 +2297,7 @@ open class ProductProcessService( jobOrder.status = JobOrderStatus.STORING jobOrderRepository.save(jobOrder) - val existingSil = jobOrder.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } + val existingSil = jobOrder.id?.let { stockInLineRepository.findFirstOutputByJobOrderId(it) } if (existingSil != null) { stockInLineService.update( SaveStockInLineRequest( @@ -2863,7 +2898,7 @@ open class ProductProcessService( val process = processesByJobOrderId[jo.id] val lines = process?.id?.let { linesByProcessId[it].orEmpty() }.orEmpty() val sils = stockInLinesByJobOrderId[jo.id].orEmpty() - val stockInLine = sils.maxByOrNull { it.acceptedQty ?: BigDecimal.ZERO } + val stockInLine = outputStockInLine(sils) val startTime = process?.startTime val lineProcessingMins = lines.sumOf { it.processingTime ?: 0 } val bomDurationMins = bomId?.let { bomDurationMinsByBomId[it] } ?: 0 diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLine.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLine.kt index 12c90309..00dc2d38 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLine.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLine.kt @@ -117,6 +117,12 @@ open class StockInLine : BaseEntity() { @Column(name = "type") open var type: String? = null + companion object { + /** Step byproduct receipt. Not the job order's finished-goods stock-in. */ + const val TYPE_BYPRODUCT = "BYPRODUCT" + const val BYPRODUCT_LINE_REMARK_PREFIX = "BYPRODUCT_LINE:" + } + @JsonManagedReference @OneToMany(mappedBy = "stockInLine", cascade = [CascadeType.ALL], orphanRemoval = true) open var escalationLog: MutableList? = mutableListOf() diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt index 764521b0..b809aad9 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt @@ -65,6 +65,23 @@ fun searchStockInLines( fun findAllByItemIdInAndDeletedFalse(itemIds: List): List fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): StockInLine? + /** Finished-goods / WIP output receipt. Excludes step byproduct stock-ins. */ + @Query( + value = """ + SELECT sil.* FROM stock_in_line sil + WHERE sil.id = ( + SELECT MIN(s2.id) FROM stock_in_line s2 + WHERE s2.jobOrderId = :jobOrderId + AND s2.deleted = 0 + AND (s2.type IS NULL OR s2.type <> 'BYPRODUCT') + ) + """, + nativeQuery = true + ) + fun findFirstOutputByJobOrderId(@Param("jobOrderId") jobOrderId: Long): StockInLine? + + fun findFirstByRemarksAndDeletedFalse(remarks: String): StockInLine? + /** Loads stock-in lines with item and purchaseOrderLine.item so bDesc/bDesc_en (item name) are available for M18 GRN. */ @Query(""" SELECT DISTINCT sil FROM StockInLine sil @@ -181,6 +198,7 @@ fun findLatestLotNoByPrefix(@Param("prefix") prefix: String): String? FROM stock_in_line s2 WHERE s2.deleted = 0 AND s2.jobOrderId IN (:jobOrderIds) + AND (s2.type IS NULL OR s2.type <> 'BYPRODUCT') GROUP BY s2.jobOrderId ) """, 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 354062dc..7b5b467c 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 @@ -501,6 +501,94 @@ open class StockInLineService( } + /** + * Stocks a process-step byproduct in and puts it away like a finished-goods job order: + * warehouse whose code ends with the item location code, otherwise warehouse 1141. + * Skips QC. Does not change the job order status, and is not the job order output receipt. + * Delivery note is the same placeholder finished-goods QC uses when none is entered. + */ + @Transactional + open fun stockInByproduct( + jobOrderId: Long, + itemId: Long, + qty: BigDecimal, + productProcessLineId: Long, + ): StockInLine? { + if (qty <= BigDecimal.ZERO) return null + val remark = "${StockInLine.BYPRODUCT_LINE_REMARK_PREFIX}$productProcessLineId" + stockInLineRepository.findFirstByRemarksAndDeletedFalse(remark)?.let { return it } + + val jo = jobOrderRepository.findById(jobOrderId).orElseThrow { + IllegalStateException("Job order $jobOrderId not found for byproduct stock-in") + } + val item = itemRepository.findById(itemId).orElseThrow { + IllegalStateException("Byproduct item $itemId not found") + } + val itemCode = item.code?.trim().orEmpty() + if (itemCode.isEmpty()) { + throw IllegalStateException("Byproduct item $itemId has no item code") + } + val stockUom = itemUomRepository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId) + ?: throw IllegalStateException("Byproduct item $itemCode has no stock unit") + val warehouse = resolveByproductWarehouse(item.LocationCode, itemCode) + val stockIn = stockInService.create(SaveStockInRequest()).entity as StockIn + val today = LocalDate.now() + val shelfDays = item.shelfLife?.toLong()?.takeIf { it > 0 } ?: 365L + val lotNo = assignLotNoForJo(jo.planStart?.toLocalDate()) + val sil = StockInLine().apply { + this.item = item + this.itemNo = itemCode.take(20) + this.jobOrder = jo + this.stockIn = stockIn + this.acceptedQty = qty + this.demandQty = qty + this.dnNo = "DN00000" + this.receiptDate = today.atStartOfDay() + this.productionDate = today.atStartOfDay() + this.productLotNo = jo.code + this.lotNo = lotNo + this.type = StockInLine.TYPE_BYPRODUCT + this.status = StockInLineStatus.COMPLETE.status + this.remarks = remark + } + val savedSil = saveAndFlush(sil) + val lot = inventoryLotRepository.saveAndFlush(InventoryLot().apply { + this.item = item + this.stockInLine = savedSil + this.stockInDate = LocalDateTime.now() + this.productionDate = savedSil.productionDate + this.expiryDate = today.plusDays(shelfDays) + this.lotNo = lotNo + }) + val lotLine = inventoryLotLineRepository.saveAndFlush(InventoryLotLine().apply { + this.inventoryLot = lot + this.warehouse = warehouse + this.inQty = qty + this.status = InventoryLotLineStatus.AVAILABLE + this.stockUom = stockUom + }) + savedSil.inventoryLot = lot + savedSil.inventoryLotLine = lotLine + val linked = saveAndFlush(savedSil) + createStockLedgerForStockIn(linked, qty.toDouble()) + return linked + } + + /** Same warehouse rule as finished-goods auto put-away: code ends with the item location code. */ + private fun resolveByproductWarehouse(locationCode: String?, itemCode: String): com.ffii.fpsms.modules.master.entity.Warehouse { + val loc = locationCode?.trim()?.uppercase().orEmpty() + if (loc.isNotEmpty()) { + val matched = warehouseRepository.findAllByDeletedIsFalse() + .filter { (it.code ?: "").trim().uppercase().endsWith(loc) } + .minByOrNull { it.id ?: Long.MAX_VALUE } + if (matched != null) return matched + } + return warehouseRepository.findByIdAndDeletedIsFalse(1141L) + ?: throw IllegalStateException( + "No put-away warehouse for byproduct $itemCode. Set the item location code, or warehouse 1141." + ) + } + @Throws(IOException::class) @Transactional fun saveInventoryLotWhenStockIn(request: SaveStockInLineRequest, stockInLine: StockInLine): InventoryLot { diff --git a/src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt b/src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt index d9d8fd24..8277777a 100644 --- a/src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt +++ b/src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt @@ -95,7 +95,7 @@ object PyJobOrderListMapper { } val itemName = buildDisplayItemName(baseName, stockUnitDesc) - val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } + val stockInLine = jo.id?.let { stockInLineRepository.findFirstOutputByJobOrderId(it) } val stockInLineId = stockInLine?.id val lotNo = stockInLine?.lotNo val p = printed ?: PrintedQtyByChannel() @@ -137,7 +137,7 @@ object PyJobOrderListMapper { } val itemName = buildDisplayItemNameForLaser(baseName, stockUnitDesc, itemCode) - val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } + val stockInLine = jo.id?.let { stockInLineRepository.findFirstOutputByJobOrderId(it) } val stockInLineId = stockInLine?.id val lotNo = stockInLine?.lotNo val p = printed ?: PrintedQtyByChannel() diff --git a/src/main/resources/db/changelog/changes/20261102_bom_process_byproduct_qty/01_add_byproduct_qty.sql b/src/main/resources/db/changelog/changes/20261102_bom_process_byproduct_qty/01_add_byproduct_qty.sql new file mode 100644 index 00000000..ba88afef --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261102_bom_process_byproduct_qty/01_add_byproduct_qty.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql +--changeset harry:20261102_bom_process_byproduct_qty +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'bom_process' AND column_name = 'byProductQty' + +ALTER TABLE bom_process + ADD COLUMN byProductQty DECIMAL(16,2) NULL DEFAULT NULL AFTER byProductUom;