| @@ -0,0 +1,366 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.service | |||||
| import com.ffii.core.support.JdbcDao | |||||
| import org.springframework.stereotype.Service | |||||
| import java.time.DayOfWeek | |||||
| data class PsPlantRules( | |||||
| val redLineDays: Double, | |||||
| val safetyStockDays: Double, | |||||
| val prepareMoreRedLineDays: Double, | |||||
| val prepareMoreSafetyStockDays: Double, | |||||
| val prepareMoreDays: Set<DayOfWeek>, | |||||
| val noProductionDays: Set<DayOfWeek>, | |||||
| val categoryProduceDays: Map<String, Set<DayOfWeek>>, | |||||
| val categorySkipPrepareMore: Map<String, Boolean>, | |||||
| ) | |||||
| data class ItemPsRule( | |||||
| val itemCode: String, | |||||
| val redLineDays: Double?, | |||||
| val safetyStockDays: Double?, | |||||
| val produceDays: Set<DayOfWeek>?, | |||||
| val skipPrepareMore: Boolean?, | |||||
| ) | |||||
| data class SavePsPlantRulesRequest( | |||||
| val redLineDays: Double = 1.9, | |||||
| val safetyStockDays: Double = 2.0, | |||||
| val prepareMoreRedLineDays: Double = 2.7, | |||||
| val prepareMoreSafetyStockDays: Double = 3.1, | |||||
| val prepareMoreDays: List<String> = listOf("FRI", "SAT"), | |||||
| val noProductionDays: List<String> = listOf("SUN"), | |||||
| val coffeeDays: List<String> = listOf("MON", "WED", "FRI"), | |||||
| val teaDays: List<String> = listOf("TUE", "THU", "SAT"), | |||||
| val lemonDays: List<String> = listOf("MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"), | |||||
| val coffeeSkipPrepareMore: Boolean = false, | |||||
| val teaSkipPrepareMore: Boolean = false, | |||||
| val lemonSkipPrepareMore: Boolean = true, | |||||
| ) | |||||
| data class ItemPsRuleSqlParams( | |||||
| val itemCode: String, | |||||
| val redLineDays: Double?, | |||||
| val safetyStockDays: Double?, | |||||
| val produceDays: String?, | |||||
| val skipPrepareMore: Int?, | |||||
| ) | |||||
| data class SaveItemPsRuleRequest( | |||||
| val itemCode: String = "", | |||||
| val redLineDays: Double? = null, | |||||
| val safetyStockDays: Double? = null, | |||||
| val useCustomProduceDays: Boolean = false, | |||||
| val produceDays: List<String> = emptyList(), | |||||
| val skipPrepareMore: Boolean? = null, | |||||
| ) | |||||
| @Service | |||||
| open class PsScheduleRuleService( | |||||
| private val jdbcDao: JdbcDao, | |||||
| ) { | |||||
| fun loadPlantRules(): PsPlantRules { | |||||
| return PsPlantRules( | |||||
| redLineDays = readDouble(RED_LINE, 1.9), | |||||
| safetyStockDays = readDouble(SAFETY, 2.0), | |||||
| prepareMoreRedLineDays = readDouble(PREP_RED, 2.7), | |||||
| prepareMoreSafetyStockDays = readDouble(PREP_SAFETY, 3.1), | |||||
| prepareMoreDays = readDays(PREP_DAYS, setOf(DayOfWeek.FRIDAY, DayOfWeek.SATURDAY)), | |||||
| noProductionDays = readDays(NO_PROD_DAYS, setOf(DayOfWeek.SUNDAY)), | |||||
| categoryProduceDays = mapOf( | |||||
| "coffee" to readDays(COFFEE_DAYS, setOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY)), | |||||
| "tea" to readDays(TEA_DAYS, setOf(DayOfWeek.TUESDAY, DayOfWeek.THURSDAY, DayOfWeek.SATURDAY)), | |||||
| "lemon" to readDays(LEMON_DAYS, ALL_DAYS), | |||||
| ), | |||||
| categorySkipPrepareMore = mapOf( | |||||
| "coffee" to readBoolean(COFFEE_SKIP, false), | |||||
| "tea" to readBoolean(TEA_SKIP, false), | |||||
| "lemon" to readBoolean(LEMON_SKIP, true), | |||||
| ), | |||||
| ) | |||||
| } | |||||
| fun loadItemRules(): Map<String, ItemPsRule> { | |||||
| val rows = jdbcDao.queryForList( | |||||
| """ | |||||
| SELECT itemCode, redLineDays, safetyStockDays, produceDays, skipPrepareMore | |||||
| FROM item_ps_rule | |||||
| """.trimIndent(), | |||||
| emptyMap<String, Any>(), | |||||
| ) | |||||
| return rows.mapNotNull { row -> | |||||
| val code = row["itemCode"]?.toString()?.trim().orEmpty() | |||||
| if (code.isEmpty()) return@mapNotNull null | |||||
| val produceRaw = row["produceDays"]?.toString() | |||||
| ItemPsRule( | |||||
| itemCode = code, | |||||
| redLineDays = (row["redLineDays"] as? Number)?.toDouble(), | |||||
| safetyStockDays = (row["safetyStockDays"] as? Number)?.toDouble(), | |||||
| produceDays = if (produceRaw.isNullOrBlank()) null else parseDays(produceRaw), | |||||
| skipPrepareMore = readNullableBoolean(row["skipPrepareMore"]), | |||||
| ) | |||||
| }.associateBy { it.itemCode } | |||||
| } | |||||
| fun plantRulesResponse(): Map<String, Any> { | |||||
| val rules = loadPlantRules() | |||||
| val items = loadItemRules().values.map { rule -> | |||||
| mapOf( | |||||
| "itemCode" to rule.itemCode, | |||||
| "redLineDays" to rule.redLineDays, | |||||
| "safetyStockDays" to rule.safetyStockDays, | |||||
| "produceDays" to (rule.produceDays?.let { formatDays(it).split(",").filter { day -> day.isNotEmpty() } } ?: emptyList<String>()), | |||||
| "useCustomProduceDays" to (rule.produceDays != null), | |||||
| "skipPrepareMore" to rule.skipPrepareMore, | |||||
| ) | |||||
| } | |||||
| return mapOf( | |||||
| "redLineDays" to rules.redLineDays, | |||||
| "safetyStockDays" to rules.safetyStockDays, | |||||
| "prepareMoreRedLineDays" to rules.prepareMoreRedLineDays, | |||||
| "prepareMoreSafetyStockDays" to rules.prepareMoreSafetyStockDays, | |||||
| "prepareMoreDays" to formatDays(rules.prepareMoreDays).split(",").filter { it.isNotEmpty() }, | |||||
| "noProductionDays" to formatDays(rules.noProductionDays).split(",").filter { it.isNotEmpty() }, | |||||
| "coffeeDays" to formatDays(rules.categoryProduceDays["coffee"].orEmpty()).split(",").filter { it.isNotEmpty() }, | |||||
| "teaDays" to formatDays(rules.categoryProduceDays["tea"].orEmpty()).split(",").filter { it.isNotEmpty() }, | |||||
| "lemonDays" to formatDays(rules.categoryProduceDays["lemon"].orEmpty()).split(",").filter { it.isNotEmpty() }, | |||||
| "coffeeSkipPrepareMore" to (rules.categorySkipPrepareMore["coffee"] == true), | |||||
| "teaSkipPrepareMore" to (rules.categorySkipPrepareMore["tea"] == true), | |||||
| "lemonSkipPrepareMore" to (rules.categorySkipPrepareMore["lemon"] == true), | |||||
| "items" to items, | |||||
| ) | |||||
| } | |||||
| fun savePlantRules(request: SavePsPlantRulesRequest) { | |||||
| requireDays(request.redLineDays, request.safetyStockDays, "一般日") | |||||
| requireDays(request.prepareMoreRedLineDays, request.prepareMoreSafetyStockDays, "多備日") | |||||
| val coffee = requireProduceDays(request.coffeeDays, "類別 A") | |||||
| val tea = requireProduceDays(request.teaDays, "類別 B") | |||||
| val lemon = requireProduceDays(request.lemonDays, "類別 C") | |||||
| val prepareMore = parseDaysStrict(request.prepareMoreDays, "多備星期") | |||||
| val noProduction = parseDaysStrict(request.noProductionDays, "不生產星期") | |||||
| upsert(RED_LINE, formatNumber(request.redLineDays), "float") | |||||
| upsert(SAFETY, formatNumber(request.safetyStockDays), "float") | |||||
| upsert(PREP_RED, formatNumber(request.prepareMoreRedLineDays), "float") | |||||
| upsert(PREP_SAFETY, formatNumber(request.prepareMoreSafetyStockDays), "float") | |||||
| upsert(PREP_DAYS, formatDays(prepareMore), "string") | |||||
| upsert(NO_PROD_DAYS, formatDays(noProduction), "string") | |||||
| upsert(COFFEE_DAYS, formatDays(coffee), "string") | |||||
| upsert(TEA_DAYS, formatDays(tea), "string") | |||||
| upsert(LEMON_DAYS, formatDays(lemon), "string") | |||||
| upsert(COFFEE_SKIP, request.coffeeSkipPrepareMore.toString(), "boolean") | |||||
| upsert(TEA_SKIP, request.teaSkipPrepareMore.toString(), "boolean") | |||||
| upsert(LEMON_SKIP, request.lemonSkipPrepareMore.toString(), "boolean") | |||||
| } | |||||
| fun saveItemRule(request: SaveItemPsRuleRequest) { | |||||
| val itemCode = request.itemCode.trim() | |||||
| if (itemCode.isEmpty()) throw IllegalArgumentException("itemCode required") | |||||
| if (request.redLineDays != null || request.safetyStockDays != null) { | |||||
| val red = request.redLineDays | |||||
| val safety = request.safetyStockDays | |||||
| if (red != null && safety != null) { | |||||
| requireDays(red, safety, itemCode) | |||||
| } else { | |||||
| red?.let { requirePositive(it, "$itemCode 觸發日") } | |||||
| safety?.let { requirePositive(it, "$itemCode 補貨日") } | |||||
| } | |||||
| } | |||||
| val produceDays = if (request.useCustomProduceDays) { | |||||
| requireProduceDays(request.produceDays, itemCode) | |||||
| } else { | |||||
| null | |||||
| } | |||||
| val clear = request.redLineDays == null && | |||||
| request.safetyStockDays == null && | |||||
| produceDays == null && | |||||
| request.skipPrepareMore == null | |||||
| if (clear) { | |||||
| jdbcDao.executeUpdate( | |||||
| "DELETE FROM item_ps_rule WHERE itemCode = :itemCode", | |||||
| mapOf("itemCode" to itemCode), | |||||
| ) | |||||
| return | |||||
| } | |||||
| val args = ItemPsRuleSqlParams( | |||||
| itemCode = itemCode, | |||||
| redLineDays = request.redLineDays, | |||||
| safetyStockDays = request.safetyStockDays, | |||||
| produceDays = produceDays?.let { formatDays(it) }, | |||||
| skipPrepareMore = request.skipPrepareMore?.let { if (it) 1 else 0 }, | |||||
| ) | |||||
| val updated = jdbcDao.executeUpdate( | |||||
| """ | |||||
| UPDATE item_ps_rule | |||||
| SET redLineDays = :redLineDays, | |||||
| safetyStockDays = :safetyStockDays, | |||||
| produceDays = :produceDays, | |||||
| skipPrepareMore = :skipPrepareMore | |||||
| WHERE itemCode = :itemCode | |||||
| """.trimIndent(), | |||||
| args, | |||||
| ) | |||||
| if (updated == 0) { | |||||
| jdbcDao.executeUpdate( | |||||
| """ | |||||
| INSERT INTO item_ps_rule (itemCode, redLineDays, safetyStockDays, produceDays, skipPrepareMore) | |||||
| VALUES (:itemCode, :redLineDays, :safetyStockDays, :produceDays, :skipPrepareMore) | |||||
| """.trimIndent(), | |||||
| args, | |||||
| ) | |||||
| } | |||||
| } | |||||
| private fun upsert(name: String, value: String, type: String) { | |||||
| val args = mapOf("name" to name, "value" to value, "type" to type) | |||||
| val updated = jdbcDao.executeUpdate( | |||||
| "UPDATE settings SET value = :value, type = :type, category = 'PS' WHERE name = :name", | |||||
| args, | |||||
| ) | |||||
| if (updated == 0) { | |||||
| jdbcDao.executeUpdate( | |||||
| "INSERT INTO settings (name, value, category, type) VALUES (:name, :value, 'PS', :type)", | |||||
| args, | |||||
| ) | |||||
| } | |||||
| } | |||||
| private fun readDouble(name: String, fallback: Double): Double { | |||||
| val raw = jdbcDao.queryForString( | |||||
| "SELECT value FROM settings WHERE name = :name LIMIT 1", | |||||
| mapOf("name" to name), | |||||
| ) | |||||
| return raw.toDoubleOrNull() ?: fallback | |||||
| } | |||||
| private fun readBoolean(name: String, fallback: Boolean): Boolean { | |||||
| val raw = jdbcDao.queryForString( | |||||
| "SELECT value FROM settings WHERE name = :name LIMIT 1", | |||||
| mapOf("name" to name), | |||||
| ).trim() | |||||
| return when { | |||||
| raw.equals("true", ignoreCase = true) -> true | |||||
| raw.equals("false", ignoreCase = true) -> false | |||||
| else -> fallback | |||||
| } | |||||
| } | |||||
| private fun readDays(name: String, fallback: Set<DayOfWeek>): Set<DayOfWeek> { | |||||
| val raw = jdbcDao.queryForString( | |||||
| "SELECT value FROM settings WHERE name = :name LIMIT 1", | |||||
| mapOf("name" to name), | |||||
| ) | |||||
| if (raw.isBlank()) return fallback | |||||
| val parsed = parseDays(raw) | |||||
| return parsed.ifEmpty { fallback } | |||||
| } | |||||
| private fun requireDays(redLine: Double, safety: Double, label: String) { | |||||
| requirePositive(redLine, "$label 觸發日") | |||||
| requirePositive(safety, "$label 補貨日") | |||||
| if (redLine > safety) { | |||||
| throw IllegalArgumentException("$label:觸發日不可大於補貨日") | |||||
| } | |||||
| } | |||||
| private fun requirePositive(value: Double, label: String) { | |||||
| if (!value.isFinite() || value <= 0.0) { | |||||
| throw IllegalArgumentException("$label 必須大於 0") | |||||
| } | |||||
| } | |||||
| private fun requireProduceDays(tokens: List<String>, label: String): Set<DayOfWeek> { | |||||
| val days = parseDaysStrict(tokens, label) | |||||
| if (days.isEmpty()) throw IllegalArgumentException("$label 至少要選一天可生產") | |||||
| return days | |||||
| } | |||||
| private fun parseDaysStrict(tokens: List<String>, label: String): Set<DayOfWeek> { | |||||
| val unknown = tokens.map { it.trim().uppercase() }.filter { it.isNotEmpty() && it !in DAY_TOKENS } | |||||
| if (unknown.isNotEmpty()) { | |||||
| throw IllegalArgumentException("$label 有無法辨識的星期: ${unknown.joinToString(",")}") | |||||
| } | |||||
| return parseDays(tokens.joinToString(",")) | |||||
| } | |||||
| companion object { | |||||
| const val RED_LINE = "ps.redLineDays" | |||||
| const val SAFETY = "ps.safetyStockDays" | |||||
| const val PREP_RED = "ps.prepareMore.redLineDays" | |||||
| const val PREP_SAFETY = "ps.prepareMore.safetyStockDays" | |||||
| const val PREP_DAYS = "ps.prepareMore.days" | |||||
| const val NO_PROD_DAYS = "ps.noProduction.days" | |||||
| const val COFFEE_DAYS = "ps.category.coffee.days" | |||||
| const val TEA_DAYS = "ps.category.tea.days" | |||||
| const val LEMON_DAYS = "ps.category.lemon.days" | |||||
| const val COFFEE_SKIP = "ps.category.coffee.skipPrepareMore" | |||||
| const val TEA_SKIP = "ps.category.tea.skipPrepareMore" | |||||
| const val LEMON_SKIP = "ps.category.lemon.skipPrepareMore" | |||||
| val ALL_DAYS: Set<DayOfWeek> = setOf( | |||||
| DayOfWeek.MONDAY, | |||||
| DayOfWeek.TUESDAY, | |||||
| DayOfWeek.WEDNESDAY, | |||||
| DayOfWeek.THURSDAY, | |||||
| DayOfWeek.FRIDAY, | |||||
| DayOfWeek.SATURDAY, | |||||
| DayOfWeek.SUNDAY, | |||||
| ) | |||||
| private val DAY_ORDER = listOf( | |||||
| DayOfWeek.MONDAY, | |||||
| DayOfWeek.TUESDAY, | |||||
| DayOfWeek.WEDNESDAY, | |||||
| DayOfWeek.THURSDAY, | |||||
| DayOfWeek.FRIDAY, | |||||
| DayOfWeek.SATURDAY, | |||||
| DayOfWeek.SUNDAY, | |||||
| ) | |||||
| private val DAY_TOKENS = mapOf( | |||||
| "MON" to DayOfWeek.MONDAY, | |||||
| "TUE" to DayOfWeek.TUESDAY, | |||||
| "WED" to DayOfWeek.WEDNESDAY, | |||||
| "THU" to DayOfWeek.THURSDAY, | |||||
| "FRI" to DayOfWeek.FRIDAY, | |||||
| "SAT" to DayOfWeek.SATURDAY, | |||||
| "SUN" to DayOfWeek.SUNDAY, | |||||
| ) | |||||
| fun parseDays(raw: String?): Set<DayOfWeek> { | |||||
| if (raw.isNullOrBlank()) return emptySet() | |||||
| return raw.split(",") | |||||
| .map { it.trim().uppercase() } | |||||
| .mapNotNull { DAY_TOKENS[it] } | |||||
| .toSet() | |||||
| } | |||||
| fun formatDays(days: Set<DayOfWeek>): String { | |||||
| return DAY_ORDER.filter { it in days }.joinToString(",") { day -> | |||||
| DAY_TOKENS.entries.first { it.value == day }.key | |||||
| } | |||||
| } | |||||
| fun formatNumber(value: Double): String { | |||||
| return if (value % 1.0 == 0.0) value.toInt().toString() else value.toString() | |||||
| } | |||||
| private fun readNullableBoolean(value: Any?): Boolean? { | |||||
| return when (value) { | |||||
| null -> null | |||||
| is Boolean -> value | |||||
| is Number -> when (value.toInt()) { | |||||
| 1 -> true | |||||
| 0 -> false | |||||
| else -> null | |||||
| } | |||||
| else -> when (value.toString().trim().lowercase()) { | |||||
| "1", "true" -> true | |||||
| "0", "false" -> false | |||||
| else -> null | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -1,9 +1,9 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.web | package com.ffii.fpsms.modules.jobOrder.web | ||||
| import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService | |||||
| import com.ffii.fpsms.modules.jobOrder.service.PSService | import com.ffii.fpsms.modules.jobOrder.service.PSService | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest | |||||
| import com.ffii.fpsms.modules.jobOrder.service.PsScheduleRuleService | |||||
| import com.ffii.fpsms.modules.jobOrder.service.SaveItemPsRuleRequest | |||||
| import com.ffii.fpsms.modules.jobOrder.service.SavePsPlantRulesRequest | |||||
| import org.springframework.http.HttpHeaders | import org.springframework.http.HttpHeaders | ||||
| import org.springframework.http.MediaType | import org.springframework.http.MediaType | ||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| @@ -15,6 +15,7 @@ import org.springframework.http.ResponseEntity | |||||
| @RequestMapping("/ps") | @RequestMapping("/ps") | ||||
| class PSController( | class PSController( | ||||
| private val psService: PSService, | private val psService: PSService, | ||||
| private val psScheduleRuleService: PsScheduleRuleService, | |||||
| ) { | ) { | ||||
| /** | /** | ||||
| * Bulk import fake on-hand overrides from Excel. | * Bulk import fake on-hand overrides from Excel. | ||||
| @@ -87,6 +88,32 @@ class PSController( | |||||
| return ResponseEntity.ok(mapOf("ok" to true, "updated" to updated)) | return ResponseEntity.ok(mapOf("ok" to true, "updated" to updated)) | ||||
| } | } | ||||
| /** Plant defaults and per-item overrides for 可用日 / weekday production rules. */ | |||||
| @GetMapping("/schedule-rules") | |||||
| fun getScheduleRules(): ResponseEntity<Map<String, Any>> { | |||||
| return ResponseEntity.ok(psScheduleRuleService.plantRulesResponse()) | |||||
| } | |||||
| @PostMapping("/schedule-rules") | |||||
| fun saveScheduleRules(@RequestBody body: SavePsPlantRulesRequest): ResponseEntity<Map<String, Any>> { | |||||
| return try { | |||||
| psScheduleRuleService.savePlantRules(body) | |||||
| ResponseEntity.ok(mapOf("ok" to true)) | |||||
| } catch (e: IllegalArgumentException) { | |||||
| ResponseEntity.badRequest().body(mapOf("error" to (e.message ?: "Invalid rules"))) | |||||
| } | |||||
| } | |||||
| @PostMapping("/setItemPsRule") | |||||
| fun setItemPsRule(@RequestBody body: SaveItemPsRuleRequest): ResponseEntity<Map<String, Any>> { | |||||
| return try { | |||||
| psScheduleRuleService.saveItemRule(body) | |||||
| ResponseEntity.ok(mapOf("ok" to true, "itemCode" to body.itemCode.trim())) | |||||
| } catch (e: IllegalArgumentException) { | |||||
| ResponseEntity.badRequest().body(mapOf("error" to (e.message ?: "Invalid item rule"))) | |||||
| } | |||||
| } | |||||
| /** Set or clear coffee/tea/lemon for an item. systemType: coffee | tea | lemon, enabled: boolean. */ | /** Set or clear coffee/tea/lemon for an item. systemType: coffee | tea | lemon, enabled: boolean. */ | ||||
| @PostMapping("/setCoffeeOrTea") | @PostMapping("/setCoffeeOrTea") | ||||
| fun setCoffeeOrTea(@RequestBody body: Map<String, Any>): ResponseEntity<Map<String, Any>> { | fun setCoffeeOrTea(@RequestBody body: Map<String, Any>): ResponseEntity<Map<String, Any>> { | ||||
| @@ -9,6 +9,8 @@ import java.io.Serializable | |||||
| interface ItemUomRespository : AbstractRepository<ItemUom, Long> { | interface ItemUomRespository : AbstractRepository<ItemUom, Long> { | ||||
| fun findAllByItemIdAndDeletedIsFalse(itemId: Serializable): List<ItemUom> | fun findAllByItemIdAndDeletedIsFalse(itemId: Serializable): List<ItemUom> | ||||
| fun findAllByItemId(itemId: Long): List<ItemUom> | |||||
| /** All active item_uom rows with uom_conversion (single round-trip for master-data scans). */ | /** All active item_uom rows with uom_conversion (single round-trip for master-data scans). */ | ||||
| @Query( | @Query( | ||||
| """ | """ | ||||
| @@ -30,6 +30,28 @@ open class ItemUomService( | |||||
| return itemUomRespository.findAllByItemIdAndDeletedIsFalse(itemId); | return itemUomRespository.findAllByItemIdAndDeletedIsFalse(itemId); | ||||
| } | } | ||||
| /** Active and inactive unit rows for one item. No price or currency. */ | |||||
| @Transactional(readOnly = true) | |||||
| open fun listItemUomConversions(itemId: Long): List<ItemUomConversionRow> { | |||||
| return itemUomRespository.findAllByItemId(itemId) | |||||
| .sortedWith(compareBy<ItemUom> { it.deleted == true }.thenBy { it.id ?: 0L }) | |||||
| .map { iu -> | |||||
| ItemUomConversionRow( | |||||
| id = iu.id ?: 0L, | |||||
| unitCode = iu.uom?.code.orEmpty(), | |||||
| unitDesc = iu.uom?.udfudesc.orEmpty(), | |||||
| ratioN = iu.ratioN ?: BigDecimal.ONE, | |||||
| ratioD = iu.ratioD ?: BigDecimal.ONE, | |||||
| baseUnit = iu.baseUnit == true, | |||||
| stockUnit = iu.stockUnit == true, | |||||
| pickingUnit = iu.pickingUnit == true, | |||||
| salesUnit = iu.salesUnit == true, | |||||
| purchaseUnit = iu.purchaseUnit == true, | |||||
| inactive = iu.deleted == true, | |||||
| ) | |||||
| } | |||||
| } | |||||
| open fun findById(id: Long): ItemUom? { | open fun findById(id: Long): ItemUom? { | ||||
| return itemUomRespository.findByIdAndDeletedIsFalse(id); | return itemUomRespository.findByIdAndDeletedIsFalse(id); | ||||
| } | } | ||||
| @@ -60,6 +60,7 @@ import org.apache.poi.xssf.usermodel.XSSFPrintSetup | |||||
| import java.io.ByteArrayOutputStream | import java.io.ByteArrayOutputStream | ||||
| import java.sql.Timestamp | import java.sql.Timestamp | ||||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderCreationService | import com.ffii.fpsms.modules.jobOrder.service.JobOrderCreationService | ||||
| import com.ffii.fpsms.modules.jobOrder.service.PsScheduleRuleService | |||||
| @Service | @Service | ||||
| open class ProductionScheduleService( | open class ProductionScheduleService( | ||||
| @@ -79,6 +80,7 @@ open class ProductionScheduleService( | |||||
| private val productProcessService: ProductProcessService, | private val productProcessService: ProductProcessService, | ||||
| private val jobOrderCreationService: JobOrderCreationService, | private val jobOrderCreationService: JobOrderCreationService, | ||||
| private val settingsService: SettingsService, | private val settingsService: SettingsService, | ||||
| private val psScheduleRuleService: PsScheduleRuleService, | |||||
| ) : AbstractBaseEntityService<ProductionSchedule, Long, ProductionScheduleRepository>( | ) : AbstractBaseEntityService<ProductionSchedule, Long, ProductionScheduleRepository>( | ||||
| jdbcDao, | jdbcDao, | ||||
| productionScheduleRepository | productionScheduleRepository | ||||
| @@ -754,6 +756,7 @@ open class ProductionScheduleService( | |||||
| isCoffee = row["isCoffee"] as Number | isCoffee = row["isCoffee"] as Number | ||||
| isTea = row["isTea"] as Number | isTea = row["isTea"] as Number | ||||
| isLemon = row["isLemon"] as Number | isLemon = row["isLemon"] as Number | ||||
| code = row["code"]?.toString() ?: "" | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -836,45 +839,46 @@ open class ProductionScheduleService( | |||||
| //ignore the -1 date | //ignore the -1 date | ||||
| //saveDetailedScheduleOutput(sortedOutputList, accProdCount, fgCount, produceAt) | //saveDetailedScheduleOutput(sortedOutputList, accProdCount, fgCount, produceAt) | ||||
| val plantRules = psScheduleRuleService.loadPlantRules() | |||||
| val itemRules = psScheduleRuleService.loadItemRules() | |||||
| //do for 9 days to predict | //do for 9 days to predict | ||||
| for (i in 0..(days)) { | for (i in 0..(days)) { | ||||
| val targetDate = produceAt.plusDays(i.toLong()) | val targetDate = produceAt.plusDays(i.toLong()) | ||||
| val dayOfWeek = targetDate.dayOfWeek | val dayOfWeek = targetDate.dayOfWeek | ||||
| val isSat = dayOfWeek == DayOfWeek.SATURDAY | |||||
| val isFri = dayOfWeek == DayOfWeek.FRIDAY | |||||
| val isSunday = dayOfWeek == DayOfWeek.SUNDAY | |||||
| val isFriSat = isFri || isSat | |||||
| println("##targetDate:" + targetDate + " isFri:"+ isFri + " isSat:" + isSat + " isSunday:" + isSunday) | |||||
| println("##targetDate:" + targetDate + " day:" + dayOfWeek) | |||||
| fgCount = 0 | fgCount = 0 | ||||
| accProdCount = 0.0 | accProdCount = 0.0 | ||||
| sortedOutputList.forEach { record -> | sortedOutputList.forEach { record -> | ||||
| // isCoffee > 0: only Mon, Wed, Fri; isTea > 0: only Tue, Thu, Sat; isLemon > 0: any day including Sunday | |||||
| val canProduceOnDay = when { | |||||
| (record.isLemon.toDouble()) > 0 -> true | |||||
| (record.isCoffee.toDouble()) > 0 -> dayOfWeek in listOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY) | |||||
| (record.isTea.toDouble()) > 0 -> dayOfWeek in listOf(DayOfWeek.TUESDAY, DayOfWeek.THURSDAY, DayOfWeek.SATURDAY) | |||||
| else -> !isSunday | |||||
| val itemRule = itemRules[record.code] | |||||
| val category = when { | |||||
| record.isLemon.toDouble() > 0 -> "lemon" | |||||
| record.isCoffee.toDouble() > 0 -> "coffee" | |||||
| record.isTea.toDouble() > 0 -> "tea" | |||||
| else -> null | |||||
| } | } | ||||
| val allowedDays = itemRule?.produceDays | |||||
| ?: category?.let { plantRules.categoryProduceDays[it] } | |||||
| ?: PsScheduleRuleService.ALL_DAYS.filter { it !in plantRules.noProductionDays }.toSet() | |||||
| val canProduceOnDay = dayOfWeek in allowedDays | |||||
| if(i > 0) | if(i > 0) | ||||
| record.stockQty = record.stockQty + (record.outputQty * record.needNoOfJobOrder.toInt()) - record.avgQtyLastMonth | record.stockQty = record.stockQty + (record.outputQty * record.needNoOfJobOrder.toInt()) - record.avgQtyLastMonth | ||||
| else | else | ||||
| record.stockQty = record.stockQty + (record.outputQty * record.needNoOfJobOrder.toInt()) | record.stockQty = record.stockQty + (record.outputQty * record.needNoOfJobOrder.toInt()) | ||||
| //compare if less than 1.9 days | |||||
| record.daysLeft = record.stockQty / record.avgQtyLastMonth | record.daysLeft = record.stockQty / record.avgQtyLastMonth | ||||
| println("name: " + record.name + " stockQty:" + record.stockQty + " avgQtyLastMonth:" + record.avgQtyLastMonth + " daysLeft:" + record.daysLeft) | println("name: " + record.name + " stockQty:" + record.stockQty + " avgQtyLastMonth:" + record.avgQtyLastMonth + " daysLeft:" + record.daysLeft) | ||||
| var safetyStockDay = 2.0 | |||||
| var redLine = 1.9 | |||||
| // isFriSat inflated thresholds not suitable for lemon; lemon always uses standard values | |||||
| if (isFriSat && (record.isLemon.toDouble()) <= 0) { | |||||
| safetyStockDay = 3.1 | |||||
| redLine = 2.7 | |||||
| } | |||||
| val skipPrepareMore = itemRule?.skipPrepareMore | |||||
| ?: category?.let { plantRules.categorySkipPrepareMore[it] } | |||||
| ?: false | |||||
| val prepareMore = dayOfWeek in plantRules.prepareMoreDays && !skipPrepareMore | |||||
| val safetyStockDay = itemRule?.safetyStockDays | |||||
| ?: if (prepareMore) plantRules.prepareMoreSafetyStockDays else plantRules.safetyStockDays | |||||
| val redLine = itemRule?.redLineDays | |||||
| ?: if (prepareMore) plantRules.prepareMoreRedLineDays else plantRules.redLineDays | |||||
| if(record.daysLeft < redLine){ | if(record.daysLeft < redLine){ | ||||
| record.needQty = (record.avgQtyLastMonth * safetyStockDay) - record.stockQty; | record.needQty = (record.avgQtyLastMonth * safetyStockDay) - record.stockQty; | ||||
| @@ -999,6 +1003,7 @@ open class ProductionScheduleService( | |||||
| open var isCoffee: Number = 0 | open var isCoffee: Number = 0 | ||||
| open var isTea: Number = 0 | open var isTea: Number = 0 | ||||
| open var isLemon: Number = 0 | open var isLemon: Number = 0 | ||||
| open var code: String = "" | |||||
| override fun toString(): String { | override fun toString(): String { | ||||
| return "NeedQtyRecord(name=${name}," + | return "NeedQtyRecord(name=${name}," + | ||||
| @@ -0,0 +1,18 @@ | |||||
| package com.ffii.fpsms.modules.master.web.models | |||||
| import java.math.BigDecimal | |||||
| /** Item unit conversion for the inventory page. Price and currency are omitted. */ | |||||
| data class ItemUomConversionRow( | |||||
| val id: Long, | |||||
| val unitCode: String, | |||||
| val unitDesc: String, | |||||
| val ratioN: BigDecimal, | |||||
| val ratioD: BigDecimal, | |||||
| val baseUnit: Boolean, | |||||
| val stockUnit: Boolean, | |||||
| val pickingUnit: Boolean, | |||||
| val salesUnit: Boolean, | |||||
| val purchaseUnit: Boolean, | |||||
| val inactive: Boolean, | |||||
| ) | |||||
| @@ -194,6 +194,131 @@ ORDER BY | |||||
| return attachItemTotals(jdbcDao.queryForList(sql, args)) | return attachItemTotals(jdbcDao.queryForList(sql, args)) | ||||
| } | } | ||||
| /** | |||||
| * One item, newest first. The date index stops after this page, then lot/order | |||||
| * joins run only for those rows. | |||||
| */ | |||||
| fun searchItemMovements(itemId: Long, pageNum: Int, pageSize: Int): ItemMovementPage { | |||||
| val size = pageSize.coerceIn(1, 200) | |||||
| val page = pageNum.coerceAtLeast(0) | |||||
| val offset = page.toLong() * size | |||||
| val args = mapOf("itemId" to itemId) | |||||
| val countSql = """ | |||||
| SELECT COUNT(*) AS total | |||||
| FROM stock_ledger sl FORCE INDEX (idx_ledger_item_deleted_date_id) | |||||
| WHERE sl.deleted = 0 | |||||
| AND sl.itemId = :itemId | |||||
| """.trimIndent() | |||||
| val total = toLongId(jdbcDao.queryForList(countSql, args).firstOrNull()?.get("total")).toInt() | |||||
| if (total == 0 || offset >= total) { | |||||
| return ItemMovementPage(emptyList(), total) | |||||
| } | |||||
| val sql = """ | |||||
| SELECT /*+ NO_MERGE(ledger) */ | |||||
| ledger.slId AS id, | |||||
| DATE_FORMAT(ledger.dt, '%Y-%m-%d') AS trnDate, | |||||
| CASE | |||||
| WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'TKE' THEN 'TKE' | |||||
| WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'ADJ' | |||||
| AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' | |||||
| ELSE COALESCE(ledger.ledgerType, '') | |||||
| END AS trnRefNoRaw, | |||||
| COALESCE(ledger.inQty, 0) AS inQty, | |||||
| COALESCE(ledger.outQty, 0) AS outQty, | |||||
| COALESCE(ledger.bal, 0) - COALESCE(ledger.inQty, 0) + COALESCE(ledger.outQty, 0) AS openingQty, | |||||
| COALESCE(ledger.bal, 0) AS balanceQty, | |||||
| COALESCE(il.lotNo, '') AS lotNo, | |||||
| COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, | |||||
| COALESCE(wh.code, '') AS storeLocation, | |||||
| COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo | |||||
| FROM ( | |||||
| SELECT | |||||
| sl.id AS slId, | |||||
| sl.date AS dt, | |||||
| sl.type AS ledgerType, | |||||
| sl.inQty AS inQty, | |||||
| sl.outQty AS outQty, | |||||
| sl.balance AS bal, | |||||
| sl.stockInLineId AS stockInLineId, | |||||
| sl.stockOutLineId AS stockOutLineId, | |||||
| sl.inventoryLotLineId AS inventoryLotLineId | |||||
| FROM stock_ledger sl FORCE INDEX (idx_ledger_item_deleted_date_id) | |||||
| WHERE sl.deleted = 0 | |||||
| AND sl.itemId = :itemId | |||||
| ORDER BY sl.date DESC, sl.id DESC | |||||
| LIMIT $size OFFSET $offset | |||||
| ) ledger | |||||
| LEFT JOIN stock_in_line sil | |||||
| ON ledger.stockInLineId = sil.id | |||||
| AND sil.deleted = 0 | |||||
| LEFT JOIN stock_out_line sol | |||||
| ON ledger.stockOutLineId = sol.id | |||||
| AND sol.deleted = 0 | |||||
| LEFT JOIN inventory_lot_line ill | |||||
| ON ill.id = ledger.inventoryLotLineId | |||||
| AND ill.deleted = 0 | |||||
| LEFT JOIN inventory_lot_line ill_sil | |||||
| ON ill.id IS NULL | |||||
| AND ill_sil.id = sil.inventoryLotLineId | |||||
| AND ill_sil.deleted = 0 | |||||
| LEFT JOIN inventory_lot_line ill_sol | |||||
| ON ill.id IS NULL | |||||
| AND ill_sil.id IS NULL | |||||
| AND ill_sol.id = sol.inventoryLotLineId | |||||
| AND ill_sol.deleted = 0 | |||||
| LEFT JOIN inventory_lot il | |||||
| ON il.id = COALESCE(ill.inventoryLotId, ill_sil.inventoryLotId, ill_sol.inventoryLotId, sil.inventoryLotId) | |||||
| AND il.deleted = 0 | |||||
| LEFT JOIN warehouse wh | |||||
| ON wh.id = COALESCE(ill.warehouseId, ill_sil.warehouseId, ill_sol.warehouseId) | |||||
| AND wh.deleted = 0 | |||||
| LEFT JOIN stock_out so | |||||
| ON sol.stockOutId = so.id | |||||
| AND so.deleted = 0 | |||||
| LEFT JOIN pick_order_line pol | |||||
| ON sol.pickOrderLineId = pol.id | |||||
| AND pol.deleted = 0 | |||||
| LEFT JOIN pick_order po_out | |||||
| ON pol.poId = po_out.id | |||||
| AND po_out.deleted = 0 | |||||
| LEFT JOIN job_order jo_po | |||||
| ON po_out.joId = jo_po.id | |||||
| AND jo_po.deleted = 0 | |||||
| LEFT JOIN delivery_order do | |||||
| ON po_out.doId = do.id | |||||
| AND do.deleted = 0 | |||||
| LEFT JOIN stock_in si | |||||
| ON sil.stockInId = si.id | |||||
| AND si.deleted = 0 | |||||
| LEFT JOIN job_order jo | |||||
| ON sil.jobOrderId = jo.id | |||||
| AND jo.deleted = 0 | |||||
| LEFT JOIN purchase_order po | |||||
| ON sil.purchaseOrderId = po.id | |||||
| AND po.deleted = 0 | |||||
| ORDER BY ledger.dt DESC, ledger.slId DESC | |||||
| """.trimIndent() | |||||
| val records = jdbcDao.queryForList(sql, args).map { row -> | |||||
| val rawType = text(row["trnRefNoRaw"]) | |||||
| ItemMovementRow( | |||||
| id = toLongId(row["id"]), | |||||
| trnDate = text(row["trnDate"]), | |||||
| type = if (rawType.equals("Expiry", ignoreCase = true)) "Expiry" else rawType, | |||||
| lotNo = text(row["lotNo"]), | |||||
| expiryDate = text(row["expiryDate"]), | |||||
| openingQty = toQty(row["openingQty"]).toDouble(), | |||||
| inQty = toQty(row["inQty"]).toDouble(), | |||||
| outQty = toQty(row["outQty"]).toDouble(), | |||||
| balanceQty = toQty(row["balanceQty"]).toDouble(), | |||||
| orderRefNo = text(row["orderRefNo"]), | |||||
| storeLocation = text(row["storeLocation"]), | |||||
| ) | |||||
| } | |||||
| return ItemMovementPage(records, total) | |||||
| } | |||||
| /** | /** | ||||
| * Item totals match the old window: first opening, sum in/out, last balance per itemCode. | * Item totals match the old window: first opening, sum in/out, last balance per itemCode. | ||||
| * Rows stay ordered by item, date, id. | * Rows stay ordered by item, date, id. | ||||
| @@ -359,3 +484,22 @@ ORDER BY | |||||
| return "AND (${conditions.joinToString(" OR ")})" | return "AND (${conditions.joinToString(" OR ")})" | ||||
| } | } | ||||
| } | } | ||||
| data class ItemMovementRow( | |||||
| val id: Long, | |||||
| val trnDate: String, | |||||
| val type: String, | |||||
| val lotNo: String, | |||||
| val expiryDate: String, | |||||
| val openingQty: Double, | |||||
| val inQty: Double, | |||||
| val outQty: Double, | |||||
| val balanceQty: Double, | |||||
| val orderRefNo: String, | |||||
| val storeLocation: String, | |||||
| ) | |||||
| data class ItemMovementPage( | |||||
| val records: List<ItemMovementRow>, | |||||
| val total: Int, | |||||
| ) | |||||
| @@ -313,6 +313,27 @@ WHERE ill.id = :id | |||||
| ) | ) | ||||
| fun countByStatusAndDeletedIsFalse(@Param("status") status: InventoryLotLineStatus): Long | fun countByStatusAndDeletedIsFalse(@Param("status") status: InventoryLotLineStatus): Long | ||||
| @Query(""" | |||||
| SELECT ill FROM InventoryLotLine ill | |||||
| JOIN FETCH ill.inventoryLot il | |||||
| LEFT JOIN FETCH ill.warehouse | |||||
| WHERE ill.deleted = false | |||||
| AND il.deleted = false | |||||
| AND il.item.id = :itemId | |||||
| AND ( | |||||
| COALESCE(ill.holdQty, 0) > 0 | |||||
| OR ( | |||||
| ill.status = :unavailable | |||||
| AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) > 0 | |||||
| ) | |||||
| ) | |||||
| ORDER BY il.expiryDate ASC, il.lotNo ASC, ill.id ASC | |||||
| """) | |||||
| fun findHoldOrUnavailableByItemId( | |||||
| @Param("itemId") itemId: Long, | |||||
| @Param("unavailable") unavailable: InventoryLotLineStatus, | |||||
| ): List<InventoryLotLine> | |||||
| @EntityGraph( | @EntityGraph( | ||||
| type = EntityGraph.EntityGraphType.FETCH, | type = EntityGraph.EntityGraphType.FETCH, | ||||
| attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"] | attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"] | ||||
| @@ -2,6 +2,9 @@ package com.ffii.fpsms.modules.stock.entity | |||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine | import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine | ||||
| import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus | |||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.data.repository.query.Param | |||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| @Repository | @Repository | ||||
| @@ -17,4 +20,22 @@ interface SuggestPickLotRepository : AbstractRepository<SuggestedPickLot, Long> | |||||
| fun findAllByPickOrderLineIdInAndDeletedFalse(pickOrderLineIds: List<Long>): List<SuggestedPickLot> | fun findAllByPickOrderLineIdInAndDeletedFalse(pickOrderLineIds: List<Long>): List<SuggestedPickLot> | ||||
| fun findFirstByStockOutLineId(stockOutLineId: Long): SuggestedPickLot? | fun findFirstByStockOutLineId(stockOutLineId: Long): SuggestedPickLot? | ||||
| @Query(""" | |||||
| SELECT spl FROM SuggestedPickLot spl | |||||
| JOIN FETCH spl.pickOrderLine pol | |||||
| JOIN FETCH pol.pickOrder po | |||||
| LEFT JOIN FETCH po.jobOrder | |||||
| LEFT JOIN FETCH po.deliveryOrder | |||||
| WHERE spl.deleted = false | |||||
| AND pol.deleted = false | |||||
| AND po.deleted = false | |||||
| AND po.status <> :completed | |||||
| AND spl.suggestedLotLine.id IN :lotLineIds | |||||
| ORDER BY spl.id ASC | |||||
| """) | |||||
| fun findOpenBySuggestedLotLineIds( | |||||
| @Param("lotLineIds") lotLineIds: Collection<Long>, | |||||
| @Param("completed") completed: PickOrderStatus, | |||||
| ): List<SuggestedPickLot> | |||||
| } | } | ||||
| @@ -5,7 +5,11 @@ import com.ffii.core.utils.PdfUtils | |||||
| import com.ffii.fpsms.modules.master.entity.ItemUomRespository | import com.ffii.fpsms.modules.master.entity.ItemUomRespository | ||||
| import com.ffii.fpsms.modules.master.entity.WarehouseRepository | import com.ffii.fpsms.modules.master.entity.WarehouseRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | ||||
| import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus | |||||
| import com.ffii.fpsms.modules.pickOrder.enums.PickOrderType | |||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | |||||
| import com.ffii.fpsms.modules.stock.entity.SuggestedPickLot | |||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotRepository | import com.ffii.fpsms.modules.stock.entity.InventoryLotRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus | import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus | ||||
| @@ -35,6 +39,7 @@ import kotlin.jvm.optionals.getOrNull | |||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | import com.ffii.fpsms.modules.master.web.models.MessageResponse | ||||
| import com.ffii.fpsms.modules.stock.web.model.UpdateInventoryLotLineQuantitiesRequest | import com.ffii.fpsms.modules.stock.web.model.UpdateInventoryLotLineQuantitiesRequest | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryRepository | import com.ffii.fpsms.modules.stock.entity.InventoryRepository | ||||
| import com.ffii.fpsms.modules.stock.web.model.InventoryQtyLotRow | |||||
| import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest | import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse | import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse | ||||
| import com.ffii.fpsms.modules.stock.web.model.ScannedLotInfo | import com.ffii.fpsms.modules.stock.web.model.ScannedLotInfo | ||||
| @@ -59,6 +64,7 @@ open class InventoryLotLineService( | |||||
| private val itemUomRespository: ItemUomRespository, | private val itemUomRespository: ItemUomRespository, | ||||
| private val stockInLineRepository: StockInLineRepository, | private val stockInLineRepository: StockInLineRepository, | ||||
| private val inventoryRepository: InventoryRepository, | private val inventoryRepository: InventoryRepository, | ||||
| private val suggestPickLotRepository: SuggestPickLotRepository, | |||||
| private val printerService: PrinterService, | private val printerService: PrinterService, | ||||
| private val settingsService: SettingsService, | private val settingsService: SettingsService, | ||||
| @Lazy | @Lazy | ||||
| @@ -596,4 +602,119 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit | |||||
| sameItemLots = sameItemLots, | sameItemLots = sameItemLots, | ||||
| ) | ) | ||||
| } | } | ||||
| /** Lots that make up on-hold and unavailable qty for one item. Hold is split by open pick order. */ | |||||
| @Transactional(readOnly = true) | |||||
| open fun listQtyBreakdownLots(itemId: Long): List<InventoryQtyLotRow> { | |||||
| val today = LocalDate.now() | |||||
| val lines = inventoryLotLineRepository | |||||
| .findHoldOrUnavailableByItemId(itemId, InventoryLotLineStatus.UNAVAILABLE) | |||||
| val holdLineIds = lines.mapNotNull { line -> | |||||
| val hold = line.holdQty ?: BigDecimal.ZERO | |||||
| if (hold > BigDecimal.ZERO) line.id else null | |||||
| } | |||||
| val suggestions = if (holdLineIds.isEmpty()) { | |||||
| emptyList() | |||||
| } else { | |||||
| suggestPickLotRepository.findOpenBySuggestedLotLineIds(holdLineIds, PickOrderStatus.COMPLETED) | |||||
| } | |||||
| return lines.flatMap { line -> toQtyBreakdownRows(line, today, suggestions) } | |||||
| } | |||||
| private fun toQtyBreakdownRows( | |||||
| line: InventoryLotLine, | |||||
| today: LocalDate, | |||||
| suggestions: List<SuggestedPickLot>, | |||||
| ): List<InventoryQtyLotRow> { | |||||
| val inQty = line.inQty ?: BigDecimal.ZERO | |||||
| val outQty = line.outQty ?: BigDecimal.ZERO | |||||
| val hold = line.holdQty ?: BigDecimal.ZERO | |||||
| val lot = line.inventoryLot | |||||
| val expiry = lot?.expiryDate | |||||
| val expired = expiry != null && expiry.isBefore(today) | |||||
| val lotNo = lot?.lotNo.orEmpty() | |||||
| val expiryText = expiry?.format(DateTimeFormatter.ISO_LOCAL_DATE).orEmpty() | |||||
| val warehouseCode = line.warehouse?.code.orEmpty() | |||||
| val remarks = line.remarks?.trim().orEmpty() | |||||
| val id = line.id ?: return emptyList() | |||||
| val rows = mutableListOf<InventoryQtyLotRow>() | |||||
| if (hold > BigDecimal.ZERO) { | |||||
| rows += splitHold(id, hold, lotNo, expiryText, warehouseCode, suggestions) | |||||
| } | |||||
| if (line.status == InventoryLotLineStatus.UNAVAILABLE) { | |||||
| val qty = inQty.subtract(outQty).subtract(hold) | |||||
| if (qty > BigDecimal.ZERO) { | |||||
| rows += InventoryQtyLotRow( | |||||
| lotLineId = id, | |||||
| bucket = "unavailable", | |||||
| lotNo = lotNo, | |||||
| expiryDate = expiryText, | |||||
| warehouseCode = warehouseCode, | |||||
| qty = qty.toDouble(), | |||||
| reason = if (expired) "expired" else "unavailable", | |||||
| detail = if (expired) "" else remarks, | |||||
| ) | |||||
| } | |||||
| } | |||||
| return rows | |||||
| } | |||||
| private fun splitHold( | |||||
| lotLineId: Long, | |||||
| hold: BigDecimal, | |||||
| lotNo: String, | |||||
| expiryText: String, | |||||
| warehouseCode: String, | |||||
| suggestions: List<SuggestedPickLot>, | |||||
| ): List<InventoryQtyLotRow> { | |||||
| var left = hold | |||||
| val rows = mutableListOf<InventoryQtyLotRow>() | |||||
| val mine = suggestions.filter { it.suggestedLotLine?.id == lotLineId }.sortedBy { it.id ?: 0L } | |||||
| for (spl in mine) { | |||||
| if (left <= BigDecimal.ZERO) break | |||||
| val suggested = spl.qty ?: BigDecimal.ZERO | |||||
| if (suggested <= BigDecimal.ZERO) continue | |||||
| val take = suggested.min(left) | |||||
| val po = spl.pickOrderLine?.pickOrder | |||||
| val source = when { | |||||
| po?.jobOrder != null || po?.type == PickOrderType.JOB_ORDER -> "jo" | |||||
| po?.deliveryOrder != null || po?.type == PickOrderType.DELIVERY_ORDER -> "do" | |||||
| else -> "other" | |||||
| } | |||||
| val orderCode = po?.jobOrder?.code ?: po?.deliveryOrder?.code ?: po?.code.orEmpty() | |||||
| rows += holdRow(lotLineId, lotNo, expiryText, warehouseCode, take, source, orderCode) | |||||
| left = left.subtract(take) | |||||
| } | |||||
| if (left > BigDecimal.ZERO) { | |||||
| rows += holdRow(lotLineId, lotNo, expiryText, warehouseCode, left, "other", "") | |||||
| } | |||||
| return rows | |||||
| } | |||||
| private fun holdRow( | |||||
| lotLineId: Long, | |||||
| lotNo: String, | |||||
| expiryText: String, | |||||
| warehouseCode: String, | |||||
| qty: BigDecimal, | |||||
| source: String, | |||||
| orderCode: String, | |||||
| ): InventoryQtyLotRow { | |||||
| val reason = when (source) { | |||||
| "jo" -> "joHold" | |||||
| "do" -> "doHold" | |||||
| else -> "otherHold" | |||||
| } | |||||
| return InventoryQtyLotRow( | |||||
| lotLineId = lotLineId, | |||||
| bucket = "onHold", | |||||
| lotNo = lotNo, | |||||
| expiryDate = expiryText, | |||||
| warehouseCode = warehouseCode, | |||||
| qty = qty.toDouble(), | |||||
| reason = reason, | |||||
| detail = orderCode, | |||||
| source = source, | |||||
| ) | |||||
| } | |||||
| } | } | ||||
| @@ -1,16 +1,27 @@ | |||||
| package com.ffii.fpsms.modules.stock.web | package com.ffii.fpsms.modules.stock.web | ||||
| import com.ffii.core.response.RecordsRes | import com.ffii.core.response.RecordsRes | ||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||||
| import com.ffii.fpsms.modules.master.web.models.ItemUomConversionRow | |||||
| import com.ffii.fpsms.modules.report.service.ItemMovementRow | |||||
| import com.ffii.fpsms.modules.report.service.StockLedgerReportService | |||||
| import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo | import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo | ||||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | |||||
| import com.ffii.fpsms.modules.stock.service.InventoryService | import com.ffii.fpsms.modules.stock.service.InventoryService | ||||
| import com.ffii.fpsms.modules.stock.web.model.InventoryQtyLotRow | |||||
| import com.ffii.fpsms.modules.stock.web.model.SearchInventoryRequest | import com.ffii.fpsms.modules.stock.web.model.SearchInventoryRequest | ||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| @RestController | @RestController | ||||
| @RequestMapping("/inventory") | @RequestMapping("/inventory") | ||||
| class InventoryController( | class InventoryController( | ||||
| private val inventoryService: InventoryService, | private val inventoryService: InventoryService, | ||||
| private val stockLedgerReportService: StockLedgerReportService, | |||||
| private val itemUomService: ItemUomService, | |||||
| private val inventoryLotLineService: InventoryLotLineService, | |||||
| ) { | ) { | ||||
| private val logger = LoggerFactory.getLogger(InventoryController::class.java) | |||||
| @GetMapping("/list") | @GetMapping("/list") | ||||
| fun allInventories(): List<InventoryInfo> { | fun allInventories(): List<InventoryInfo> { | ||||
| return inventoryService.allInventories() | return inventoryService.allInventories() | ||||
| @@ -32,6 +43,39 @@ class InventoryController( | |||||
| return inventoryService.searchInventoriesByLatestInventory(request) | return inventoryService.searchInventoriesByLatestInventory(request) | ||||
| } | } | ||||
| /** Latest in/out rows for one item. Page is 0-based. */ | |||||
| @GetMapping("/item-movements") | |||||
| fun itemMovements( | |||||
| @RequestParam itemId: Long, | |||||
| @RequestParam(required = false, defaultValue = "0") pageNum: Int, | |||||
| @RequestParam(required = false, defaultValue = "100") pageSize: Int, | |||||
| ): RecordsRes<ItemMovementRow> { | |||||
| val page = stockLedgerReportService.searchItemMovements(itemId, pageNum, pageSize) | |||||
| return RecordsRes(page.records, page.total) | |||||
| } | |||||
| /** Unit conversion for one item. Currency and unit price are not included. */ | |||||
| @GetMapping("/item-uoms") | |||||
| fun itemUoms(@RequestParam itemId: Long): List<ItemUomConversionRow> { | |||||
| return try { | |||||
| itemUomService.listItemUomConversions(itemId) | |||||
| } catch (ex: Exception) { | |||||
| logger.error("item-uoms failed for itemId=$itemId", ex) | |||||
| emptyList() | |||||
| } | |||||
| } | |||||
| /** Lots behind on-hold and unavailable qty: lot, qty, expiry, and why. */ | |||||
| @GetMapping("/qty-breakdown") | |||||
| fun qtyBreakdown(@RequestParam itemId: Long): List<InventoryQtyLotRow> { | |||||
| return try { | |||||
| inventoryLotLineService.listQtyBreakdownLots(itemId) | |||||
| } catch (ex: Exception) { | |||||
| logger.error("qty-breakdown failed for itemId=$itemId", ex) | |||||
| emptyList() | |||||
| } | |||||
| } | |||||
| @PostMapping("/import-bom") | @PostMapping("/import-bom") | ||||
| fun importInventory() { | fun importInventory() { | ||||
| val reportResult = inventoryService.importInventoryExcel() | val reportResult = inventoryService.importInventoryExcel() | ||||
| @@ -0,0 +1,14 @@ | |||||
| package com.ffii.fpsms.modules.stock.web.model | |||||
| data class InventoryQtyLotRow( | |||||
| val lotLineId: Long, | |||||
| val bucket: String, | |||||
| val lotNo: String, | |||||
| val expiryDate: String, | |||||
| val warehouseCode: String, | |||||
| val qty: Double, | |||||
| val reason: String, | |||||
| val detail: String, | |||||
| /** jo = job-order pick, do = delivery pick, other = hold with no open pick. */ | |||||
| val source: String = "", | |||||
| ) | |||||
| @@ -0,0 +1,31 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset fai:20261007_ps_schedule_rules | |||||
| CREATE TABLE IF NOT EXISTS `item_ps_rule` ( | |||||
| `itemCode` VARCHAR(100) NOT NULL, | |||||
| `redLineDays` DECIMAL(8,2) NULL, | |||||
| `safetyStockDays` DECIMAL(8,2) NULL, | |||||
| `produceDays` VARCHAR(64) NULL, | |||||
| `skipPrepareMore` TINYINT(1) NULL, | |||||
| PRIMARY KEY (`itemCode`) | |||||
| ); | |||||
| INSERT INTO `settings` (`name`, `value`, `category`, `type`) | |||||
| SELECT v.name, v.value, v.category, v.type | |||||
| FROM ( | |||||
| SELECT 'ps.redLineDays' AS name, '1.9' AS value, 'PS' AS category, 'float' AS type | |||||
| UNION ALL SELECT 'ps.safetyStockDays', '2.0', 'PS', 'float' | |||||
| UNION ALL SELECT 'ps.prepareMore.redLineDays', '2.7', 'PS', 'float' | |||||
| UNION ALL SELECT 'ps.prepareMore.safetyStockDays', '3.1', 'PS', 'float' | |||||
| UNION ALL SELECT 'ps.prepareMore.days', 'FRI,SAT', 'PS', 'string' | |||||
| UNION ALL SELECT 'ps.noProduction.days', 'SUN', 'PS', 'string' | |||||
| UNION ALL SELECT 'ps.category.coffee.days', 'MON,WED,FRI', 'PS', 'string' | |||||
| UNION ALL SELECT 'ps.category.tea.days', 'TUE,THU,SAT', 'PS', 'string' | |||||
| UNION ALL SELECT 'ps.category.lemon.days', 'MON,TUE,WED,THU,FRI,SAT,SUN', 'PS', 'string' | |||||
| UNION ALL SELECT 'ps.category.coffee.skipPrepareMore', 'false', 'PS', 'boolean' | |||||
| UNION ALL SELECT 'ps.category.tea.skipPrepareMore', 'false', 'PS', 'boolean' | |||||
| UNION ALL SELECT 'ps.category.lemon.skipPrepareMore', 'true', 'PS', 'boolean' | |||||
| ) v | |||||
| WHERE NOT EXISTS ( | |||||
| SELECT 1 FROM `settings` s WHERE s.name = v.name | |||||
| ); | |||||