diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/PickExcludeRule.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/PickExcludeRule.kt new file mode 100644 index 00000000..42a091a6 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/PickExcludeRule.kt @@ -0,0 +1,38 @@ +package com.ffii.fpsms.modules.deliveryOrder.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table + +@Entity +@Table(name = "pick_exclude_rule") +open class PickExcludeRule : BaseEntity() { + @Column(nullable = false, length = 2) + open var scope: String = "" + + @Column(nullable = false, length = 30) + open var floor: String = "" + + @Column(nullable = false, length = 30) + open var warehouse: String = "" + + @Column(nullable = false, columnDefinition = "TEXT") + open var areas: String = "" + + @Column(nullable = false, columnDefinition = "TEXT") + open var slots: String = "" + + @Column(name = "print_mode", nullable = false, length = 20) + open var printMode: String = "hideList" + + @Column(name = "rule_enabled", nullable = false) + open var ruleEnabled: Boolean = true + + @Column(name = "card_id", nullable = false, length = 64) + open var cardId: String = "" + + /** use, unused, or deleted. */ + @Column(name = "record_status", nullable = false, length = 10) + open var recordStatus: String = "use" +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/PickExcludeRuleRepository.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/PickExcludeRuleRepository.kt new file mode 100644 index 00000000..cbeec561 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/PickExcludeRuleRepository.kt @@ -0,0 +1,9 @@ +package com.ffii.fpsms.modules.deliveryOrder.entity + +import com.ffii.core.support.AbstractRepository +import org.springframework.stereotype.Repository + +@Repository +interface PickExcludeRuleRepository : AbstractRepository { + fun findByScopeAndDeletedIsFalseOrderByIdAsc(scope: String): List +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt index dd3fbc7f..aa13c2d8 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt @@ -9,6 +9,7 @@ import java.util.Locale @Service open class DoFloorSupplierSettingsService( private val settingsRepository: SettingsRepository, + private val pickExcludeRuleService: PickExcludeRuleService, ) { companion object { private const val SETTING_DO_FLOOR_SUPPLIERS_2F = "DO.floor.suppliers.2F" @@ -51,17 +52,19 @@ open class DoFloorSupplierSettingsService( /** DO pick / workbench default: lots in these warehouses are not suggested. */ open fun loadDoPickExcludeWarehouseCodes(): Set = - warehouseCodesFromSettingOrDefault( - SETTING_DO_PICK_EXCLUDE_WAREHOUSES, - DEFAULT_DO_PICK_EXCLUDE_WAREHOUSES, - ) + pickExcludeRuleService.expandedOrNull("DO")?.all + ?: warehouseCodesFromSettingOrDefault( + SETTING_DO_PICK_EXCLUDE_WAREHOUSES, + DEFAULT_DO_PICK_EXCLUDE_WAREHOUSES, + ) /** JO workbench (and consumable user 246): lots in these warehouses are not suggested. */ open fun loadJoPickExcludeWarehouseCodes(): Set = - warehouseCodesFromSettingOrDefault( - SETTING_JO_PICK_EXCLUDE_WAREHOUSES, - JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES.toList(), - ) + pickExcludeRuleService.expandedOrNull("JO")?.all + ?: warehouseCodesFromSettingOrDefault( + SETTING_JO_PICK_EXCLUDE_WAREHOUSES, + JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES.toList(), + ) /** * hideList: excluded warehouses are omitted from the print list, and scanning their physical QR does not pass. @@ -77,19 +80,25 @@ open class DoFloorSupplierSettingsService( * Hide-QR warehouses stay on the list and can still be scanned. * A missing hide-QR row keeps the old single print mode. */ - open fun loadDoHideListWarehouseCodes(): Set = - hideListCodes( + open fun loadDoHideListWarehouseCodes(): Set { + val expanded = pickExcludeRuleService.expandedOrNull("DO") + if (expanded != null) return expanded.all.filter { it !in expanded.hideQr }.toSet() + return hideListCodes( loadDoPickExcludeWarehouseCodes(), SETTING_DO_PICK_EXCLUDE_HIDE_QR, loadDoPickExcludePrintMode(), ) + } - open fun loadJoHideListWarehouseCodes(): Set = - hideListCodes( + open fun loadJoHideListWarehouseCodes(): Set { + val expanded = pickExcludeRuleService.expandedOrNull("JO") + if (expanded != null) return expanded.all.filter { it !in expanded.hideQr }.toSet() + return hideListCodes( loadJoPickExcludeWarehouseCodes(), SETTING_JO_PICK_EXCLUDE_HIDE_QR, loadJoPickExcludePrintMode(), ) + } open fun rejectsScanOfExcludedWarehouse(printMode: String): Boolean = printMode != PRINT_MODE_HIDE_QR diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/PickExcludeRuleService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/PickExcludeRuleService.kt new file mode 100644 index 00000000..55b80652 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/PickExcludeRuleService.kt @@ -0,0 +1,274 @@ +package com.ffii.fpsms.modules.deliveryOrder.service + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.ffii.fpsms.modules.deliveryOrder.entity.PickExcludeRule +import com.ffii.fpsms.modules.deliveryOrder.entity.PickExcludeRuleRepository +import com.ffii.fpsms.modules.master.entity.WarehouseRepository +import com.ffii.fpsms.modules.settings.entity.SettingsRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.Locale + +data class PickExcludeRuleDto( + val floor: String = "", + val warehouse: String = "", + val areas: String = "", + val slots: String = "", + val printMode: String = "hideList", + val enabled: Boolean = true, + val cardId: String = "", +) + +data class SavePickExcludeRulesRequest( + val scope: String = "", + val rules: List = emptyList(), +) + +data class SavePickExcludeRulesResponse( + val source: String, + val rules: List, + val droppedUnusedOrDeleted: Int, +) + +data class ExpandedExcludeCodes( + val all: Set, + val hideQr: Set, +) + +/** + * Pick-exclude rules are stored as JSON in `settings` when that JSON fits in varchar(1000). + * The setting value `db` means [pick_exclude_rule] is the source. + * Disabled rules are `unused` and removed rules are `deleted`. Those two statuses together + * are kept only up to [ARCHIVE_LIMIT] rows; older deleted rows go first, then older unused rows. + */ +@Service +open class PickExcludeRuleService( + private val settingsRepository: SettingsRepository, + private val warehouseRepository: WarehouseRepository, + private val pickExcludeRuleRepository: PickExcludeRuleRepository, + private val objectMapper: ObjectMapper, +) { + companion object { + const val SOURCE_DB = "db" + const val ARCHIVE_LIMIT = 100 + const val SETTINGS_VALUE_LIMIT = 1000 + const val STATUS_USE = "use" + const val STATUS_UNUSED = "unused" + const val STATUS_DELETED = "deleted" + private const val EMPTY = "-" + private const val SCOPE_DO = "DO" + private const val SCOPE_JO = "JO" + } + + /** Null means the legacy warehouse-code setting is still the source. */ + open fun expandedOrNull(scope: String): ExpandedExcludeCodes? { + val normalized = normalizeScope(scope) + val raw = readSetting(rulesSetting(normalized)) + if (raw == SOURCE_DB) { + return expand(rowsToDtos(loadRows(normalized).filter { it.recordStatus == STATUS_USE })) + } + if (raw.startsWith("[") || raw.startsWith("{")) { + return expand(parseRules(raw).filter { it.enabled }) + } + return null + } + + open fun visibleRules(scope: String): List { + val normalized = normalizeScope(scope) + val raw = readSetting(rulesSetting(normalized)) + val rows = loadRows(normalized) + if (raw == SOURCE_DB) { + return rowsToDtos(rows.filter { it.recordStatus != STATUS_DELETED }) + } + val unused = rowsToDtos(rows.filter { it.recordStatus == STATUS_UNUSED }) + if (raw.startsWith("[") || raw.startsWith("{")) { + val parsed = parseRules(raw) + val parsedIds = parsed.map { it.cardId }.toSet() + return parsed + unused.filter { it.cardId !in parsedIds } + } + return unused + } + + @Transactional + open fun save(request: SavePickExcludeRulesRequest): SavePickExcludeRulesResponse { + val scope = normalizeScope(request.scope) + val incoming = request.rules.map { normalizeRule(it) } + val previousLive = visibleRules(scope) + val incomingIds = incoming.map { it.cardId }.toSet() + val newlyDeleted = previousLive.filter { it.cardId !in incomingIds } + val existingDeleted = rowsToDtos(loadRows(scope).filter { it.recordStatus == STATUS_DELETED }) + val deleted = (existingDeleted + newlyDeleted).distinctBy { it.cardId } + val used = incoming.filter { it.enabled } + var unused = incoming.filter { !it.enabled }.toMutableList() + var deletedKept = deleted.toMutableList() + var dropped = 0 + while (unused.size + deletedKept.size > ARCHIVE_LIMIT) { + if (deletedKept.isNotEmpty()) { + deletedKept.removeAt(0) + } else { + unused.removeAt(0) + } + dropped += 1 + } + val kept = used + unused + val activeJson = objectMapper.writeValueAsString(used) + val source = if (activeJson.length <= SETTINGS_VALUE_LIMIT) "json" else SOURCE_DB + replaceRows(scope, source, kept, deletedKept) + writeLegacyLists(scope, expand(used)) + writeSetting(rulesSetting(scope), if (source == SOURCE_DB) SOURCE_DB else activeJson) + return SavePickExcludeRulesResponse(source = source, rules = kept, droppedUnusedOrDeleted = dropped) + } + + private fun replaceRows( + scope: String, + source: String, + kept: List, + deleted: List, + ) { + val existing = loadRows(scope) + if (existing.isNotEmpty()) pickExcludeRuleRepository.deleteAll(existing) + val rows = mutableListOf>() + if (source == SOURCE_DB) { + kept.forEach { dto -> + rows += dto to if (dto.enabled) STATUS_USE else STATUS_UNUSED + } + } else { + kept.filter { !it.enabled }.forEach { dto -> + rows += dto to STATUS_UNUSED + } + } + val keptIds = kept.map { it.cardId }.toSet() + deleted.forEach { dto -> + if (dto.cardId !in keptIds) rows += dto to STATUS_DELETED + } + rows.forEach { (dto, status) -> + val row = PickExcludeRule() + row.scope = scope + row.floor = dto.floor + row.warehouse = dto.warehouse + row.areas = dto.areas + row.slots = dto.slots + row.printMode = dto.printMode + row.ruleEnabled = status == STATUS_USE + row.cardId = dto.cardId + row.recordStatus = status + pickExcludeRuleRepository.save(row) + } + } + + private fun expand(rules: List): ExpandedExcludeCodes { + val warehouses = warehouseRepository.findAllByDeletedIsFalse() + val all = linkedSetOf() + val hideQr = linkedSetOf() + for (rule in rules) { + if (!rule.enabled) continue + val areas = tokens(rule.areas) + val allAreas = areas.any { it == "*" } + val slots = tokens(rule.slots) + if (slots.any { it == "-" }) continue + for (row in warehouses) { + val code = row.code?.trim().orEmpty() + if (code.isEmpty()) continue + if (!row.store_id.orEmpty().trim().equals(rule.floor, ignoreCase = true)) continue + if (!row.warehouse.orEmpty().trim().equals(rule.warehouse, ignoreCase = true)) continue + val area = row.area.orEmpty().trim() + if (!allAreas && areas.none { it.equals(area, ignoreCase = true) }) continue + val slot = row.slot.orEmpty().trim() + if (slots.isNotEmpty() && slots.none { it.equals(slot, ignoreCase = true) }) continue + val upper = code.uppercase(Locale.ROOT) + all.add(upper) + if (rule.printMode == DoFloorSupplierSettingsService.PRINT_MODE_HIDE_QR) hideQr.add(upper) + } + } + return ExpandedExcludeCodes(all, hideQr) + } + + private fun writeLegacyLists(scope: String, expanded: ExpandedExcludeCodes) { + val csv = expanded.all.joinToString(",") + val hideQr = expanded.hideQr.joinToString(",") + val warehouseSetting = if (scope == SCOPE_DO) { + DoFloorSupplierSettingsService.SETTING_DO_PICK_EXCLUDE_WAREHOUSES + } else { + DoFloorSupplierSettingsService.SETTING_JO_PICK_EXCLUDE_WAREHOUSES + } + val hideQrSetting = if (scope == SCOPE_DO) { + DoFloorSupplierSettingsService.SETTING_DO_PICK_EXCLUDE_HIDE_QR + } else { + DoFloorSupplierSettingsService.SETTING_JO_PICK_EXCLUDE_HIDE_QR + } + val printModeSetting = if (scope == SCOPE_DO) { + DoFloorSupplierSettingsService.SETTING_DO_PICK_EXCLUDE_PRINT_MODE + } else { + DoFloorSupplierSettingsService.SETTING_JO_PICK_EXCLUDE_PRINT_MODE + } + writeSetting(warehouseSetting, if (csv.length <= SETTINGS_VALUE_LIMIT) csv.ifEmpty { EMPTY } else EMPTY) + writeSetting(hideQrSetting, if (hideQr.length <= SETTINGS_VALUE_LIMIT) hideQr.ifEmpty { EMPTY } else EMPTY) + val mode = if (expanded.all.isNotEmpty() && expanded.hideQr.size == expanded.all.size) { + DoFloorSupplierSettingsService.PRINT_MODE_HIDE_QR + } else { + DoFloorSupplierSettingsService.PRINT_MODE_HIDE_LIST + } + writeSetting(printModeSetting, mode) + } + + private fun loadRows(scope: String): List = + pickExcludeRuleRepository.findByScopeAndDeletedIsFalseOrderByIdAsc(scope) + + private fun rowsToDtos(rows: List): List = + rows.map { row -> + PickExcludeRuleDto( + floor = row.floor, + warehouse = row.warehouse, + areas = row.areas, + slots = row.slots, + printMode = row.printMode, + enabled = row.recordStatus == STATUS_USE, + cardId = row.cardId, + ) + } + + private fun parseRules(raw: String): List = + try { + objectMapper.readValue(raw, object : TypeReference>() {}).map { normalizeRule(it) } + } catch (_: Exception) { + emptyList() + } + + private fun normalizeRule(rule: PickExcludeRuleDto): PickExcludeRuleDto { + val cardId = rule.cardId.trim().ifEmpty { "c${System.nanoTime().toString(36)}" } + val mode = if (rule.printMode == DoFloorSupplierSettingsService.PRINT_MODE_HIDE_QR) { + DoFloorSupplierSettingsService.PRINT_MODE_HIDE_QR + } else { + DoFloorSupplierSettingsService.PRINT_MODE_HIDE_LIST + } + return rule.copy( + floor = rule.floor.trim(), + warehouse = rule.warehouse.trim(), + areas = rule.areas.trim(), + slots = rule.slots.trim(), + printMode = mode, + cardId = cardId, + ) + } + + private fun normalizeScope(scope: String): String { + val value = scope.trim().uppercase(Locale.ROOT) + return if (value == SCOPE_JO) SCOPE_JO else SCOPE_DO + } + + private fun rulesSetting(scope: String): String = + if (scope == SCOPE_JO) "JO.pick.excludeRules" else "DO.pick.excludeRules" + + private fun readSetting(name: String): String = + settingsRepository.findByName(name).map { it.value }.orElse(null)?.trim().orEmpty() + + private fun writeSetting(name: String, value: String) { + val row = settingsRepository.findByName(name).orElse(null) ?: return + row.value = value.ifEmpty { EMPTY } + settingsRepository.save(row) + } + + private fun tokens(raw: String): List = + raw.split(",").map { it.trim() }.filter { it.isNotEmpty() } +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/PickExcludeRuleController.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/PickExcludeRuleController.kt new file mode 100644 index 00000000..6ffda922 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/PickExcludeRuleController.kt @@ -0,0 +1,26 @@ +package com.ffii.fpsms.modules.deliveryOrder.web + +import com.ffii.fpsms.modules.deliveryOrder.service.PickExcludeRuleDto +import com.ffii.fpsms.modules.deliveryOrder.service.PickExcludeRuleService +import com.ffii.fpsms.modules.deliveryOrder.service.SavePickExcludeRulesRequest +import com.ffii.fpsms.modules.deliveryOrder.service.SavePickExcludeRulesResponse +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/settings/pick-exclude-rules") +class PickExcludeRuleController( + private val pickExcludeRuleService: PickExcludeRuleService, +) { + @GetMapping + fun list(@RequestParam scope: String): List = + pickExcludeRuleService.visibleRules(scope) + + @PostMapping + fun save(@RequestBody request: SavePickExcludeRulesRequest): SavePickExcludeRulesResponse = + pickExcludeRuleService.save(request) +} diff --git a/src/main/resources/db/changelog/changes/20261005_pick_exclude_rule/01_table.sql b/src/main/resources/db/changelog/changes/20261005_pick_exclude_rule/01_table.sql new file mode 100644 index 00000000..5927a2af --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261005_pick_exclude_rule/01_table.sql @@ -0,0 +1,28 @@ +--liquibase formatted sql +--changeset fpsms:20261005_pick_exclude_rule + +-- Active pick-exclude rules stay in settings when the JSON fits in varchar(1000). +-- `db` in DO/JO.pick.excludeRules means the rows here are the source. +-- record_status: use = applied, unused = disabled, deleted = removed from the screen. +-- unused + deleted are kept only up to 100 rows per scope. + +CREATE TABLE IF NOT EXISTS `pick_exclude_rule` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `version` INT NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL, + `createdBy` VARCHAR(255) NULL, + `modified` DATETIME NOT NULL, + `modifiedBy` VARCHAR(255) NULL, + `deleted` BIT(1) NOT NULL DEFAULT 0, + `scope` VARCHAR(2) NOT NULL, + `floor` VARCHAR(30) NOT NULL, + `warehouse` VARCHAR(30) NOT NULL, + `areas` TEXT NOT NULL, + `slots` TEXT NOT NULL, + `print_mode` VARCHAR(20) NOT NULL, + `rule_enabled` BIT(1) NOT NULL, + `card_id` VARCHAR(64) NOT NULL, + `record_status` VARCHAR(10) NOT NULL, + PRIMARY KEY (`id`), + INDEX `pick_exclude_rule_scope_idx` (`scope`, `record_status`) +);