소스 검색

BOM byproducts now stock on their own receipt, so does not replace job order output

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
Harry Groves 2 일 전
부모
커밋
368b19eb90
13개의 변경된 파일과 266개의 추가작업 그리고 47개의 파일을 삭제
  1. +4
    -4
      src/main/java/com/ffii/fpsms/modules/jobOrder/entity/projections/JobOrderInfo.kt
  2. +2
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt
  3. +1
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt
  4. +2
    -2
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt
  5. +11
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/BomProcess.kt
  6. +76
    -27
      src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt
  7. +4
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/SaveBomRequest.kt
  8. +45
    -10
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  9. +6
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLine.kt
  10. +18
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt
  11. +88
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  12. +2
    -2
      src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt
  13. +7
    -0
      src/main/resources/db/changelog/changes/20261102_bom_process_byproduct_qty/01_add_byproduct_qty.sql

+ 4
- 4
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}")


+ 2
- 1
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<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")
}


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt 파일 보기

@@ -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


+ 2
- 2
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt 파일 보기

@@ -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


+ 11
- 0
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<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)


+ 76
- 27
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


+ 4
- 0
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,


+ 45
- 10
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>?): 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


+ 6
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLine.kt 파일 보기

@@ -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()


+ 18
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt 파일 보기

@@ -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
)
""",


+ 88
- 0
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 {


+ 2
- 2
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()


+ 7
- 0
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;

불러오는 중...
취소
저장