From a3439c0c046c4aeaf2c4cf8063b06978ffaedbc0 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Wed, 7 Oct 2026 13:09:14 +0800 Subject: [PATCH] enhance the item inventory view to see the transaction and expired lot clearly --- .../jobOrder/service/PsScheduleRuleService.kt | 366 ++++++++++++++++++ .../modules/jobOrder/web/PSController.kt | 33 +- .../master/entity/ItemUomRespository.kt | 2 + .../modules/master/service/ItemUomService.kt | 22 ++ .../service/ProductionScheduleService.kt | 47 ++- .../master/web/models/ItemUomConversionRow.kt | 18 + .../service/StockLedgerReportService.kt | 144 +++++++ .../entity/InventoryLotLineRepository.kt | 21 + .../stock/entity/SuggestPickLotRepository.kt | 21 + .../stock/service/InventoryLotLineService.kt | 121 ++++++ .../modules/stock/web/InventoryController.kt | 44 +++ .../stock/web/model/InventoryQtyLotRow.kt | 14 + .../01_ps_schedule_rules.sql | 31 ++ 13 files changed, 860 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/PsScheduleRuleService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomConversionRow.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/web/model/InventoryQtyLotRow.kt create mode 100644 src/main/resources/db/changelog/changes/20261007_ps_schedule_rules/01_ps_schedule_rules.sql diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PsScheduleRuleService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PsScheduleRuleService.kt new file mode 100644 index 00000000..7336b7fb --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PsScheduleRuleService.kt @@ -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, + val noProductionDays: Set, + val categoryProduceDays: Map>, + val categorySkipPrepareMore: Map, +) + +data class ItemPsRule( + val itemCode: String, + val redLineDays: Double?, + val safetyStockDays: Double?, + val produceDays: Set?, + 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 = listOf("FRI", "SAT"), + val noProductionDays: List = listOf("SUN"), + val coffeeDays: List = listOf("MON", "WED", "FRI"), + val teaDays: List = listOf("TUE", "THU", "SAT"), + val lemonDays: List = 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 = 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 { + val rows = jdbcDao.queryForList( + """ + SELECT itemCode, redLineDays, safetyStockDays, produceDays, skipPrepareMore + FROM item_ps_rule + """.trimIndent(), + emptyMap(), + ) + 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 { + 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()), + "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): Set { + 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, label: String): Set { + val days = parseDaysStrict(tokens, label) + if (days.isEmpty()) throw IllegalArgumentException("$label 至少要選一天可生產") + return days + } + + private fun parseDaysStrict(tokens: List, label: String): Set { + 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 = 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 { + if (raw.isNullOrBlank()) return emptySet() + return raw.split(",") + .map { it.trim().uppercase() } + .mapNotNull { DAY_TOKENS[it] } + .toSet() + } + + fun formatDays(days: Set): 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 + } + } + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt index fd05658e..99bc5bac 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt @@ -1,9 +1,9 @@ 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.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.MediaType import org.springframework.web.bind.annotation.* @@ -15,6 +15,7 @@ import org.springframework.http.ResponseEntity @RequestMapping("/ps") class PSController( private val psService: PSService, + private val psScheduleRuleService: PsScheduleRuleService, ) { /** * Bulk import fake on-hand overrides from Excel. @@ -87,6 +88,32 @@ class PSController( 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> { + return ResponseEntity.ok(psScheduleRuleService.plantRulesResponse()) + } + + @PostMapping("/schedule-rules") + fun saveScheduleRules(@RequestBody body: SavePsPlantRulesRequest): ResponseEntity> { + 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> { + 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. */ @PostMapping("/setCoffeeOrTea") fun setCoffeeOrTea(@RequestBody body: Map): ResponseEntity> { diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt index 19ddb069..31f620c9 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt @@ -9,6 +9,8 @@ import java.io.Serializable interface ItemUomRespository : AbstractRepository { fun findAllByItemIdAndDeletedIsFalse(itemId: Serializable): List + fun findAllByItemId(itemId: Long): List + /** All active item_uom rows with uom_conversion (single round-trip for master-data scans). */ @Query( """ diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index 1ccd30e8..568d2675 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -30,6 +30,28 @@ open class ItemUomService( 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 { + return itemUomRespository.findAllByItemId(itemId) + .sortedWith(compareBy { 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? { return itemUomRespository.findByIdAndDeletedIsFalse(id); } diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt index 648b1740..564c6aba 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt @@ -60,6 +60,7 @@ import org.apache.poi.xssf.usermodel.XSSFPrintSetup import java.io.ByteArrayOutputStream import java.sql.Timestamp import com.ffii.fpsms.modules.jobOrder.service.JobOrderCreationService +import com.ffii.fpsms.modules.jobOrder.service.PsScheduleRuleService @Service open class ProductionScheduleService( @@ -79,6 +80,7 @@ open class ProductionScheduleService( private val productProcessService: ProductProcessService, private val jobOrderCreationService: JobOrderCreationService, private val settingsService: SettingsService, + private val psScheduleRuleService: PsScheduleRuleService, ) : AbstractBaseEntityService( jdbcDao, productionScheduleRepository @@ -754,6 +756,7 @@ open class ProductionScheduleService( isCoffee = row["isCoffee"] as Number isTea = row["isTea"] as Number isLemon = row["isLemon"] as Number + code = row["code"]?.toString() ?: "" } } } @@ -836,45 +839,46 @@ open class ProductionScheduleService( //ignore the -1 date //saveDetailedScheduleOutput(sortedOutputList, accProdCount, fgCount, produceAt) + val plantRules = psScheduleRuleService.loadPlantRules() + val itemRules = psScheduleRuleService.loadItemRules() + //do for 9 days to predict for (i in 0..(days)) { val targetDate = produceAt.plusDays(i.toLong()) 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 accProdCount = 0.0 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) record.stockQty = record.stockQty + (record.outputQty * record.needNoOfJobOrder.toInt()) - record.avgQtyLastMonth else record.stockQty = record.stockQty + (record.outputQty * record.needNoOfJobOrder.toInt()) - //compare if less than 1.9 days record.daysLeft = record.stockQty / record.avgQtyLastMonth 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){ record.needQty = (record.avgQtyLastMonth * safetyStockDay) - record.stockQty; @@ -999,6 +1003,7 @@ open class ProductionScheduleService( open var isCoffee: Number = 0 open var isTea: Number = 0 open var isLemon: Number = 0 + open var code: String = "" override fun toString(): String { return "NeedQtyRecord(name=${name}," + diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomConversionRow.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomConversionRow.kt new file mode 100644 index 00000000..020010f5 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomConversionRow.kt @@ -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, +) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 7e57c09c..a33c7bde 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -194,6 +194,131 @@ ORDER BY 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. * Rows stay ordered by item, date, id. @@ -359,3 +484,22 @@ ORDER BY 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, + val total: Int, +) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index 68911e1c..dc0fe7b2 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -313,6 +313,27 @@ WHERE ill.id = :id ) 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 + @EntityGraph( type = EntityGraph.EntityGraphType.FETCH, attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"] diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt index 3ccd286b..c920a12b 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt @@ -2,6 +2,9 @@ package com.ffii.fpsms.modules.stock.entity import com.ffii.core.support.AbstractRepository 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 @Repository @@ -17,4 +20,22 @@ interface SuggestPickLotRepository : AbstractRepository fun findAllByPickOrderLineIdInAndDeletedFalse(pickOrderLineIds: List): List 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, + @Param("completed") completed: PickOrderStatus, + ): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index ec914f52..5463af06 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -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.WarehouseRepository 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.SuggestPickLotRepository +import com.ffii.fpsms.modules.stock.entity.SuggestedPickLot import com.ffii.fpsms.modules.stock.entity.InventoryLotRepository import com.ffii.fpsms.modules.stock.entity.StockInLineRepository 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.stock.web.model.UpdateInventoryLotLineQuantitiesRequest 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.QrCodeAnalysisResponse import com.ffii.fpsms.modules.stock.web.model.ScannedLotInfo @@ -59,6 +64,7 @@ open class InventoryLotLineService( private val itemUomRespository: ItemUomRespository, private val stockInLineRepository: StockInLineRepository, private val inventoryRepository: InventoryRepository, + private val suggestPickLotRepository: SuggestPickLotRepository, private val printerService: PrinterService, private val settingsService: SettingsService, @Lazy @@ -596,4 +602,119 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit 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 { + 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, + ): List { + 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() + 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, + ): List { + var left = hold + val rows = mutableListOf() + 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, + ) + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt index 10edbb55..13637914 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt @@ -1,16 +1,27 @@ package com.ffii.fpsms.modules.stock.web 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.service.InventoryLotLineService 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 org.slf4j.LoggerFactory import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/inventory") class InventoryController( 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") fun allInventories(): List { return inventoryService.allInventories() @@ -32,6 +43,39 @@ class InventoryController( 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 { + 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 { + 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 { + return try { + inventoryLotLineService.listQtyBreakdownLots(itemId) + } catch (ex: Exception) { + logger.error("qty-breakdown failed for itemId=$itemId", ex) + emptyList() + } + } + @PostMapping("/import-bom") fun importInventory() { val reportResult = inventoryService.importInventoryExcel() diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/InventoryQtyLotRow.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/InventoryQtyLotRow.kt new file mode 100644 index 00000000..5ef2cce1 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/InventoryQtyLotRow.kt @@ -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 = "", +) diff --git a/src/main/resources/db/changelog/changes/20261007_ps_schedule_rules/01_ps_schedule_rules.sql b/src/main/resources/db/changelog/changes/20261007_ps_schedule_rules/01_ps_schedule_rules.sql new file mode 100644 index 00000000..8e8e8a10 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261007_ps_schedule_rules/01_ps_schedule_rules.sql @@ -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 +);