| @@ -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 | |||
| 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<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. */ | |||
| @PostMapping("/setCoffeeOrTea") | |||
| fun setCoffeeOrTea(@RequestBody body: Map<String, Any>): ResponseEntity<Map<String, Any>> { | |||
| @@ -9,6 +9,8 @@ import java.io.Serializable | |||
| interface ItemUomRespository : AbstractRepository<ItemUom, Long> { | |||
| 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). */ | |||
| @Query( | |||
| """ | |||
| @@ -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<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? { | |||
| return itemUomRespository.findByIdAndDeletedIsFalse(id); | |||
| } | |||
| @@ -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<ProductionSchedule, Long, ProductionScheduleRepository>( | |||
| 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}," + | |||
| @@ -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)) | |||
| } | |||
| /** | |||
| * 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<ItemMovementRow>, | |||
| val total: Int, | |||
| ) | |||
| @@ -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<InventoryLotLine> | |||
| @EntityGraph( | |||
| type = EntityGraph.EntityGraphType.FETCH, | |||
| 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.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<SuggestedPickLot, Long> | |||
| fun findAllByPickOrderLineIdInAndDeletedFalse(pickOrderLineIds: List<Long>): List<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.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<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 | |||
| 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<InventoryInfo> { | |||
| 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<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") | |||
| fun importInventory() { | |||
| 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 | |||
| ); | |||