Переглянути джерело

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 3 дні тому
джерело
коміт
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; val item: JobOrderItemInfo;


// TODO pack below as StockInLineInfo // 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?; 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?; 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?; 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 lotNo: String?
val planStart: LocalDateTime?; val planStart: LocalDateTime?;
// @get:Value("#{target.bom.item.itemUoms.^[salesUnit == true && deleted == false]?.uom}") // @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.jobOrder.web.model.StockOutLineDetailResponse


import com.ffii.fpsms.modules.stock.entity.enum.StockInLineStatus 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 com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import java.time.LocalDate import java.time.LocalDate
import com.ffii.fpsms.modules.jobOrder.web.model.MaterialPickStatusItem 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" // 2. 进一步过滤:status = "received" 或 "completed",且 BOM type = "WIP" 或 "FG"
val stockInLines = allStockInLines.filter { val stockInLines = allStockInLines.filter {
it.jobOrder != null
it.jobOrder != null && it.type != StockInLine.TYPE_BYPRODUCT
//it.status == "received" && // 只显示 QC 完成但尚未 putaway 的 //it.status == "received" && // 只显示 QC 完成但尚未 putaway 的
// it.jobOrder?.bom?.description in listOf("WIP", "FG") // 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() pp.date = newPlanStart.toLocalDate()
productProcessRepository.save(pp) productProcessRepository.save(pp)


val sil = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) }
val sil = jo.id?.let { stockInLineRepository.findFirstOutputByJobOrderId(it) }
if (sil != null) { if (sil != null) {
sil.lotNo = stockInLineService.assignLotNoForJo(newPlanStart.toLocalDate()) sil.lotNo = stockInLineService.assignLotNoForJo(newPlanStart.toLocalDate())
sil.productLotNo = newCode 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>, expiryPrintNames: Map<String, String>,
jobOrder: JobOrder?, jobOrder: JobOrder?,
): OnPackBmpExportItem? { ): OnPackBmpExportItem? {
val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(order.jobOrderId)
val stockInLine = stockInLineRepository.findFirstOutputByJobOrderId(order.jobOrderId)
?: return null ?: return null
val itemId = stockInLine.item?.id ?: return null val itemId = stockInLine.item?.id ?: return null
val stockInLineId = stockInLine.id ?: return null val stockInLineId = stockInLine.id ?: return null
@@ -1021,7 +1021,7 @@ class PlasticBagPrinterService(
.groupBy { it.itemCode.trim().lowercase() } .groupBy { it.itemCode.trim().lowercase() }
.mapNotNull { (codeLower, orders) -> .mapNotNull { (codeLower, orders) ->
val order = orders.firstOrNull() ?: return@mapNotNull null val order = orders.firstOrNull() ?: return@mapNotNull null
val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(order.jobOrderId)
val stockInLine = stockInLineRepository.findFirstOutputByJobOrderId(order.jobOrderId)
?: return@mapNotNull null ?: return@mapNotNull null
val itemId = stockInLine.item?.id ?: return@mapNotNull null val itemId = stockInLine.item?.id ?: return@mapNotNull null
val stockInLineId = stockInLine.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 com.ffii.core.entity.BaseEntity
import jakarta.persistence.* import jakarta.persistence.*
import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.NotNull
import java.math.BigDecimal


@Table(name = "bom_process") @Table(name = "bom_process")
@Entity @Entity
@@ -47,6 +48,16 @@ open class BomProcess : BaseEntity<Long>() {
@JoinColumn(name = "bomId", nullable = false) @JoinColumn(name = "bomId", nullable = false)
open var bom: Bom? = null 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) @OneToMany(mappedBy = "bomProcess", cascade = [CascadeType.ALL], orphanRemoval = true)
@JsonManagedReference("bomProcess-material") @JsonManagedReference("bomProcess-material")
@JsonInclude(JsonInclude.Include.NON_NULL) @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.prepTimeInMinute = req.prepTimeInMinute
this.postProdTimeInMinute = req.postProdTimeInMinute this.postProdTimeInMinute = req.postProdTimeInMinute
this.bom = req.bom 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) return bomProcessRepository.saveAndFlush(bomProcess)
} }
@@ -1372,7 +1375,8 @@ open class BomService(
var startRowIndex = 30 var startRowIndex = 30
var endRowIndex = 70 var endRowIndex = 70
var startColumnIndex = 0 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) { while (startRowIndex < endRowIndex) {
val tempRow = sheet.getRow(startRowIndex) ?: throw Exception() val tempRow = sheet.getRow(startRowIndex) ?: throw Exception()
val tempCell = tempRow.getCell(startColumnIndex) val tempCell = tempRow.getCell(startColumnIndex)
@@ -1476,35 +1480,27 @@ open class BomService(
bomProcessRequest.byProductUnit = byProductUnit 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("req:")
// println(bomProcessRequest) // println(bomProcessRequest)
@@ -1837,6 +1833,9 @@ open class BomService(
durationInMinute = p.durationInMinute durationInMinute = p.durationInMinute
prepTimeInMinute = p.prepTimeInMinute prepTimeInMinute = p.prepTimeInMinute
postProdTimeInMinute = p.postProdTimeInMinute postProdTimeInMinute = p.postProdTimeInMinute
byProduct = p.byProduct
byProductUom = p.byProductUom
byProductQty = p.byProductQty
bom = wipBom bom = wipBom
} }
bomProcessRepository.saveAndFlush(newP) bomProcessRepository.saveAndFlush(newP)
@@ -1994,6 +1993,51 @@ open class BomService(
return regex.matches(trimmed) 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 結果為字串) */ /** Cell 是否為非空字串(含 STRING / FORMULA 結果為字串) */
private fun isNonEmptyStringCell(cell: org.apache.poi.ss.usermodel.Cell?): Boolean { private fun isNonEmptyStringCell(cell: org.apache.poi.ss.usermodel.Cell?): Boolean {
if (cell == null || cell.cellType == CellType.BLANK) return false if (cell == null || cell.cellType == CellType.BLANK) return false
@@ -2103,7 +2147,12 @@ open class BomService(
issues += BomFormatIssue(fileName, "工序區:第${rowNum}行『步驟時間(mins)』不可為空且須為數值") 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 -> { 8 -> {
val rowNum = startRowIndex + 1 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 prepTimeInMinute: Int? = 0,
var postProdTimeInMinute: Int? = 0, var postProdTimeInMinute: Int? = 0,
var bom: Bom? = null, 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( data class ImportBomProcessMaterialRequest(
var bomProcess: BomProcess? = null, 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.math.RoundingMode
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter 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.stock.entity.StockInLineRepository
import com.ffii.fpsms.modules.qc.entity.QcResultRepository import com.ffii.fpsms.modules.qc.entity.QcResultRepository
import org.springframework.dao.DataIntegrityViolationException import org.springframework.dao.DataIntegrityViolationException
@@ -153,6 +154,7 @@ open class ProductProcessService(
this.description = bomProcess.description this.description = bomProcess.description
this.equipmentType = bomProcess.equipment?.name this.equipmentType = bomProcess.equipment?.name
this.isOriginal = true this.isOriginal = true
applyBomByproduct(this, bomProcess)
} }
productProcessLineRepository.save(line) productProcessLineRepository.save(line)
println("➕ Service: Created line ${index + 1} - seq: ${line.seqNo}, name: ${line.name}") 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 { private fun generateProductProcessCode(datePrefix: String, bomId: Long): String {
val searchPattern = "PP-$datePrefix-$bomId-" val searchPattern = "PP-$datePrefix-$bomId-"
val latestCode = productProcessRepository val latestCode = productProcessRepository
@@ -705,7 +725,7 @@ open class ProductProcessService(
prepTimeInMinutes = line.setupTime ?: 0, prepTimeInMinutes = line.setupTime ?: 0,
postProdTimeInMinutes = line.changeoverTime ?: 0, postProdTimeInMinutes = line.changeoverTime ?: 0,
byproductId = line.byproduct?.id ?: 0, byproductId = line.byproduct?.id ?: 0,
byproductName = line.byproduct?.name ?: "",
byproductName = line.byproduct?.name?.takeIf { it.isNotBlank() } ?: line.byproductName ?: "",
byproductQty = line.byproductQty ?: 0, byproductQty = line.byproductQty ?: 0,
byproductUom = line.byproductUom ?: "", byproductUom = line.byproductUom ?: "",
scrapQty = line.scrapQty ?: 0, scrapQty = line.scrapQty ?: 0,
@@ -966,6 +986,7 @@ open class ProductProcessService(
this.setupTime = bomProcess.prepTimeInMinute this.setupTime = bomProcess.prepTimeInMinute
this.changeoverTime = bomProcess.postProdTimeInMinute this.changeoverTime = bomProcess.postProdTimeInMinute
this.isOriginal = true this.isOriginal = true
applyBomByproduct(this, bomProcess)
} }
productProcessLineRepository.save(productProcessLine) productProcessLineRepository.save(productProcessLine)
} }
@@ -1244,7 +1265,8 @@ open class ProductProcessService(
scrapQty = productProcessLine.scrapQty ?: 0, scrapQty = productProcessLine.scrapQty ?: 0,
scrapUom = productProcessLine.scrapUom ?: "", scrapUom = productProcessLine.scrapUom ?: "",
byproductId = productProcessLine.byproduct?.id ?: 0, byproductId = productProcessLine.byproduct?.id ?: 0,
byproductName = productProcessLine.byproduct?.name ?: "",
byproductName = productProcessLine.byproduct?.name?.takeIf { it.isNotBlank() }
?: productProcessLine.byproductName ?: "",
byproductQty = productProcessLine.byproductQty ?: 0, byproductQty = productProcessLine.byproductQty ?: 0,
byproductUom = productProcessLine.byproductUom ?: "" byproductUom = productProcessLine.byproductUom ?: ""
) )
@@ -1399,16 +1421,28 @@ open class ProductProcessService(
if (scrapUom != null) { if (scrapUom != null) {
productProcessLine.scrapUom = scrapUom productProcessLine.scrapUom = scrapUom
} }
if (byproductName != null) {
if (!byproductName.isNullOrBlank()) {
productProcessLine.byproductName = byproductName productProcessLine.byproductName = byproductName
} }
if (byproductQty != null) { if (byproductQty != null) {
productProcessLine.byproductQty = byproductQty productProcessLine.byproductQty = byproductQty
} }
if (byproductUom != null) {
if (!byproductUom.isNullOrBlank()) {
productProcessLine.byproductUom = byproductUom productProcessLine.byproductUom = byproductUom
} }
productProcessLineRepository.save(productProcessLine) 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) CompleteProductProcessLine(request.productProcessLineId)
return MessageResponse( return MessageResponse(
id = request.productProcessLineId, id = request.productProcessLineId,
@@ -1434,7 +1468,7 @@ open class ProductProcessService(
val FinishedProductProcessLineCount = val FinishedProductProcessLineCount =
productProcessLineRepository.findByProductProcess_Id(productProcesses.id ?: 0L) productProcessLineRepository.findByProductProcess_Id(productProcesses.id ?: 0L)
.count { it.status == "Completed" } .count { it.status == "Completed" }
val stockInLine = jobOrder?.stockInLines?.firstOrNull()
val stockInLine = outputStockInLine(jobOrder?.stockInLines)
val stockInLineId = stockInLine?.id val stockInLineId = stockInLine?.id
val pickOrder = pickOrderRepository.findAllByJobOrder_Id(jobOrder?.id ?: 0L).firstOrNull() val pickOrder = pickOrderRepository.findAllByJobOrder_Id(jobOrder?.id ?: 0L).firstOrNull()
val joPickOrdersList = joPickOrderRepository.findByPickOrderId(pickOrder?.id ?: 0L) val joPickOrdersList = joPickOrderRepository.findByPickOrderId(pickOrder?.id ?: 0L)
@@ -1499,7 +1533,7 @@ open class ProductProcessService(
}.filter { response -> }.filter { response ->
// 过滤掉已完成上架的 job order // 过滤掉已完成上架的 job order
val jobOrder = jobOrderRepository.findById(response.jobOrderId ?: 0L).orElse(null) val jobOrder = jobOrderRepository.findById(response.jobOrderId ?: 0L).orElse(null)
val stockInLineStatus = jobOrder?.stockInLines?.firstOrNull()?.status
val stockInLineStatus = outputStockInLine(jobOrder?.stockInLines)?.status
stockInLineStatus != "completed" stockInLineStatus != "completed"
// && stockInLineStatus != "escalated" // 你已經註解掉,會把 escalated 顯示出來 // && stockInLineStatus != "escalated" // 你已經註解掉,會把 escalated 顯示出來
&& stockInLineStatus != "rejected" && stockInLineStatus != "rejected"
@@ -1635,7 +1669,8 @@ open class ProductProcessService(
.findAllByJobOrder_IdInAndDeletedFalse(candidateJobOrderIds) .findAllByJobOrder_IdInAndDeletedFalse(candidateJobOrderIds)
.groupBy { it.jobOrder?.id } .groupBy { it.jobOrder?.id }
.mapNotNull { (jobOrderId, rows) -> .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() .toMap()


@@ -1994,7 +2029,7 @@ open class ProductProcessService(
val jobOrder = jobOrderById[jobOrderId] ?: continue val jobOrder = jobOrderById[jobOrderId] ?: continue
if (jobOrder.status == JobOrderStatus.PLANNING) 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 if (stockInLine.purchaseOrderLine != null) continue


val statusNorm = (stockInLine.status ?: "").trim().lowercase() val statusNorm = (stockInLine.status ?: "").trim().lowercase()
@@ -2262,7 +2297,7 @@ open class ProductProcessService(
jobOrder.status = JobOrderStatus.STORING jobOrder.status = JobOrderStatus.STORING
jobOrderRepository.save(jobOrder) jobOrderRepository.save(jobOrder)
val existingSil = jobOrder.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) }
val existingSil = jobOrder.id?.let { stockInLineRepository.findFirstOutputByJobOrderId(it) }
if (existingSil != null) { if (existingSil != null) {
stockInLineService.update( stockInLineService.update(
SaveStockInLineRequest( SaveStockInLineRequest(
@@ -2863,7 +2898,7 @@ open class ProductProcessService(
val process = processesByJobOrderId[jo.id] val process = processesByJobOrderId[jo.id]
val lines = process?.id?.let { linesByProcessId[it].orEmpty() }.orEmpty() val lines = process?.id?.let { linesByProcessId[it].orEmpty() }.orEmpty()
val sils = stockInLinesByJobOrderId[jo.id].orEmpty() val sils = stockInLinesByJobOrderId[jo.id].orEmpty()
val stockInLine = sils.maxByOrNull { it.acceptedQty ?: BigDecimal.ZERO }
val stockInLine = outputStockInLine(sils)
val startTime = process?.startTime val startTime = process?.startTime
val lineProcessingMins = lines.sumOf { it.processingTime ?: 0 } val lineProcessingMins = lines.sumOf { it.processingTime ?: 0 }
val bomDurationMins = bomId?.let { bomDurationMinsByBomId[it] } ?: 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") @Column(name = "type")
open var type: String? = null 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 @JsonManagedReference
@OneToMany(mappedBy = "stockInLine", cascade = [CascadeType.ALL], orphanRemoval = true) @OneToMany(mappedBy = "stockInLine", cascade = [CascadeType.ALL], orphanRemoval = true)
open var escalationLog: MutableList<EscalationLog>? = mutableListOf() 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 findAllByItemIdInAndDeletedFalse(itemIds: List<Long>): List<StockInLine>
fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): 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. */ /** Loads stock-in lines with item and purchaseOrderLine.item so bDesc/bDesc_en (item name) are available for M18 GRN. */
@Query(""" @Query("""
SELECT DISTINCT sil FROM StockInLine sil SELECT DISTINCT sil FROM StockInLine sil
@@ -181,6 +198,7 @@ fun findLatestLotNoByPrefix(@Param("prefix") prefix: String): String?
FROM stock_in_line s2 FROM stock_in_line s2
WHERE s2.deleted = 0 WHERE s2.deleted = 0
AND s2.jobOrderId IN (:jobOrderIds) AND s2.jobOrderId IN (:jobOrderIds)
AND (s2.type IS NULL OR s2.type <> 'BYPRODUCT')
GROUP BY s2.jobOrderId 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) @Throws(IOException::class)
@Transactional @Transactional
fun saveInventoryLotWhenStockIn(request: SaveStockInLineRequest, stockInLine: StockInLine): InventoryLot { 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 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 stockInLineId = stockInLine?.id
val lotNo = stockInLine?.lotNo val lotNo = stockInLine?.lotNo
val p = printed ?: PrintedQtyByChannel() val p = printed ?: PrintedQtyByChannel()
@@ -137,7 +137,7 @@ object PyJobOrderListMapper {
} }
val itemName = buildDisplayItemNameForLaser(baseName, stockUnitDesc, itemCode) 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 stockInLineId = stockInLine?.id
val lotNo = stockInLine?.lotNo val lotNo = stockInLine?.lotNo
val p = printed ?: PrintedQtyByChannel() 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;

Завантаження…
Відмінити
Зберегти