Excel now imports the byproduct name, quantity, and unit onto the process step. Completing the step writes a separate stock-in line, and job-order output lookups skip that line.debrand
| @@ -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}") | |||
| @@ -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<JobOrderListForPri | |||
| // 2. 进一步过滤:status = "received" 或 "completed",且 BOM type = "WIP" 或 "FG" | |||
| val stockInLines = allStockInLines.filter { | |||
| it.jobOrder != null | |||
| it.jobOrder != null && it.type != StockInLine.TYPE_BYPRODUCT | |||
| //it.status == "received" && // 只显示 QC 完成但尚未 putaway 的 | |||
| // it.jobOrder?.bom?.description in listOf("WIP", "FG") | |||
| } | |||
| @@ -225,7 +225,7 @@ open class JobOrderPlanStartAutoService( | |||
| pp.date = newPlanStart.toLocalDate() | |||
| productProcessRepository.save(pp) | |||
| val sil = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } | |||
| val sil = jo.id?.let { stockInLineRepository.findFirstOutputByJobOrderId(it) } | |||
| if (sil != null) { | |||
| sil.lotNo = stockInLineService.assignLotNoForJo(newPlanStart.toLocalDate()) | |||
| sil.productLotNo = newCode | |||
| @@ -316,7 +316,7 @@ class PlasticBagPrinterService( | |||
| expiryPrintNames: Map<String, String>, | |||
| 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 | |||
| @@ -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<Long>() { | |||
| @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) | |||
| @@ -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 | |||
| @@ -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, | |||
| @@ -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>?): 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 | |||
| @@ -117,6 +117,12 @@ open class StockInLine : BaseEntity<Long>() { | |||
| @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<EscalationLog>? = mutableListOf() | |||
| @@ -65,6 +65,23 @@ fun searchStockInLines( | |||
| fun findAllByItemIdInAndDeletedFalse(itemIds: List<Long>): List<StockInLine> | |||
| 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 | |||
| ) | |||
| """, | |||
| @@ -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 { | |||
| @@ -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() | |||
| @@ -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; | |||