瀏覽代碼

enhance the item inventory view to see the transaction and expired lot clearly

debrand
Fai Luk 4 週之前
父節點
當前提交
a3439c0c04
共有 13 個文件被更改,包括 860 次插入 和 24 次删除
  1. +366
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PsScheduleRuleService.kt
  2. +30
    -3
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt
  3. +2
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt
  4. +22
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  5. +26
    -21
      src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt
  6. +18
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomConversionRow.kt
  7. +144
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt
  8. +21
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  9. +21
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt
  10. +121
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  11. +44
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt
  12. +14
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/InventoryQtyLotRow.kt
  13. +31
    -0
      src/main/resources/db/changelog/changes/20261007_ps_schedule_rules/01_ps_schedule_rules.sql

+ 366
- 0
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<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
}
}
}
}
}

+ 30
- 3
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<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>> {


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt 查看文件

@@ -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(
"""


+ 22
- 0
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<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);
}


+ 26
- 21
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<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}," +


+ 18
- 0
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,
)

+ 144
- 0
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<ItemMovementRow>,
val total: Int,
)

+ 21
- 0
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<InventoryLotLine>

@EntityGraph(
type = EntityGraph.EntityGraphType.FETCH,
attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"]


+ 21
- 0
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<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>
}

+ 121
- 0
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<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,
)
}
}

+ 44
- 0
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<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()


+ 14
- 0
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 = "",
)

+ 31
- 0
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
);

Loading…
取消
儲存