@@ -15,6 +15,7 @@ import com.ffii.fpsms.modules.master.entity.*
import com.ffii.fpsms.modules.master.entity.projections.*
import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.master.web.models.ReleaseProdScheduleLineRequest
import com.ffii.fpsms.modules.master.web.models.ReleaseProdScheduleRequest
import com.ffii.fpsms.modules.master.web.models.SearchProdScheduleRequest
import com.ffii.fpsms.modules.settings.service.SettingsService
import com.ffii.fpsms.modules.stock.entity.Inventory
@@ -31,6 +32,7 @@ import org.springframework.data.domain.PageRequest
import org.springframework.scheduling.support.CronTrigger
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.jdbc.core.BeanPropertyRowMapper
import java.lang.reflect.Type
import java.math.BigDecimal
import java.math.RoundingMode
@@ -43,6 +45,7 @@ import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.jvm.optionals.getOrNull
import kotlin.math.ceil
import kotlin.comparisons.maxOf
@Service
open class ProductionScheduleService(
@@ -360,12 +363,109 @@ open class ProductionScheduleService(
)
}
@Transactional(rollbackFor = [java.lang.Exception::class])
open fun releaseProdSchedule(request: ReleaseProdScheduleRequest): MessageResponse {
// 1. Fetch data safely. Assuming searchProdScheduleLine returns a List<Map<String, Any>>
val data: List<Map<String, Any>> = searchProdScheduleLine(request.id)
if (data.isEmpty()) {
logger.warn("No production schedule lines found for ID: ${request.id}")
return MessageResponse(
id = request.id.toLong(),
message = "No lines to release. Parent ID: ${request.id}",
errorPosition = "data",
name = "",
code = "",
type = ""
)
}
val approver = SecurityUtils.getUser().getOrNull() // Get approver once
for (d in data) {
// 2. Safely cast the ID (Int) and convert it to Long for repository lookup
val prodScheduleLineIdInt = d["id"] as? Int
?: throw IllegalStateException("Production Schedule Line ID is missing or not an Int in search result map.")
val prodScheduleLineId = prodScheduleLineIdInt.toLong()
val prodScheduleLine = productionScheduleLineRepository.findById(prodScheduleLineId).getOrNull()
?: throw NoSuchElementException("Production Schedule Line with ID $prodScheduleLineId not found.")
try {
jobOrderService.jobOrderDetailByPsId(prodScheduleLineId)
} catch (e: NoSuchElementException) {
// 3. Fetch BOM, handling nullability safely
val item = prodScheduleLine.item
?: throw IllegalStateException("Item object is missing for Production Schedule Line $prodScheduleLineId.")
val itemId = item.id
?: throw IllegalStateException("Item ID is missing for Production Schedule Line $prodScheduleLineId.")
val bom = bomService.findByItemId(itemId)
?: throw NoSuchElementException("BOM not found for Item ID $itemId.")
// 4. Update Prod Schedule Line fields
prodScheduleLine.apply {
// Use bom.outputQty, ensuring it's treated as Double for prodQty
prodQty = bom.outputQty?.toDouble()
?: throw IllegalStateException("BOM output quantity is null for Item ID $itemId.")
approverId = approver?.id
}
productionScheduleLineRepository.save(prodScheduleLine)
// 5. Logging (optional but kept)
logger.info("prodScheduleLine.prodQty: ${prodScheduleLine.prodQty}")
logger.info("bom?.outputQty: ${bom.outputQty} ${bom.outputQtyUom}")
logger.info("[releaseProdSchedule] prodScheduleLine.needNoOfJobOrder:" + prodScheduleLine.needNoOfJobOrder)
repeat(prodScheduleLine.needNoOfJobOrder) {
// 6. Create Job Order
val joRequest = CreateJobOrderRequest(
bomId = bom.id, // bom is guaranteed non-null here
reqQty = bom.outputQty, // bom is guaranteed non-null here
approverId = approver?.id,
// CRUCIAL FIX: Use the line ID, not the parent schedule ID
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)
}
}
// No need to fetch latest detail inside the loop
}
return MessageResponse(
id = request.id.toLong(),
message = "Successfully created Job Orders for all production schedule lines (Parent ID: ${request.id}).",
name = "",
code = "",
type = "",
errorPosition = null
// entity = mapOf("prodScheduleLines" to latestDetail?.prodScheduleLines)
)
}
@Transactional(rollbackFor = [java.lang.Exception::class])
open fun releaseProdScheduleLine(request: ReleaseProdScheduleLineRequest): MessageResponse {
val prodScheduleLine = request.id.let { productionScheduleLineRepository.findById(it).getOrNull() } ?: throw NoSuchElementException()
val bom = prodScheduleLine.item.id?.let { bomService.findByItemId(it) }
val approver = SecurityUtils.getUser().getOrNull()
val proportion = BigDecimal.ONE // request.demandQty.divide(bom?.outputQty ?: BigDecimal.ONE, 5, RoundingMode.HALF_UP)
val isSameQty = request.demandQty.equals(prodScheduleLine.prodQty)
// Update Prod Schedule Type
@@ -385,107 +485,71 @@ open class ProductionScheduleService(
}
productionScheduleLineRepository.save(prodScheduleLine)
// Create Job Order
val joRequest = CreateJobOrderRequest(
bomId = bom?.id,
reqQty = request.demandQty,
approverId = approver?.id,
prodScheduleLineId = request.id,
//jobType = null,
)
val jo = jobOrderService.createJobOrder(joRequest)
// Create Job Order Bom Materials
if (bom?.bomMaterials != null) {
// Job Order Bom Material
val jobmRequests = bom.bomMaterials.map { bm ->
val demandQty = bm.qty?.times(proportion) ?: BigDecimal.ZERO
val saleUnit = bm.item?.id?.let { itemUomService.findSalesUnitByItemId(it) }
println("itemId: ${bm.item?.id} | itemNo: ${bm.item?.code} | itemName: ${bm.item?.name} | saleUnit: ${saleUnit?.uom?.udfudesc}")
val jobm = CreateJobOrderBomMaterialRequest(
joId = jo.id,
itemId = bm.item?.id,
reqQty = bm.qty?.times(proportion) ?: BigDecimal.ZERO,
uomId = saleUnit?.uom?.id
)
jobm
}
if (jobmRequests != null) {
jobOrderBomMaterialService.createJobOrderBomMaterials(jobmRequests)
}
// Inventory
/* val inventories = bom.bomMaterials.map { bm ->
val demandQty = bm.qty?.times(proportion) ?: BigDecimal.ZERO
var inventory = bm.item?.id?.let { inventoryRepository.findByItemId(it).getOrNull() }
if (inventory != null) {
inventory.apply {
this.onHoldQty = (this.onHoldQty ?: BigDecimal.ZERO).plus(demandQty)
}
} else {
if (bm.item != null) {
val itemUom = bm.item?.id?.let { itemUomService.findSalesUnitByItemId(it) }
inventory = Inventory().apply {
item = bm.item
onHandQty = BigDecimal.ZERO
unavailableQty = BigDecimal.ZERO
this.onHoldQty = demandQty
uom = itemUom?.uom
status = "unavailable"
}
}
}
inventory
}.groupBy { it?.item } // Group by item
.mapNotNull { (item, invList) ->
if (invList.isNotEmpty()) {
invList[0]?.apply {
onHoldQty = invList.sumOf { it?.onHoldQty ?: BigDecimal.ZERO }
}
} else {
null
}
}
inventoryRepository.saveAllAndFlush(inventories) */
}
// Create Job Order Process
val jopRequests = bom?.bomProcesses?.map { bp ->
CreateJobOrderProcessRequest(
joId = jo.id,
processId = bp.process?.id,
seqNo = bp.seqNo,
//compare prodQty to bom outputQty
logger.info("request.demandQty:" + request.demandQty);
logger.info("prodScheduleLine.prodQty:" + prodScheduleLine.prodQty);
logger.info("bom?.outputQty:" + bom?.outputQty + "" + bom?.outputQtyUom);
logger.info("prodScheduleLine.needNoOfJobOrder:" + prodScheduleLine.needNoOfJobOrder)
repeat(prodScheduleLine.needNoOfJobOrder) {
// Create Job Order
val joRequest = CreateJobOrderRequest(
bomId = bom?.id,
reqQty = bom?.outputQty,
approverId = approver?.id,
prodScheduleLineId = request.id
)
}
if (jopRequests != null) {
jobOrderProcessService.createJobOrderProcesses(jopRequests)
}
val jo = jobOrderService.createJobOrder(joRequest)
productProcessService.createProductProcessByJobOrderId(jo.id!!)
logger.info("jo created:" + jo.id!!)
jobOrderBomMaterialService.createJobOrderBomMaterialsByJoId(jo.id!!)
jobOrderProcessService.createJobOrderProcessesByJoId(jo.id!!)
productProcessService.createProductProcessByJobOrderId(jo.id!!)
}
// Get Latest Data
// val bomMaterials = prodScheduleLine.id?.let { productionScheduleLineRepository.getBomMaterials(it) }
val latestDetail = prodScheduleLine.productionSchedule.id?.let { detailedProdScheduleDetail(it) }
return MessageResponse(
id = request.id,
name = null,
code = jo.code,
//code = "",
//entity = null,
type = null,
message = "Success",
code = "",
entity = mapOf("prodScheduleLines" to latestDetail?.prodScheduleLines),
errorPosition = null
)
}
//====================細排相關 START====================//
open fun searchProdScheduleLine(prodScheduleId: Int): List<Map<String, Any>> {
val args = mapOf(
"prodScheduleId" to prodScheduleId,
)
val sql = """
SELECT
psl.*
FROM production_schedule ps
LEFT JOIN production_schedule_line psl ON psl.prodScheduleId = ps.id
WHERE ps.deleted = FALSE
AND psl.deleted = FALSE
AND psl.prodScheduleId = :prodScheduleId
""";
return jdbcDao.queryForList(sql, args);
}
open fun getDailyProductionCount(assignDate: Int, selectedDate: LocalDateTime): Int {
@@ -507,65 +571,164 @@ open class ProductionScheduleService(
+ " Limit 1"
);
return jdbcDao.queryForInt(sql.toString(), args);
}
open fun generateDetailedScheduleByDay(assignDate: Int, selectedDate: LocalDateTime, produceAt: LocalDateTime) {
val detailedScheduleOutputList = ArrayList<ProductionScheduleRecord>()
open fun getNeedQty(): List<NeedQtyRecord> {
val sql = """
SELECT
i.outputQty,
i.avgQtyLastMonth,
(i.onHandQty -500),
(i.onHandQty -500) * 1.0 / i.avgQtyLastMonth as daysLeft,
i.avgQtyLastMonth * 2 - stockQty as needQty,
i.stockQty,
CASE
WHEN stockQty * 1.0 / i.avgQtyLastMonth <= 1.9 THEN
CEIL((i.avgQtyLastMonth * 1.9 - stockQty) / i.outputQty)
ELSE 0
END AS needNoOfJobOrder,
CASE
WHEN stockQty * 1.0 / i.avgQtyLastMonth <= 1.9 THEN
CEIL((i.avgQtyLastMonth * 1.9 - stockQty) / i.outputQty)
ELSE 0
END AS batchNeed,
markDark + markFloat + markDense + markAS as priority,
i.*
FROM
(SELECT
(SELECT
ROUND(AVG(d.dailyQty) * 1.5)
FROM
(SELECT
SUM(dol.qty) AS dailyQty
FROM
delivery_order_line dol
LEFT JOIN delivery_order do ON dol.deliveryOrderId = do.id
WHERE
dol.itemId = items.id
-- AND MONTH(do.estimatedArrivalDate) = MONTH(DATE_SUB(NOW(), INTERVAL 1 MONTH))
AND do.estimatedArrivalDate >= '2025-12-01' AND do.estimatedArrivalDate < '2025-12-11'
GROUP BY do.estimatedArrivalDate) AS d) AS avgQtyLastMonth,
inventory.onHandQty - 500 AS stockQty,
bom.outputQty,
bom.outputQtyUom,
(SELECT
udfudesc
FROM
delivery_order_line
LEFT JOIN uom_conversion ON delivery_order_line.uomId = uom_conversion.id
WHERE
delivery_order_line.itemId = bom.itemId
LIMIT 1) AS doUom,
items.code,
items.name,
bom.description,
inventory.onHandQty,
bom.itemId,
bom.id AS bomId,
CASE WHEN bom.isDark = 5 THEN 11
ELSE 0 END as markDark,
CASE WHEN bom.isFloat = 3 THEN 11
ELSE 0 END as markFloat,
CASE WHEN bom.isDense = 5 THEN 11
ELSE 0 END as markDense,
CASE WHEN bom.allergicSubstances = 5 THEN 11
ELSE 0 END as markAS,
inventory.id AS inventoryId
FROM
bom
LEFT JOIN items ON bom.itemId = items.id
LEFT JOIN inventory ON items.id = inventory.itemId
WHERE
bom.itemId != 16771) AS i
WHERE 1
and i.avgQtyLastMonth is not null
and i.onHandQty is not null
and (i.onHandQty -500) * 1.0 / i.avgQtyLastMonth <= 1.9
-- and avgQtyLastMonth - (onHandQty - 500) > 0
""".trimIndent()
val rows: List<Map<String, Any>> = jdbcDao.queryForList(sql)
return rows.map { row ->
NeedQtyRecord().apply {
name = row["name"] as String
id = (row["itemId"] as Number).toLong()
needQty = (row["needQty"] as Number).toDouble()
outputQty = (row["outputQty"] as Number).toDouble()
stockQty = (row["stockQty"] as Number).toDouble()
avgQtyLastMonth = (row["avgQtyLastMonth"] as Number).toDouble()
needNoOfJobOrder = (row["needNoOfJobOrder"] as Number).toLong()
daysLeft = (row["daysLeft"] as Number).toDouble()
batchNeed = row["batchNeed"] as Number
// check the produce date have manual changes.
val manualChange = productionScheduleRepository.findByTypeAndProduceAtAndDeletedIsFalse("detailed", produceAt)
if (manualChange != null) {
return;
}
}
}
//increasement available
var idleProductionCount = 22000.0 - getDailyProductionCount(assignDate, selectedDate);
println("idleProductionCount - " + idleProductionCount);
open fun generateDetailedScheduleByDay(assignDate: Int, selectedDate: LocalDateTime, produceAt: LocalDateTime) {
val detailedScheduleOutputList = ArrayList<NeedQtyRecord>()
//increasement available
//var warehouseCap = 22000.0
var machineCap = 10000.0
var needQtyList = getNeedQty()
println("needQtyList - " + needQtyList);
//##### The 22000, 10000 machine cap just means the max warehouse storage qty, not production qty cap
//##### The total production qty of the date is 10000 due to machine cap
//##### search all items with bom to consider need or no need production
val args = mapOf(
"assignDate" to assignDate,
"selectedDate" to selectedDate.format(formatter)
)
val scheduledList: List<ProductionScheduleRecord> = getProductionScheduleRecord(args)
//用缺口決定生產順序
val productionPriorityMap: HashMap<ProductionScheduleRecord, Double> = HashMap();
//TODO: update to use SQL get shop order record for detailed scheduling (real prodQty and openBal)
for (record in scheduledList) {
val productionPriorityMap: HashMap<NeedQtyRecord, Double> = HashMap();
for (record in needQtyList) {
println("Object - " + record.toString());
val tempRecord = record;
tempRecord.prodQty = tempRecord.prodQty * 2;
val difference =
-(tempRecord.targetMinStock + tempRecord.prodQty - tempRecord.estCloseBal) /*TODO: this should be real close bal*/;
productionPriorityMap.put(tempRecord, difference)
//val tempRecord = record;
//val difference =
// -tempRecord.outputQty
val isDark = maxOf(record.isDark.toDouble(), 0.0)
val isFloat = maxOf(record.isFloat.toDouble(), 0.0)
val isDense = maxOf(record.isDense.toDouble(), 0.0)
val allergicSubstances = maxOf(record.allergicSubstances.toDouble(), 0.0)
val priority = isDark + isFloat + isDense + allergicSubstances
record.itemPriority = priority
productionPriorityMap.put(record, priority)
}
//##### all qty should be 包, FG qty/bom outputQty
//sort by difference
val sortedEntries = productionPriorityMap.entries.sortedBy { it.value }
var accProdCount = 0.0;
var fgCount = 0L;
for ((updatedScheduleRecord, totalDifference) in sortedEntries) {
for ((updatedScheduleRecord, priority ) in sortedEntries) {
//match id with rough schedule record to create new record
val targetRecord = scheduledList.find { it.item.id == updatedScheduleRecord.item.id }
val prodDifference = updatedScheduleRecord.prodQty - (targetRecord?.prodQty ?: 0.0)
if (idleProductionCount - prodDifference >= 0) {
val targetRecord = needQtyList.find { it.id == updatedScheduleRecord.id }
//??? this should be the bom output Qty * no. of job order needed
val prodDifference = updatedScheduleRecord.outputQty - (targetRecord?.outputQty ?: 0.0)
updatedScheduleRecord.itemPriority = priority;
if (machineCap - prodDifference >= 0) {
//have enough quoter for adjustment
idleProductionCount -= prodDifference;
machineCap -= prodDifference;
detailedScheduleOutputList.add(updatedScheduleRecord)
accProdCount += updatedScheduleRecord.prodQty
accProdCount += updatedScheduleRecord.output Qty
fgCount++
} else {
println("[INFO] item " + updatedScheduleRecord.name + " have bee skipped");
}
}
// Sort detailedScheduleOutputList by item priority
val sortedOutputList = detailedScheduleOutputList.sortedBy { it.weightingRef }.toMutableList()
// Sort detailedScheduleOutputList by item priority: needQty
val sortedOutputList = detailedScheduleOutputList.sortedBy { it.needQty }.toMutableList()
// Update itemPriority in the sorted list
var tempPriority = 1L
@@ -576,9 +739,9 @@ open class ProductionScheduleService(
saveDetailedScheduleOutput(sortedOutputList, accProdCount, fgCount, produceAt)
}
open fun saveDetailedScheduleOutput(
sortedEntries: List<ProductionSchedule Record>,
sortedEntries: List<NeedQty Record>,
accProdCount: Double,
fgCount: Long,
produceAt: LocalDateTime,
@@ -593,28 +756,37 @@ open class ProductionScheduleService(
tempObj.id = saveProductionScheduleToDatabase(tempObj);
for (detailedScheduleRecord in sortedEntries) {
saveDetailedScheduleLineToDatabase(tempObj.id ?: -1, detailedScheduleRecord)
if(detailedScheduleRecord.batchNeed.toInt() > 0)
saveDetailedScheduleLineToDatabase(tempObj.id ?: -1, detailedScheduleRecord)
}
}
private fun saveDetailedScheduleLineToDatabase(parentId: Long, detailedScheduleObj: ProductionSchedule Record) {
private fun saveDetailedScheduleLineToDatabase(parentId: Long, detailedScheduleObj: NeedQty Record) {
try {
val prodSchedule = productionScheduleRepository.findById(parentId).get()
val item = detailedScheduleObj.item.i d?.let { itemService.findById(it) } ?: Items()
val item = detailedScheduleObj.id?.let { itemService.findById(it) } ?: Items()
var savedItem = ProductionScheduleLine()
print("###detailedScheduleObj.stockQty:" + detailedScheduleObj.stockQty)
savedItem.id = -1;
// savedItem.prodScheduleId = parentId;
savedItem.productionSchedule = prodSchedule;
savedItem.item = item;
savedItem.lastMonthAvgSales = detailedScheduleObj.lastMonthAvgSales ?: 0.0;
savedItem.lastMonthAvgSales = detailedScheduleObj.avgQtyLastMonth ?: 0.0;
savedItem.refScheduleId = detailedScheduleObj.id;
savedItem.approverId = null;
savedItem.estCloseBal = detailedScheduleObj.estCloseBal ;
savedItem.prodQty = detailedScheduleObj.prod Qty
savedItem.estCloseBal = detailedScheduleObj.outputQty ;
savedItem.prodQty = detailedScheduleObj.output Qty
savedItem.type = "detailed"
savedItem.assignDate = detailedScheduleObj.assignDate;
savedItem.weightingRef = detailedScheduleObj.weightingRef
savedItem.itemPriority = detailedScheduleObj.itemPriority
savedItem.assignDate = LocalDateTime.now().dayOfMonth.toLong();
savedItem.weightingRef = detailedScheduleObj.needQty
savedItem.itemPriority = detailedScheduleObj.needQty.toLong()
savedItem.outputQty = detailedScheduleObj.outputQty
savedItem.avgQtyLastMonth = detailedScheduleObj.avgQtyLastMonth
savedItem.stockQty = detailedScheduleObj.stockQty
savedItem.daysLeft = detailedScheduleObj.daysLeft
savedItem.batchNeed = detailedScheduleObj.batchNeed.toInt()
savedItem.needNoOfJobOrder = detailedScheduleObj.needNoOfJobOrder.toInt()
productionScheduleLineRepository.saveAndFlush(savedItem)
} catch (e: Exception) {
@@ -622,6 +794,38 @@ open class ProductionScheduleService(
}
}
//====================細排相關 END====================//
open class NeedQtyRecord {
//SQL record in general with item name
open var name: String = "" //item name
open var avgQtyLastMonth: Double = 0.0
open var needNoOfJobOrder: Number = 0
open var id: Long = 0
open var needQty: Double = 0.0
open var outputQty: Double = 0.0
open var itemPriority: Number = 0
open var isDark: Number = 0
open var isFloat: Number = 0
open var isDense: Number = 0
open var allergicSubstances: Number = 0
open var stockQty: Double = 0.0
open var daysLeft: Double = 0.0
open var batchNeed: Number = 0
override fun toString(): String {
return "NeedQtyRecord(name=${name}," +
" avgQtyLastMonth=$avgQtyLastMonth" +
" stockQty=$stockQty" +
" itemId=$id" +
" needNoOfJobOrder=$needNoOfJobOrder" +
" outputQty=$outputQty" +
" needQty=$needQty" +
" itemPriority=$itemPriority"+
" isDark=$isDark" +
" isFloat=$isFloat" +
" isDense=$isDense" +
" allergicSubstances=$allergicSubstances"
}
}
open class ProductionScheduleRecord : ProductionScheduleLine() {
//SQL record in general with item name