CANCERYS\kw093 3 дней назад
Родитель
Сommit
83fa775bc3
11 измененных файлов: 258 добавлений и 183 удалений
  1. +49
    -9
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt
  2. +32
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderCreationService.kt
  3. +3
    -9
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt
  4. +2
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt
  5. +11
    -1
      src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt
  6. +34
    -9
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  7. +10
    -20
      src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt
  8. +22
    -47
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt
  9. +1
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt
  10. +36
    -6
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  11. +58
    -82
      src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt

+ 49
- 9
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt Просмотреть файл

@@ -1508,7 +1508,12 @@ open fun getCompletedJobOrderPickOrderLotDetails(pickOrderId: Long): List<Map<St

LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.inventoryLotLineId = ill.id AND sol.deleted = false
-- no-lot 情況:ill.id 為 null 時,要匹配 sol.inventoryLotLineId IS NULL
LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = false
AND (
sol.inventoryLotLineId = ill.id
OR (sol.inventoryLotLineId IS NULL AND ill.id IS NULL)
)
LEFT JOIN fpsmsdb.jo_pick_order jpo ON jpo.pick_order_id = po.id AND jpo.item_id = pol.itemId
LEFT JOIN fpsmsdb.item_uom iu ON iu.itemId = b.itemId AND iu.stockUnit = 1
LEFT JOIN fpsmsdb.uom_conversion uc_fg ON uc_fg.id = iu.uomId
@@ -1517,8 +1522,9 @@ open fun getCompletedJobOrderPickOrderLotDetails(pickOrderId: Long): List<Map<St
AND po.id = :pickOrderId
AND pol.deleted = false
AND po.status = 'COMPLETED'
AND ill.deleted = false
AND il.deleted = false
-- ill / il 可能為 null(no-lot),避免把 no-lot 記錄過濾掉
AND (ill.id IS NULL OR ill.deleted = false)
AND (il.id IS NULL OR il.deleted = false)
AND (spl.pickOrderLineId IS NOT NULL OR sol.pickOrderLineId IS NOT NULL)
ORDER BY
CASE WHEN sol.status = 'rejected' THEN 0 ELSE 1 END,
@@ -1719,15 +1725,21 @@ open fun getCompletedJobOrderPickOrderLotDetailsForCompletedPick(pickOrderId: Lo

LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId
LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.inventoryLotLineId = ill.id AND sol.deleted = false
-- no-lot 情況:ill.id 為 null 時,要匹配 sol.inventoryLotLineId IS NULL 的记录
LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = false
AND (
sol.inventoryLotLineId = ill.id
OR (sol.inventoryLotLineId IS NULL AND ill.id IS NULL)
)
LEFT JOIN fpsmsdb.jo_pick_order jpo ON jpo.pick_order_id = po.id AND jpo.item_id = pol.itemId

WHERE po.deleted = false
AND po.id = :pickOrderId
AND pol.deleted = false
AND po.status = 'COMPLETED'
AND ill.deleted = false
AND il.deleted = false
-- ill / il 可能为 null(no-lot),避免把 no-lot 记录过滤掉
AND (ill.id IS NULL OR ill.deleted = false)
AND (il.id IS NULL OR il.deleted = false)
AND (spl.pickOrderLineId IS NOT NULL OR sol.pickOrderLineId IS NOT NULL)
ORDER BY
CASE WHEN sol.status = 'rejected' THEN 0 ELSE 1 END,
@@ -1988,6 +2000,32 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo
// 获取 pick order lines
val pickOrderLines = pickOrderLineRepository.findAllByPickOrderId(pickOrder.id!!)
.filter { it.deleted == false }

// Pre-calculate total available qty per item (for UI display).
// Note: we align with pick suggestion logic: AVAILABLE status, not expired, remaining > 0.
val itemIds = pickOrderLines.mapNotNull { it.item?.id }.distinct()
val today = LocalDate.now()
val totalAvailableQtyByItemId: Map<Long, Double> = if (itemIds.isNotEmpty()) {
inventoryLotLineRepository.findAllByItemIdIn(itemIds)
.asSequence()
.filter { it.deleted == false }
.filter { it.status == InventoryLotLineStatus.AVAILABLE } // entity enum
.filter { ill ->
val exp = ill.inventoryLot?.expiryDate
exp == null || !exp.isBefore(today)
}
.mapNotNull { ill ->
val iid = ill.inventoryLot?.item?.id ?: return@mapNotNull null
val remaining = (ill.inQty ?: BigDecimal.ZERO)
.minus(ill.outQty ?: BigDecimal.ZERO)
.minus(ill.holdQty ?: BigDecimal.ZERO)
if (remaining > BigDecimal.ZERO) iid to remaining else null
}
.groupBy({ it.first }, { it.second })
.mapValues { (_, qtys) -> qtys.fold(BigDecimal.ZERO) { acc, q -> acc + q }.toDouble() }
} else {
emptyMap()
}
// 获取所有 pick order line IDs
val pickOrderLineIds = pickOrderLines.map { it.id!! }
@@ -2205,6 +2243,7 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo
itemCode = item?.code,
itemName = item?.name,
requiredQty = pol.qty?.toDouble(),
totalAvailableQty = item?.id?.let { totalAvailableQtyByItemId[it] },
uomCode = uom?.code,
uomDesc = uom?.udfudesc,
status = pol.status?.value,
@@ -2254,8 +2293,8 @@ open fun updateHandledByForItem(pickOrderId: Long, itemId: Long, userId: Long):
}

val joPickOrder = joPickOrderOpt.get()
if (userId != null && joPickOrder.matchingBy != null) {
val existingOperatorId = joPickOrder.matchingBy
if (userId != null && joPickOrder.handledBy != null) {
val existingOperatorId = joPickOrder.handledBy
val newOperatorId = userId
// 如果不是同一个用户,拒绝更新
@@ -2270,7 +2309,8 @@ open fun updateHandledByForItem(pickOrderId: Long, itemId: Long, userId: Long):
)
}
}
joPickOrder.matchingBy = userId
// Update the displayed operator: jo_pick_order.handled_by
joPickOrder.handledBy = userId
joPickOrderRepository.save(joPickOrder)
// Don't update other fields - only handledBy


+ 32
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderCreationService.kt Просмотреть файл

@@ -0,0 +1,32 @@
package com.ffii.fpsms.modules.jobOrder.service

import com.ffii.fpsms.modules.jobOrder.web.model.CreateJobOrderRequest
import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.productProcess.service.ProductProcessService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
open class JobOrderCreationService(
private val jobOrderService: JobOrderService,
private val jobOrderBomMaterialService: JobOrderBomMaterialService,
private val jobOrderProcessService: JobOrderProcessService,
private val productProcessService: ProductProcessService,
) {
/**
* 建立 JO 並完成所有關聯資料(BOM materials / processes / productprocess + lines)。
* 任一步失敗會整體回滾,避免留下「JO 有、productprocess 沒有」的半成品。
*/
@Transactional(rollbackFor = [Exception::class])
open fun createJobOrderWithFullFlow(request: CreateJobOrderRequest, productionPriority: Int?): MessageResponse {
val jo = jobOrderService.createJobOrder(request)
val joId = jo.id ?: throw IllegalStateException("Job Order creation failed: returned object ID is null.")

jobOrderBomMaterialService.createJobOrderBomMaterialsByJoId(joId)
jobOrderProcessService.createJobOrderProcessesByJoId(joId)
productProcessService.createProductProcessByJobOrderId(joId, productionPriority)

return jo
}
}


+ 3
- 9
src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt Просмотреть файл

@@ -50,7 +50,8 @@ class JobOrderController(
private val jobOrderBomMaterialService: JobOrderBomMaterialService,
private val jobOrderProcessService: JobOrderProcessService,
private val joPickOrderService: JoPickOrderService,
private val productProcessService: ProductProcessService
private val productProcessService: ProductProcessService,
private val jobOrderCreationService: com.ffii.fpsms.modules.jobOrder.service.JobOrderCreationService
) {

@GetMapping("/getRecordByPage")
@@ -94,14 +95,7 @@ class JobOrderController(

@PostMapping("/manualCreate")
fun manualCreateJobOrder(@Valid @RequestBody request: CreateJobOrderRequest): MessageResponse {
val jo = jobOrderService.createJobOrder(request)
if (jo.id == null) {
throw NoSuchElementException()
}
jobOrderBomMaterialService.createJobOrderBomMaterialsByJoId(jo.id)
jobOrderProcessService.createJobOrderProcessesByJoId(jo.id)
productProcessService.createProductProcessByJobOrderId(jo.id, request.productionPriority)
return jo
return jobOrderCreationService.createJobOrderWithFullFlow(request, request.productionPriority)
}
@GetMapping("/all-lots-hierarchical/{userId}")
fun getAllJobOrderLotsHierarchical(@PathVariable userId: Long): JobOrderLotsHierarchicalResponse {


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt Просмотреть файл

@@ -97,6 +97,8 @@ data class PickOrderLineWithLotsResponse(
val itemCode: String?,
val itemName: String?,
val requiredQty: Double?,
// Total available qty across all inventory lot lines for this item (used by JO pick UI)
val totalAvailableQty: Double? = null,
val uomCode: String?,
val uomDesc: String?,
val status: String?,


+ 11
- 1
src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt Просмотреть файл

@@ -4,7 +4,8 @@ import com.ffii.core.support.AbstractRepository
import com.ffii.fpsms.modules.master.entity.projections.BomCombo
import org.springframework.stereotype.Repository
import java.io.Serializable

import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
@Repository
interface BomRepository : AbstractRepository<Bom, Long> {
fun findAllByDeletedIsFalse(): List<Bom>
@@ -18,4 +19,13 @@ interface BomRepository : AbstractRepository<Bom, Long> {
fun findBomComboByDeletedIsFalse(): List<BomCombo>
fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Bom>
fun findByCodeAndDeletedIsFalse(code: String): Bom?
@Query("""
select b.item.id
from Bom b
where b.deleted = false
and lower(b.description) = 'wip'
and b.item.id in :itemIds
""")
fun findWipItemIds(@Param("itemIds") itemIds: List<Long>): List<Long>
fun findFirstByItemIdAndDescriptionIgnoreCaseAndDeletedIsFalse(itemId: Long, description: String): Bom?
}

+ 34
- 9
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt Просмотреть файл

@@ -81,10 +81,20 @@ open class ItemUomService(
val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty;
val one = BigDecimal.ONE;

val baseQty = purchaseQty.multiply(purchaseUnit.ratioN ?: one).divide(purchaseUnit.ratioD ?: one, 2, RoundingMode.UP)
val stockQty = baseQty.multiply(stockUnit.ratioD ?: one).divide(stockUnit.ratioN ?: one, 2, RoundingMode.UP)
// IMPORTANT:
// Use high precision for intermediate steps, and round to integer only at the end.
// Rounding during intermediate steps (scale=0 + UP) can cause drift (e.g. 2 -> 11 -> 3).
val calcScale = 10

return stockQty;
val baseQty = purchaseQty
.multiply(purchaseUnit.ratioN ?: one)
.divide(purchaseUnit.ratioD ?: one, calcScale, RoundingMode.HALF_UP)

val stockQty = baseQty
.multiply(stockUnit.ratioD ?: one)
.divide(stockUnit.ratioN ?: one, calcScale, RoundingMode.HALF_UP)

return stockQty.setScale(0, RoundingMode.UP)
}

/** Inverse of convertPurchaseQtyToStockQty: stock qty -> purchase qty (for PO-origin StockInLine display). */
@@ -92,9 +102,17 @@ open class ItemUomService(
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty
val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty
val one = BigDecimal.ONE
val baseQty = stockQty.multiply(stockUnit.ratioN ?: one).divide(stockUnit.ratioD ?: one, 2, RoundingMode.UP)
val purchaseQty = baseQty.multiply(purchaseUnit.ratioD ?: one).divide(purchaseUnit.ratioN ?: one, 2, RoundingMode.UP)
return purchaseQty
val calcScale = 10

val baseQty = stockQty
.multiply(stockUnit.ratioN ?: one)
.divide(stockUnit.ratioD ?: one, calcScale, RoundingMode.HALF_UP)

val purchaseQty = baseQty
.multiply(purchaseUnit.ratioD ?: one)
.divide(purchaseUnit.ratioN ?: one, calcScale, RoundingMode.HALF_UP)

return purchaseQty.setScale(0, RoundingMode.UP)
}

open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
@@ -103,10 +121,17 @@ open class ItemUomService(
val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO;
val one = BigDecimal.ONE;

val baseQty = sourceQty.multiply(itemUom?.ratioN ?: one).divide(itemUom?.ratioD ?: one, 2, RoundingMode.UP)
val stockQty = baseQty.multiply(stockUnit.ratioD ?: one).divide(stockUnit.ratioN ?: one, 2, RoundingMode.UP)
val calcScale = 10

val baseQty = sourceQty
.multiply(itemUom.ratioN ?: one)
.divide(itemUom.ratioD ?: one, calcScale, RoundingMode.HALF_UP)

val stockQty = baseQty
.multiply(stockUnit.ratioD ?: one)
.divide(stockUnit.ratioN ?: one, calcScale, RoundingMode.HALF_UP)

return stockQty;
return stockQty.setScale(0, RoundingMode.UP)
}

// See if need to update the response


+ 10
- 20
src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt Просмотреть файл

@@ -59,6 +59,7 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.apache.poi.xssf.usermodel.XSSFPrintSetup
import java.io.ByteArrayOutputStream
import java.sql.Timestamp
import com.ffii.fpsms.modules.jobOrder.service.JobOrderCreationService

@Service
open class ProductionScheduleService(
@@ -76,6 +77,7 @@ open class ProductionScheduleService(
private val inventoryRepository: InventoryRepository,
private val itemUomService: ItemUomService,
private val productProcessService: ProductProcessService,
private val jobOrderCreationService: JobOrderCreationService,
private val settingsService: SettingsService,
) : AbstractBaseEntityService<ProductionSchedule, Long, ProductionScheduleRepository>(
jdbcDao,
@@ -462,16 +464,10 @@ open class ProductionScheduleService(
prodScheduleLineId = prodScheduleLine.id!!
)

// Assuming createJobOrder returns the created Job Order (jo)
val jo = jobOrderService.createJobOrder(joRequest)

val createdJobOrderId = jo.id
?: throw IllegalStateException("Job Order creation failed: returned object ID is null.")

// 7. Create related job order data
jobOrderBomMaterialService.createJobOrderBomMaterialsByJoId(createdJobOrderId)
jobOrderProcessService.createJobOrderProcessesByJoId(createdJobOrderId)
productProcessService.createProductProcessByJobOrderId(createdJobOrderId, prodScheduleLine.itemPriority.toInt())
val jo = jobOrderCreationService.createJobOrderWithFullFlow(
request = joRequest,
productionPriority = prodScheduleLine.itemPriority.toInt()
)

jobOrderCount++
//}
@@ -559,16 +555,10 @@ open class ProductionScheduleService(
prodScheduleLineId = prodScheduleLine.id!!
)

// Assuming createJobOrder returns the created Job Order (jo)
val jo = jobOrderService.createJobOrder(joRequest)
val createdJobOrderId = jo.id
?: throw IllegalStateException("Job Order creation failed: returned object ID is null.")

// 7. Create related job order data
jobOrderBomMaterialService.createJobOrderBomMaterialsByJoId(createdJobOrderId)
jobOrderProcessService.createJobOrderProcessesByJoId(createdJobOrderId)
productProcessService.createProductProcessByJobOrderId(createdJobOrderId, prodScheduleLine.itemPriority.toInt())
jobOrderCreationService.createJobOrderWithFullFlow(
request = joRequest,
productionPriority = prodScheduleLine.itemPriority.toInt()
)

}


+ 22
- 47
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt Просмотреть файл

@@ -3515,21 +3515,7 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A
WHEN sol.status = 'rejected' THEN 'rejected'
WHEN sol.status = 'created' THEN 'pending'
ELSE 'pending'
END as processingStatus,

-- 新增:stockouts 专用字段(不依赖 ll)
sol_any.id as stockOutLineId_any,
sol_any.status as stockOutLineStatus_any,
COALESCE(sol_any.qty, 0) as stockOutLineQty_any,
sol_any.inventoryLotLineId as lotId_any,
il_any.lotNo as lotNo_any,
w_any.name as location_any,
il_any.stockInLineId as stockInLineId_any,
-- 仅当有 lot 时可计算,没 lot 时为 NULL
CASE
WHEN ill_any.id IS NULL THEN NULL
ELSE (COALESCE(ill_any.inQty,0) - COALESCE(ill_any.outQty,0) - COALESCE(ill_any.holdQty,0))
END as availableQty_any
END as processingStatus
FROM fpsmsdb.pick_order po
JOIN fpsmsdb.pick_order_line pol ON pol.poId = po.id AND pol.deleted = false
JOIN fpsmsdb.items i ON i.id = pol.itemId
@@ -3558,17 +3544,6 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A
LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = false
LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId

-- stockouts 用(不依赖 ll)
LEFT JOIN fpsmsdb.stock_out_line sol_any
ON sol_any.pickOrderLineId = pol.id
AND sol_any.deleted = false
LEFT JOIN fpsmsdb.inventory_lot_line ill_any
ON ill_any.id = sol_any.inventoryLotLineId AND ill_any.deleted = false
LEFT JOIN fpsmsdb.inventory_lot il_any
ON il_any.id = ill_any.inventoryLotId AND il_any.deleted = false
LEFT JOIN fpsmsdb.warehouse w_any
ON w_any.id = ill_any.warehouseId

WHERE po.id = :pickOrderId
AND po.deleted = false
ORDER BY
@@ -3629,27 +3604,27 @@ println("DEBUG sol polIds in linesResults: " + linesResults.mapNotNull { it["sto
emptyList()
}

// 新增:stockouts(包括 inventoryLotLineId 为 null 的出库行
// stockouts:包含所有出库行(即使 lot 为空)
val stockouts = lineRows
.filter { it["stockOutLineId_any"] != null }
.distinctBy { row ->
Pair(row["stockOutLineId_any"], row["lotId_any"])
}
.map { row ->
val noLot = (row["lotId_any"] == null)
mapOf(
"id" to row["stockOutLineId_any"],
"status" to row["stockOutLineStatus_any"],
"qty" to row["stockOutLineQty_any"],
"lotId" to row["lotId_any"],
"lotNo" to (row["lotNo_any"] ?: ""),
"location" to (row["location_any"] ?: ""),
"availableQty" to row["availableQty_any"],
"stockInLineId" to row["stockInLineId_any"],
"noLot" to noLot
)
}
// stockouts:包含该 pick order line 的所有出库行(包含 noLot
// 注意:之前用 sol_any 会导致行被重复 N 次(N=该行 stock_out_line 数量),进而前端合并 requiredQty 时被累加。
// 这里直接用同一行上的 sol 字段构建 stockouts,避免笛卡尔积。
val stockouts = lineRows
.filter { it["stockOutLineId"] != null }
.distinctBy { row -> row["stockOutLineId"] }
.map { row ->
val lotId = row["lotId"]
val noLot = (lotId == null)
mapOf(
"id" to row["stockOutLineId"],
"status" to row["stockOutLineStatus"],
"qty" to row["stockOutLineQty"],
"lotId" to lotId,
"lotNo" to (row["lotNo"] ?: ""),
"location" to (row["location"] ?: ""),
"availableQty" to row["availableQty"],
"stockInLineId" to row["stockInLineId"],
"noLot" to noLot
)
}

mapOf(
"id" to lineId,


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt Просмотреть файл

@@ -10,6 +10,7 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe
fun findByProductProcessCode(code: String): Optional<ProductProcess>
fun findByJobOrder_Id(jobOrderId: Long): List<ProductProcess>
fun findByProductProcessCodeStartingWith(prefix: String): List<ProductProcess>
fun findTopByProductProcessCodeStartingWithOrderByProductProcessCodeDesc(prefix: String): ProductProcess?
fun findByIdAndDeletedIsFalse(id: Long): Optional<ProductProcess>
fun findAllByDeletedIsFalse(): List<ProductProcess>
fun findByBom_Id(bomId: Long): List<ProductProcess>

+ 36
- 6
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt Просмотреть файл

@@ -53,6 +53,7 @@ import java.math.RoundingMode
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import org.springframework.dao.DataIntegrityViolationException
@Service
@Transactional
open class ProductProcessService(
@@ -161,11 +162,38 @@ open class ProductProcessService(

private fun generateProductProcessCode(datePrefix: String): String {
val searchPattern = "PP-$datePrefix-"
val existingCodes = productProcessRepository.findByProductProcessCodeStartingWith(searchPattern)
val nextNumber = (existingCodes.size + 1).toString().padStart(3, '0')
val latestCode = productProcessRepository
.findTopByProductProcessCodeStartingWithOrderByProductProcessCodeDesc(searchPattern)
?.productProcessCode

val latestNo = latestCode
?.substringAfterLast("-", missingDelimiterValue = "")
?.toIntOrNull()
?: 0

val nextNumber = (kotlin.math.max(0, latestNo) + 1).toString().padStart(3, '0')
return "$searchPattern$nextNumber"
}

private fun isDuplicateProductProcessCode(e: DataIntegrityViolationException): Boolean {
val msg = (e.rootCause?.message ?: e.message ?: "").lowercase()
return msg.contains("duplicate entry") && msg.contains("productprocesscode")
}

private inline fun <T> retryOnDuplicateProductProcessCode(maxRetries: Int = 8, block: () -> T): T {
var last: DataIntegrityViolationException? = null
repeat(maxRetries) { attempt ->
try {
return block()
} catch (e: DataIntegrityViolationException) {
if (!isDuplicateProductProcessCode(e)) throw e
last = e
println("⚠️ Duplicate productProcessCode, retrying... attempt=${attempt + 1}/$maxRetries")
}
}
throw last ?: IllegalStateException("Duplicate productProcessCode after retries")
}


// 添加:查询工序的所有步骤
open fun getLines(productProcessId: Long): List<ProductProcessLine> {
@@ -896,12 +924,10 @@ open class ProductProcessService(
val bomProcess = bomProcessRepository.findByBomId(jobOrder?.bom?.id ?: 0L)
val item = itemsRepository.findById(bom?.item?.id ?: 0L).orElse(null)
val datePrefix = (LocalDate.now()).format(DateTimeFormatter.ofPattern("yyyyMMdd"))
val productProcessCode = generateProductProcessCode(datePrefix)
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")

val productProcess = ProductProcess().apply {
this.jobOrder = jobOrder
this.productProcessCode = productProcessCode
this.item = item
this.status = ProductProcessStatus.PENDING
this.date = jobOrder?.planEnd?.toLocalDate()
@@ -910,12 +936,16 @@ open class ProductProcessService(
this.productionPriority = productionPriority
}

productProcessRepository.save(productProcess)
val savedProcess = retryOnDuplicateProductProcessCode {
val newCode = generateProductProcessCode(datePrefix)
productProcess.productProcessCode = newCode
productProcessRepository.saveAndFlush(productProcess)
}
bomProcess.forEach { bomProcess ->
val process = processRepository.findById(bomProcess?.process?.id ?: 0L).orElse(null)
val equipment = equipmentRepository.findById(bomProcess?.equipment?.id ?: 0L).orElse(null)
val productProcessLine = ProductProcessLine().apply {
this.productProcess = productProcess
this.productProcess = savedProcess
this.bomProcess = bomProcess
// this.equipment = equipment
this.seqNo = bomProcess.seqNo ?: 0


+ 58
- 82
src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt Просмотреть файл

@@ -17,6 +17,7 @@ import com.ffii.fpsms.modules.stock.web.model.SaveSuggestedPickLotRequest
import com.ffii.fpsms.modules.stock.web.model.SuggestedPickLotForPoRequest
import com.ffii.fpsms.modules.stock.web.model.SuggestedPickLotForPolRequest
import com.ffii.fpsms.modules.stock.web.model.SuggestedPickLotResponse
import com.ffii.fpsms.modules.master.entity.BomRepository
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.time.LocalDate
@@ -47,6 +48,7 @@ open class SuggestedPickLotService(
val stockOutLIneRepository: StockOutLIneRepository,
val inventoryLotLineRepository: InventoryLotLineRepository,
val pickOrderLineRepository: PickOrderLineRepository,
val bomRepository: BomRepository,
val inventoryLotLineService: InventoryLotLineService,
val itemUomService: ItemUomService,
val pickExecutionIssueRepository: PickExecutionIssueRepository, // 添加逗号
@@ -179,7 +181,7 @@ open class SuggestedPickLotService(
val holdQtyInBaseUnits = holdQtyMap[lotLine.id] ?: zero
val availableQtyInSalesUnits = availableQtyInBaseUnits
.minus(holdQtyInBaseUnits)
.divide(ratio, 2, RoundingMode.HALF_UP)
.divide(ratio, 0, RoundingMode.HALF_UP)
println("holdQtyMap[lotLine.id] ?: zero ${holdQtyMap[lotLine.id] ?: zero}")
@@ -265,6 +267,7 @@ open class SuggestedPickLotService(
open fun suggestionForPickOrderLinesForJobOrder(request: SuggestedPickLotForPolRequest): SuggestedPickLotResponse {
val pols = request.pickOrderLines
val itemIds = pols.mapNotNull { it.item?.id }
val wipItemIdSet: Set<Long> = bomRepository.findWipItemIds(itemIds).toSet()
val zero = BigDecimal.ZERO
val one = BigDecimal.ONE
val today = LocalDate.now()
@@ -286,94 +289,67 @@ open class SuggestedPickLotService(
// loop for suggest pick lot line
pols.forEach { line ->
val ratio = one
// === 新增:如果是 DO pick order,则按 DO 的 preferredFloor 过滤 lot ===
// preferredFloor 规则:supplier.code == "P06B" -> 4F,否则 2F
val pickOrder = line.pickOrder
val isDoPickOrder = pickOrder?.type?.value == "do" || pickOrder?.type?.value == "delivery_order"
val doPreferredFloor: String? = if (isDoPickOrder) {
val supplierCode = pickOrder?.deliveryOrder?.supplier?.code
when (supplierCode) {
"P06B" -> "4F"
"P07", "P06D" -> "2F"
else -> null // 其他供应商不限定 2F/4F
}
val itemId = line.item?.id
val isWipItem = itemId != null && wipItemIdSet.contains(itemId)
val lotInfos = availableInventoryLotLines[itemId].orEmpty()
val lotIds = lotInfos.mapNotNull { it.id }
// 仅一次查询,避免后续重复 findById
val lotEntities = if (lotIds.isNotEmpty()) inventoryLotLineRepository.findAllById(lotIds) else emptyList()
val lotEntityById = lotEntities.associateBy { it.id }
// 计算“默认优先仓位”(短期固定,后续可从 Items/配置取)
val preferredWarehouseCode = if (isWipItem) "2F-W200-#A-00" else null
// 分组:先默认仓位,再其他;组内保持 FEFO 顺序(lotInfos 已按 expiry 排过序)
val (preferred, others) = if (!preferredWarehouseCode.isNullOrBlank()) {
lotInfos.partition { li -> lotEntityById[li.id]?.warehouse?.code == preferredWarehouseCode }
} else {
null
Pair(emptyList(), lotInfos)
}
val lotLines = availableInventoryLotLines[line.item?.id].orEmpty()
// 计算 remaining qty
val stockOutLines = stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(line.id!!)
val totalPickedQty = stockOutLines
.filter {
it.status == "completed" ||
it.status == "partially_completed" ||
(it.status == "rejected" && (it.qty ?: zero) > zero)
}
.sumOf { it.qty ?: zero }
val requiredQty = line.qty ?: zero
val remainingQty = requiredQty.minus(totalPickedQty)
var remainingQtyToAllocate = remainingQty
lotLines.forEachIndexed { _, lotLine ->
if (remainingQtyToAllocate <= zero) return@forEachIndexed
// 拿 entity 是因为 projection 的 warehouse 没有 store_id 字段
val inventoryLotLine = lotLine.id?.let { inventoryLotLineService.findById(it).getOrNull() }
?: return@forEachIndexed
val warehouseStoreId = inventoryLotLine.warehouse?.store_id
val warehouseCode = inventoryLotLine.warehouse?.code
// 规则 1:排除指定 warehouse code
if (warehouseCode != null && excludedWarehouseCodes.contains(warehouseCode)) {
return@forEachIndexed
}
// 规则 2:原有逻辑:跳过 3F
val isJobOrder = pickOrder?.type?.value == "jo" || pickOrder?.type?.value == "jo"
if (warehouseStoreId == "3F" && !isJobOrder) {
return@forEachIndexed
}
// 规则 3:新增逻辑:DO 订单必须匹配楼层,否则不建议
// 例:doPreferredFloor=2F,但 lot 在 4F => 跳过
if (doPreferredFloor != null && warehouseStoreId != doPreferredFloor) {
return@forEachIndexed
}
if (doPreferredFloor != null && warehouseStoreId != "1F" && warehouseStoreId != doPreferredFloor) {
return@forEachIndexed
}
val availableQtyInBaseUnits = calculateRemainingQtyForInfo(lotLine)
val holdQtyInBaseUnits = holdQtyMap[lotLine.id] ?: zero
val availableQtyInSalesUnits = availableQtyInBaseUnits
.minus(holdQtyInBaseUnits)
.divide(ratio, 2, RoundingMode.HALF_UP)
if (availableQtyInSalesUnits <= zero) return@forEachIndexed
val assignQtyInSalesUnits = minOf(availableQtyInSalesUnits, remainingQtyToAllocate)
remainingQtyToAllocate = remainingQtyToAllocate.minus(assignQtyInSalesUnits)
val assignQtyInBaseUnits = assignQtyInSalesUnits.multiply(ratio)
holdQtyMap[lotLine.id] = (holdQtyMap[lotLine.id] ?: zero).plus(assignQtyInBaseUnits)
val orderedLotInfos = preferred + others
// 分配时直接用 entity(不再 findById)
var remainingQtyToAllocate = (line.qty ?: BigDecimal.ZERO).minus(
stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(line.id!!)
.filter { it.status == "completed" || it.status == "partially_completed" || (it.status == "rejected" && (it.qty ?: BigDecimal.ZERO) > BigDecimal.ZERO) }
.sumOf { it.qty ?: BigDecimal.ZERO }
)
for (lotInfo in orderedLotInfos) {
if (remainingQtyToAllocate <= BigDecimal.ZERO) break
val lot = lotEntityById[lotInfo.id] ?: continue
val warehouseCode = lot.warehouse?.code
val storeId = lot.warehouse?.store_id
// 既有规则
if (warehouseCode != null && excludedWarehouseCodes.contains(warehouseCode)) continue
val isJobOrder = line.pickOrder?.type?.value == "jo"
if (storeId == "3F" && !isJobOrder) continue
val availableQtyInBase = (lot.inQty ?: BigDecimal.ZERO)
.minus(lot.outQty ?: BigDecimal.ZERO)
.minus(lot.holdQty ?: BigDecimal.ZERO)
val holdQtyInBase = holdQtyMap[lotInfo.id] ?: BigDecimal.ZERO
val availableInSales = availableQtyInBase.minus(holdQtyInBase) // ratio 目前为 1
if (availableInSales <= BigDecimal.ZERO) continue
val assign = availableInSales.min(remainingQtyToAllocate)
remainingQtyToAllocate = remainingQtyToAllocate.minus(assign)
holdQtyMap[lotInfo.id] = (holdQtyMap[lotInfo.id] ?: BigDecimal.ZERO).plus(assign)
suggestedList += SuggestedPickLot().apply {
type = SuggestedPickLotType.PICK_ORDER
suggestedLotLine = inventoryLotLine
suggestedLotLine = lot
pickOrderLine = line
qty = assignQtyInSalesUnits
qty = assign
}
}
// 如果仍有剩余 -> 给 null lot(你举例的场景会走到这里)
if (remainingQtyToAllocate > zero) {
if (remainingQtyToAllocate > BigDecimal.ZERO) {
suggestedList += SuggestedPickLot().apply {
type = SuggestedPickLotType.PICK_ORDER
suggestedLotLine = null


Загрузка…
Отмена
Сохранить