From bf5fc537fe8b9efefa98d2b16b43a7c7fce55606 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Thu, 3 Sep 2026 19:20:57 +0800 Subject: [PATCH 01/10] try to fix the onpack expiry date setting --- src/main/java/com/ffii/core/support/JdbcDao.java | 6 ++++-- .../modules/jobOrder/service/OnPackTemplateFileService.kt | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ffii/core/support/JdbcDao.java b/src/main/java/com/ffii/core/support/JdbcDao.java index c6854ff..cc227db 100644 --- a/src/main/java/com/ffii/core/support/JdbcDao.java +++ b/src/main/java/com/ffii/core/support/JdbcDao.java @@ -46,7 +46,8 @@ public class JdbcDao { */ public String queryForString(String sql, Map paramMap) { try { - return this.template.queryForObject(sql, paramMap, String.class); + String value = this.template.queryForObject(sql, paramMap, String.class); + return value != null ? value : StringUtils.EMPTY; } catch (EmptyResultDataAccessException e) { return StringUtils.EMPTY; } @@ -60,7 +61,8 @@ public class JdbcDao { */ public String queryForString(String sql, Object paramObj) { try { - return this.template.queryForObject(sql, new BeanPropertySqlParameterSource(paramObj), String.class); + String value = this.template.queryForObject(sql, new BeanPropertySqlParameterSource(paramObj), String.class); + return value != null ? value : StringUtils.EMPTY; } catch (EmptyResultDataAccessException e) { return StringUtils.EMPTY; } diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt index 2dc7790..a6df5b1 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt @@ -347,7 +347,7 @@ open class OnPackTemplateFileService( LIMIT 1 """.trimIndent(), mapOf("machine" to machine, "code" to itemCode), - ).trim().takeIf { it.isNotEmpty() } + ).orEmpty().trim().takeIf { it.isNotEmpty() } } catch (_: DataAccessException) { null } From 8eeccd35cd35a9f043053781f72373dca453a1e9 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Fri, 4 Sep 2026 00:15:03 +0800 Subject: [PATCH 02/10] make onpack zip for expiry now separate by -1, -2 ... --- .../service/PlasticBagPrinterService.kt | 104 ++++++++++++------ .../service/OnPackExpiryZipStemTest.kt | 32 ++++++ 2 files changed, 103 insertions(+), 33 deletions(-) create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt index 862c8be..33b6d49 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt @@ -69,6 +69,8 @@ data class OnPackZipResult( private data class OnPackBmpExportItem( val codeLower: String, + /** ZIP / template file stem (e.g. `pp2211` or `pp2211-2` for expiry multi-JO). */ + val fileStem: String, val itemId: Long, val stockInLineId: Long, val itemCode: String, @@ -149,6 +151,19 @@ class PlasticBagPrinterService( fun laserAckLooksInvalid(ack: String?): Boolean = ack?.contains("invalid", ignoreCase = true) == true + /** Expiry 汁水機 ZIP: one file set per job order, ordered by job order no then id. */ + fun sortOnPackExportJobOrders( + orders: List, + jobOrdersById: Map, + ): List = + orders.sortedWith( + compareBy { jobOrdersById[it.jobOrderId]?.code?.trim().orEmpty() } + .thenBy { it.jobOrderId }, + ) + + fun onPackExpiryFileStem(itemCodeLower: String, indexOneBased: Int): String = + "$itemCodeLower-$indexOneBased" + /** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */ fun formatLaserExpiryParam(expiryDate: String?): String { val raw = expiryDate?.trim().orEmpty() @@ -285,6 +300,36 @@ class PlasticBagPrinterService( .toSet() } + private fun toOnPackBmpExportItem( + codeLower: String, + fileStem: String, + order: OnPackQrJobOrderRequest, + expiryPrintNames: Map, + jobOrder: JobOrder?, + ): OnPackBmpExportItem? { + val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(order.jobOrderId) + ?: return null + val itemId = stockInLine.item?.id ?: return null + val stockInLineId = stockInLine.id ?: return null + val jo = jobOrder ?: jobOrderRepository.findById(order.jobOrderId).orElse(null) + val baseName = jo?.bom?.name ?: stockInLine.item?.name + val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc + val computedName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc) + val itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase() + val productName = expiryPrintNames[itemCode]?.trim()?.takeIf { it.isNotEmpty() } + ?: computedName + ?: itemCode + return OnPackBmpExportItem( + codeLower = codeLower, + fileStem = fileStem, + itemId = itemId, + stockInLineId = stockInLineId, + itemCode = itemCode, + productName = productName, + planDate = jo?.planStart?.toLocalDate(), + ) + } + /** * Bag2.py [send_job_to_laser] / [send_job_to_laser_with_retry]: UTF-8 TCP payload and optional ack read. */ @@ -725,30 +770,23 @@ class PlasticBagPrinterService( emptyMap() } + val jobOrdersById = jobOrderRepository.findAllById( + packagingJobOrders.map { it.jobOrderId }.distinct(), + ).associateBy { it.id!! } val exportItemsRaw = packagingJobOrders .groupBy { it.itemCode.trim().lowercase() } - .mapNotNull { (codeLower, orders) -> - val order = orders.firstOrNull() ?: return@mapNotNull null - val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(order.jobOrderId) - ?: return@mapNotNull null - val itemId = stockInLine.item?.id ?: return@mapNotNull null - val stockInLineId = stockInLine.id ?: return@mapNotNull null - val jo = jobOrderRepository.findById(order.jobOrderId).orElse(null) - val baseName = jo?.bom?.name ?: stockInLine.item?.name - val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc - val computedName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc) - val itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase() - val productName = expiryPrintNames[itemCode]?.trim()?.takeIf { it.isNotEmpty() } - ?: computedName - ?: itemCode - OnPackBmpExportItem( - codeLower, - itemId, - stockInLineId, - itemCode, - productName, - jo?.planStart?.toLocalDate(), - ) + .flatMap { (codeLower, orders) -> + val sorted = sortOnPackExportJobOrders(orders, jobOrdersById) + val selected = if (includeExpiry) sorted else listOfNotNull(sorted.firstOrNull()) + selected.mapIndexedNotNull { index, order -> + toOnPackBmpExportItem( + codeLower = codeLower, + fileStem = if (includeExpiry) onPackExpiryFileStem(codeLower, index + 1) else codeLower, + order = order, + expiryPrintNames = expiryPrintNames, + jobOrder = jobOrdersById[order.jobOrderId], + ) + } } require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } @@ -808,12 +846,12 @@ class PlasticBagPrinterService( ZipOutputStream(baos).use { zos -> val addedEntries = linkedSetOf() exportItems.forEach { item -> - val codeLower = item.codeLower + val fileStem = item.fileStem val imageTemplate = if (includeExpiry) { val master = expiryMasterImage ?: return@forEach - OnPackPp1181Master.rewriteImageBytes(master, codeLower) + OnPackPp1181Master.rewriteImageBytes(master, fileStem) } else { - loadOnPackImageTemplateOrNull(codeLower, forExpiry = false) ?: return@forEach + loadOnPackImageTemplateOrNull(item.codeLower, forExpiry = false) ?: return@forEach } val qrContent = """{"itemId": ${item.itemId}, "stockInLineId": ${item.stockInLineId}}""" @@ -821,13 +859,13 @@ class PlasticBagPrinterService( // Width = 386 + (42 * 2) = 470 // Height = 386 + (1 * 2) = 388 (~389) val bmp = createQrCodeBitmap(qrContent, contentSize = 386, horizontalPadding = 42, verticalPadding = 1) - val qrBmpFileName = "${codeLower}qr.bmp" - val imageFileName = "$codeLower.image" + val qrBmpFileName = "${fileStem}qr.bmp" + val imageFileName = "$fileStem.image" var imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName) if (includeExpiry) { - val productFile = "${codeLower}Product.bmp" - val codeFile = "${codeLower}Code.bmp" - val dateFile = "${codeLower}Date.bmp" + val productFile = "${fileStem}Product.bmp" + val codeFile = "${fileStem}Code.bmp" + val dateFile = "${fileStem}Date.bmp" val productBmp = createMonochromeBitmapFixed( item.productName, ONPACK_PRODUCT_BMP_WIDTH, @@ -857,7 +895,7 @@ class PlasticBagPrinterService( } } val expiryLabel = if (includeExpiry) { - itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate) + itemDefaultShelfLifeService.expiryDatePrintLabel(item.itemCode, effectivePrintDate) } else { null } @@ -867,7 +905,7 @@ class PlasticBagPrinterService( ONPACK_EXPIRY_BMP_WIDTH, ONPACK_EXPIRY_BMP_HEIGHT, ) - val expBmpFileName = "${codeLower}exp.bmp" + val expBmpFileName = "${fileStem}exp.bmp" imageContent = withOnPackExpiryLogo(imageContent, expBmpFileName, expBmp.width) if (addedEntries.add(expBmpFileName)) { addToZip(zos, expBmpFileName, expBmp.bytes) @@ -893,7 +931,7 @@ class PlasticBagPrinterService( addToZip(zos, bmpName, bmpBytes) } } - val jobFileName = "${codeLower}.job" + val jobFileName = "$fileStem.job" if (includeExpiry) { val masterJob = expiryMasterJob if (masterJob != null && addedEntries.add(jobFileName)) { diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt new file mode 100644 index 0000000..0ec0a81 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt @@ -0,0 +1,32 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import com.ffii.fpsms.modules.jobOrder.entity.JobOrder +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class OnPackExpiryZipStemTest { + + @Test + fun expiry_file_stem_appends_one_based_index() { + assertEquals("pp2211-1", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 1)) + assertEquals("pp2211-2", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 2)) + } + + @Test + fun export_job_orders_sort_by_job_order_code_then_id() { + val jo10 = JobOrder().apply { id = 10L; code = "JO-002" } + val jo20 = JobOrder().apply { id = 20L; code = "JO-001" } + val jo30 = JobOrder().apply { id = 30L; code = "JO-001" } + val byId = mapOf(10L to jo10, 20L to jo20, 30L to jo30) + val sorted = PlasticBagPrinterService.sortOnPackExportJobOrders( + listOf( + OnPackQrJobOrderRequest(10, "PP2211"), + OnPackQrJobOrderRequest(30, "PP2211"), + OnPackQrJobOrderRequest(20, "PP2211"), + ), + byId, + ) + assertEquals(listOf(20L, 30L, 10L), sorted.map { it.jobOrderId }) + } +} From bf9cdd15bb0702ac1a9e17ba74ab8575b8c64eb8 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Fri, 4 Sep 2026 00:29:13 +0800 Subject: [PATCH 03/10] refine the onpack zip expiry --- .../jobOrder/service/PlasticBagPrinterService.kt | 15 +++++++++++---- .../jobOrder/service/OnPackExpiryZipStemTest.kt | 7 ++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt index 33b6d49..d4a3834 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt @@ -161,8 +161,8 @@ class PlasticBagPrinterService( .thenBy { it.jobOrderId }, ) - fun onPackExpiryFileStem(itemCodeLower: String, indexOneBased: Int): String = - "$itemCodeLower-$indexOneBased" + fun onPackExpiryFileStem(itemCodeLower: String, indexOneBased: Int, totalForItem: Int): String = + if (totalForItem > 1) "$itemCodeLower-$indexOneBased" else itemCodeLower /** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */ fun formatLaserExpiryParam(expiryDate: String?): String { @@ -778,15 +778,22 @@ class PlasticBagPrinterService( .flatMap { (codeLower, orders) -> val sorted = sortOnPackExportJobOrders(orders, jobOrdersById) val selected = if (includeExpiry) sorted else listOfNotNull(sorted.firstOrNull()) - selected.mapIndexedNotNull { index, order -> + val items = selected.mapNotNull { order -> toOnPackBmpExportItem( codeLower = codeLower, - fileStem = if (includeExpiry) onPackExpiryFileStem(codeLower, index + 1) else codeLower, + fileStem = codeLower, order = order, expiryPrintNames = expiryPrintNames, jobOrder = jobOrdersById[order.jobOrderId], ) } + if (includeExpiry) { + items.mapIndexed { index, item -> + item.copy(fileStem = onPackExpiryFileStem(codeLower, index + 1, items.size)) + } + } else { + items + } } require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt index 0ec0a81..92da893 100644 --- a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt @@ -8,9 +8,10 @@ import org.junit.jupiter.api.Test class OnPackExpiryZipStemTest { @Test - fun expiry_file_stem_appends_one_based_index() { - assertEquals("pp2211-1", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 1)) - assertEquals("pp2211-2", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 2)) + fun expiry_file_stem_suffixes_only_when_item_has_more_than_one_job() { + assertEquals("pp2211", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 1, 1)) + assertEquals("pp2211-1", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 1, 2)) + assertEquals("pp2211-2", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 2, 2)) } @Test From f546af6ed0a56ae0846b48b4149631dfbc13feda Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Fri, 4 Sep 2026 00:33:31 +0800 Subject: [PATCH 04/10] refine onpack zip expiry --- .../modules/jobOrder/service/PlasticBagPrinterService.kt | 2 +- .../modules/jobOrder/service/OnPackExpiryZipStemTest.kt | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt index d4a3834..beebf47 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt @@ -162,7 +162,7 @@ class PlasticBagPrinterService( ) fun onPackExpiryFileStem(itemCodeLower: String, indexOneBased: Int, totalForItem: Int): String = - if (totalForItem > 1) "$itemCodeLower-$indexOneBased" else itemCodeLower + if (totalForItem > 1 && indexOneBased > 1) "$itemCodeLower-$indexOneBased" else itemCodeLower /** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */ fun formatLaserExpiryParam(expiryDate: String?): String { diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt index 92da893..ce88952 100644 --- a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt @@ -8,10 +8,11 @@ import org.junit.jupiter.api.Test class OnPackExpiryZipStemTest { @Test - fun expiry_file_stem_suffixes_only_when_item_has_more_than_one_job() { + fun expiry_file_stem_suffixes_from_second_job_only() { assertEquals("pp2211", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 1, 1)) - assertEquals("pp2211-1", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 1, 2)) + assertEquals("pp2211", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 1, 2)) assertEquals("pp2211-2", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 2, 2)) + assertEquals("pp2211-3", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 3, 3)) } @Test From 8f99f1b2584d87a659de2ac851c8c3af83748126 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Fri, 4 Sep 2026 00:53:00 +0800 Subject: [PATCH 05/10] refining the onpack zip --- .../service/PlasticBagPrinterService.kt | 18 +++++++++++++++--- .../service/OnPackExpiryZipStemTest.kt | 10 +++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt index beebf47..3a57467 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt @@ -69,11 +69,13 @@ data class OnPackZipResult( private data class OnPackBmpExportItem( val codeLower: String, - /** ZIP / template file stem (e.g. `pp2211` or `pp2211-2` for expiry multi-JO). */ + /** ZIP / template file stem. First JO stays `pp2211` so operators select that template. */ val fileStem: String, val itemId: Long, val stockInLineId: Long, val itemCode: String, + /** Code BMP text. When that item has 2+ JOs, first bag shows `PP2211-1`. */ + val printItemCode: String, val productName: String, val planDate: LocalDate?, ) @@ -164,6 +166,11 @@ class PlasticBagPrinterService( fun onPackExpiryFileStem(itemCodeLower: String, indexOneBased: Int, totalForItem: Int): String = if (totalForItem > 1 && indexOneBased > 1) "$itemCodeLower-$indexOneBased" else itemCodeLower + fun onPackExpiryPrintCode(itemCode: String, indexOneBased: Int, totalForItem: Int): String { + val code = itemCode.trim().uppercase() + return if (totalForItem > 1) "$code-$indexOneBased" else code + } + /** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */ fun formatLaserExpiryParam(expiryDate: String?): String { val raw = expiryDate?.trim().orEmpty() @@ -325,6 +332,7 @@ class PlasticBagPrinterService( itemId = itemId, stockInLineId = stockInLineId, itemCode = itemCode, + printItemCode = itemCode, productName = productName, planDate = jo?.planStart?.toLocalDate(), ) @@ -789,7 +797,11 @@ class PlasticBagPrinterService( } if (includeExpiry) { items.mapIndexed { index, item -> - item.copy(fileStem = onPackExpiryFileStem(codeLower, index + 1, items.size)) + val n = index + 1 + item.copy( + fileStem = onPackExpiryFileStem(codeLower, n, items.size), + printItemCode = onPackExpiryPrintCode(item.itemCode, n, items.size), + ) } } else { items @@ -879,7 +891,7 @@ class PlasticBagPrinterService( ONPACK_PRODUCT_BMP_HEIGHT, ) val codeBmp = createMonochromeBitmapFixed( - item.itemCode, + item.printItemCode, ONPACK_CODE_BMP_WIDTH, ONPACK_CODE_BMP_HEIGHT, ) diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt index ce88952..99ce7bf 100644 --- a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt @@ -8,13 +8,21 @@ import org.junit.jupiter.api.Test class OnPackExpiryZipStemTest { @Test - fun expiry_file_stem_suffixes_from_second_job_only() { + fun expiry_file_stem_keeps_first_as_plain_item_code() { assertEquals("pp2211", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 1, 1)) assertEquals("pp2211", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 1, 2)) assertEquals("pp2211-2", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 2, 2)) assertEquals("pp2211-3", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 3, 3)) } + @Test + fun expiry_print_code_shows_dash_one_on_first_bag_when_multiple() { + assertEquals("PP2211", PlasticBagPrinterService.onPackExpiryPrintCode("PP2211", 1, 1)) + assertEquals("PP2211-1", PlasticBagPrinterService.onPackExpiryPrintCode("pp2211", 1, 2)) + assertEquals("PP2211-2", PlasticBagPrinterService.onPackExpiryPrintCode("PP2211", 2, 2)) + assertEquals("PP2211-3", PlasticBagPrinterService.onPackExpiryPrintCode("PP2211", 3, 3)) + } + @Test fun export_job_orders_sort_by_job_order_code_then_id() { val jo10 = JobOrder().apply { id = 10L; code = "JO-002" } From 79f0b2852a4ef01c47e19943b68af978a197b5d0 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Mon, 7 Sep 2026 21:56:05 +0800 Subject: [PATCH 06/10] changed the sync of DO1 to 23:00 --- .../fpsms/modules/common/SettingNames.java | 1 + .../common/alert/SchedulerSyncAlertService.kt | 4 ++-- .../scheduler/service/SchedulerService.kt | 22 +++++++++++-------- .../01_update_do1_schedule_to_23.sql | 5 +++++ 4 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260907_do1_2300/01_update_do1_schedule_to_23.sql diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index 3adac3b..a80a077 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -25,6 +25,7 @@ public abstract class SettingNames { */ public static final String SCHEDULE_M18_PO = "SCHEDULE.m18.po"; + /** Mon–Fri & Sun DO1 time (default 23:00). Saturday uses [SCHEDULE_M18_DO1_SAT]. */ public static final String SCHEDULE_M18_DO1 = "SCHEDULE.m18.do1"; /** Saturday-only DO1 time (default 03:10). Mon–Fri & Sun use [SCHEDULE_M18_DO1] time via a second trigger. */ public static final String SCHEDULE_M18_DO1_SAT = "SCHEDULE.m18.do1.sat"; diff --git a/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt b/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt index dca5b13..caae5b3 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt @@ -229,10 +229,10 @@ open class SchedulerSyncAlertService( val cron = if (date.dayOfWeek == DayOfWeek.SATURDAY) { settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_SAT).getOrNull()?.value - ?: "0 10 3 ? * SAT" + ?: SchedulerService.DO1_SAT_DEFAULT_CRON } else { settingsService.findByName(SettingNames.SCHEDULE_M18_DO1).getOrNull()?.value - ?: "0 10 19 * * *" + ?: SchedulerService.DO1_DEFAULT_CRON } return scheduledTimeToday(cron, date) } diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index 875ce4b..de198a6 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt @@ -75,6 +75,11 @@ open class SchedulerService( @Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean, ) { companion object { + /** Mon–Fri & Sun DO1 (23:00). Saturday stays on [DO1_SAT_DEFAULT_CRON]. */ + const val DO1_DEFAULT_CRON: String = "0 0 23 * * *" + const val DO1_SAT_DEFAULT_CRON: String = "0 10 3 ? * SAT" + /** DO2 lastModified from yesterday this hour (aligns with weekday/Sun DO1). Sunday uses Sat 03:00. */ + const val DO2_MODIFIED_FROM_HOUR: Int = 23 /** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */ const val DO2_MODIFIED_TO_HOUR: Int = 13 const val DO2_DEFAULT_CRON: String = "0 0 13 * * *" @@ -178,18 +183,18 @@ open class SchedulerService( return } - var cronMain = settingsService.findByName(SettingNames.SCHEDULE_M18_DO1).getOrNull()?.value ?: "0 10 19 * * *" + var cronMain = settingsService.findByName(SettingNames.SCHEDULE_M18_DO1).getOrNull()?.value ?: DO1_DEFAULT_CRON if (!isValidCronExpression(cronMain)) { - cronMain = "0 10 19 * * *" + cronMain = DO1_DEFAULT_CRON } val weekdaySunCron = cronToMonFriSunSchedule(cronMain) val mainCron = if (isValidCronExpression(weekdaySunCron)) weekdaySunCron else cronMain scheduledM18Do1 = taskScheduler.schedule({ getM18Dos1() }, CronTrigger(mainCron)) - var cronSat = settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_SAT).getOrNull()?.value ?: "0 10 3 ? * SAT" + var cronSat = settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_SAT).getOrNull()?.value ?: DO1_SAT_DEFAULT_CRON if (!isValidCronExpression(cronSat)) { - cronSat = "0 10 3 ? * SAT" + cronSat = DO1_SAT_DEFAULT_CRON } scheduledM18Do1Sat = taskScheduler.schedule({ getM18Dos1() }, CronTrigger(cronSat)) @@ -743,14 +748,13 @@ open class SchedulerService( val ysd = today.minusDays(1L) val tmr = today.plusDays(1L) - // Default: lastModified from yesterday 19:00 through today's DO2 run hour (1pm; aligns with SCHEDULE.m18.do2). - // On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1 - // (otherwise Sat 03:00–18:59 would be skipped until a much later sync). + // Default: lastModified from yesterday 23:00 through today's DO2 run hour (1pm; aligns with weekday/Sun DO1). + // On Sunday, yesterday is Saturday: keep 03:00 so we include DO changed after Sat 03:10 DO1. val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY val modifiedFromStart = if (isSundayDo2) { ysd.withHour(3).withMinute(0).withSecond(0) } else { - ysd.withHour(19).withMinute(0).withSecond(0) + ysd.withHour(DO2_MODIFIED_FROM_HOUR).withMinute(0).withSecond(0) } val modifiedDateToEnd = @@ -759,7 +763,7 @@ open class SchedulerService( logger.info( "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", modifiedFromStart.format(dateTimeStringFormat), - if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00", + if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 23:00", modifiedDateToEnd.format(dateTimeStringFormat), ) diff --git a/src/main/resources/db/changelog/changes/20260907_do1_2300/01_update_do1_schedule_to_23.sql b/src/main/resources/db/changelog/changes/20260907_do1_2300/01_update_do1_schedule_to_23.sql new file mode 100644 index 0000000..26d8c5c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260907_do1_2300/01_update_do1_schedule_to_23.sql @@ -0,0 +1,5 @@ +--liquibase formatted sql +--changeset fpsms:20260907_do1_schedule_2300 + +-- Mon–Fri & Sun DO1 → 23:00. Saturday SCHEDULE.m18.do1.sat (03:10) is unchanged. +UPDATE `settings` SET `value` = '0 0 23 * * *' WHERE `name` = 'SCHEDULE.m18.do1'; From fbe77690db10e690778321d209c6996bf156c41f Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 8 Sep 2026 17:37:03 +0800 Subject: [PATCH 07/10] =?UTF-8?q?=20=E9=81=8E=E6=9C=9F=E5=93=81=E8=99=95?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/PickExecutionIssueService.kt | 26 +- .../web/PickExecutionIssueController.kt | 40 ++- .../web/models/SubmitIssueRequest.kt | 3 + .../service/LotExpiryAlertExcelBuilder.kt | 247 ++++++++++++++++++ .../service/LotExpiryAlertReportService.kt | 126 +++++++++ .../web/LotExpiryAlertReportController.kt | 54 ++++ .../entity/InventoryLotLineRepository.kt | 14 +- .../stock/entity/StockLedgerRepository.kt | 4 + .../stock/service/StockIssueService.kt | 24 +- .../modules/stock/web/StockIssueController.kt | 4 + .../stock/web/model/StockIssueModels.kt | 2 + 11 files changed, 524 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertExcelBuilder.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertReportService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/web/LotExpiryAlertReportController.kt diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index f165f96..5fd8fcb 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -1814,24 +1814,30 @@ open fun getBadItemOnlyList(): List { return pickExecutionIssueRepository.findBadItemOnlyList(IssueCategory.lot_issue) } +/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ open fun getExpiryItemList( - expiryDate: LocalDate? = null, itemCode: String? = null, itemName: String? = null, + lotNo: String? = null, + daysAhead: Int? = null, ): List { val today = LocalDate.now() + val days = (daysAhead ?: 7).coerceIn(0, 365) + val untilDate = today.plusDays(days.toLong()) val normalizedItemCode = itemCode?.trim()?.takeIf { it.isNotEmpty() } val normalizedItemName = itemName?.trim()?.takeIf { it.isNotEmpty() } + val normalizedLotNo = lotNo?.trim()?.takeIf { it.isNotEmpty() } val lotLines = inventoryLotLineRepository.findExpiredItems( - today = today, - expiryDate = expiryDate, + untilDate = untilDate, itemCode = normalizedItemCode, itemName = normalizedItemName, + lotNo = normalizedLotNo, ) return lotLines.map { lotLine -> val lot = lotLine.inventoryLot val item = lot?.item // Get item from inventoryLot + val expiry = lot?.expiryDate ExpiryItemResponse( id = lotLine.id ?: 0L, itemId = item?.id ?: 0L, @@ -1840,8 +1846,10 @@ open fun getExpiryItemList( lotId = lot?.id ?: 0L, lotNo = lot?.lotNo, storeLocation = lotLine.warehouse?.code, // Construct from warehouse - expiryDate = lot?.expiryDate, - remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO) + expiryDate = expiry, + remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO), + uomDesc = lotLine.stockUom?.uom?.udfudesc, + canHandle = expiry != null && !expiry.isAfter(today), ) } } @@ -1993,6 +2001,7 @@ open fun submitBadItem(request: SubmitIssueRequest): MessageResponse { } } +/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ @Transactional(rollbackFor = [Exception::class]) open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse { try { @@ -2011,7 +2020,7 @@ open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse { val lot = lotLine.inventoryLot val today = LocalDate.now() - if (lot?.expiryDate == null || !lot.expiryDate!!.isBefore(today)) { + if (lot?.expiryDate == null || lot.expiryDate!!.isAfter(today)) { return MessageResponse( id = null, name = "Error", @@ -2201,6 +2210,7 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse { } } // Fix batchSubmitExpiryItem method (around line 945): +/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ @Transactional(rollbackFor = [Exception::class]) open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse { try { @@ -2209,8 +2219,8 @@ open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageRespon val lot = it.inventoryLot val today = LocalDate.now() lot?.expiryDate != null && - lot.expiryDate!!.isBefore(today) && - (it.inQty ?: BigDecimal.ZERO) != (it.outQty ?: BigDecimal.ZERO) + lot.expiryDate!!.let { !it.isAfter(today) } && + (it.inQty ?: BigDecimal.ZERO) > (it.outQty ?: BigDecimal.ZERO) } if (lotLines.isEmpty()) { diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt index 3447764..956b13d 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt @@ -6,15 +6,18 @@ import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssue import com.ffii.fpsms.modules.pickOrder.enums.PickExecutionIssueEnum import com.ffii.fpsms.modules.pickOrder.service.PickExecutionIssueService // 修复导入路径 import com.ffii.fpsms.modules.pickOrder.web.models.* -import org.springframework.format.annotation.DateTimeFormat +import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService +import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService.SearchFilters +import com.ffii.fpsms.modules.report.web.lotExpiryAlertExcelResponse +import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* -import java.time.LocalDate @RestController @RequestMapping("/pickExecution") class PickExecutionIssueController( - private val pickExecutionIssueService: PickExecutionIssueService + private val pickExecutionIssueService: PickExecutionIssueService, + private val lotExpiryAlertReportService: LotExpiryAlertReportService, ) { @PostMapping("/recordIssue") @@ -66,19 +69,44 @@ class PickExecutionIssueController( return pickExecutionIssueService.getBadItemList(issueCategory) } + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ @GetMapping("/issues/expiryItem") fun getExpiryItemIssues( - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) expiryDate: LocalDate?, @RequestParam(required = false) itemCode: String?, @RequestParam(required = false) itemName: String?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) daysAhead: Int?, ): List { return pickExecutionIssueService.getExpiryItemList( - expiryDate = expiryDate, itemCode = itemCode, itemName = itemName, + lotNo = lotNo, + daysAhead = daysAhead, ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ + @GetMapping("/issues/expiryItem/excel") + fun exportExpiryItemExcel( + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) itemName: String?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) bucket: String?, + @RequestParam(required = false) daysAhead: Int?, + ): ResponseEntity { + return lotExpiryAlertExcelResponse( + lotExpiryAlertReportService.exportExcel( + SearchFilters( + itemCode = itemCode, + itemName = itemName, + lotNo = lotNo, + bucket = bucket, + daysAhead = daysAhead, + ), + ), + ) + } + @PostMapping("/submitMissItem") fun submitMissItem(@RequestBody request: SubmitIssueRequest): MessageResponse { return pickExecutionIssueService.submitMissItem(request) @@ -99,11 +127,13 @@ fun batchSubmitBadItem(@RequestBody request: BatchSubmitIssueRequest): MessageRe return pickExecutionIssueService.batchSubmitBadItem(request) } + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ @PostMapping("/submitExpiryItem") fun submitExpiryItem(@RequestBody request: SubmitExpiryRequest): MessageResponse { return pickExecutionIssueService.submitExpiryItem(request) } + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ @PostMapping("/batchSubmitExpiryItem") fun batchSubmitExpiryItem(@RequestBody request: BatchSubmitExpiryRequest): MessageResponse { return pickExecutionIssueService.batchSubmitExpiryItem(request) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt index d1f67f4..049dd15 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt @@ -31,6 +31,9 @@ data class ExpiryItemResponse( val storeLocation: String?, val expiryDate: LocalDate?, val remainingQty: BigDecimal, + val uomDesc: String?, + /** True when expiryDate is today or earlier. */ + val canHandle: Boolean, ) data class LotIssueDetailRequest( val lotId: Long, diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertExcelBuilder.kt b/src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertExcelBuilder.kt new file mode 100644 index 0000000..7159446 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertExcelBuilder.kt @@ -0,0 +1,247 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService.SearchFilters +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.CellStyle +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.streaming.SXSSFWorkbook +import java.io.ByteArrayOutputStream +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +internal object LotExpiryAlertExcelBuilder { + private data class ExcelStyles( + val title: CellStyle, + val subtitle: CellStyle, + val header: CellStyle, + val text: CellStyle, + val center: CellStyle, + val number: CellStyle, + ) + + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ + fun build( + dbData: List>, + filters: SearchFilters, + ): ByteArray { + val workbook = SXSSFWorkbook(100) + workbook.setCompressTempFiles(true) + try { + val styles = createStyles(workbook) + val reportTitle = "到期警示報告" + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) + val headers = listOf( + "到期警示", + "樓層", + "到期日", + "貨品編號", + "貨品名稱", + "批號", + "倉庫", + "區域", + "儲位", + "剩餘數量", + "單位", + ) + val totalColumns = headers.size + var rowIndex = 0 + + val titleRow = sheet.createRow(rowIndex++) + titleRow.heightInPoints = 24f + titleRow.createCell(0).apply { + setCellValue(reportTitle) + cellStyle = styles.title + } + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + + val downloadedAt = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + val dateRow = sheet.createRow(rowIndex++) + dateRow.heightInPoints = 32f + dateRow.createCell(0).apply { + setCellValue("下載日期:$downloadedAt") + cellStyle = styles.subtitle + } + val dateEndCol = 3.coerceAtMost(totalColumns - 2) + if (dateEndCol > 0) { + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, dateEndCol)) + } + val filterStartCol = dateEndCol + 1 + val filterText = listOf( + "貨品編號=${displayFilter(filters.itemCode)}", + "貨品=${displayFilter(filters.itemName)}", + "批號=${displayFilter(filters.lotNo)}", + "未來天數=${filters.resolvedDaysAhead()}", + "到期分類=${displayBucket(filters.bucket, filters.resolvedDaysAhead())}", + ).joinToString(" ") + dateRow.createCell(filterStartCol).apply { + setCellValue("搜尋條件:$filterText") + cellStyle = styles.subtitle + } + if (filterStartCol < totalColumns - 1) { + sheet.addMergedRegion(CellRangeAddress(1, 1, filterStartCol, totalColumns - 1)) + } + + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headerRow.heightInPoints = 22f + headers.forEachIndexed { i, h -> + headerRow.createCell(i).apply { + setCellValue(h) + cellStyle = styles.header + } + } + + if (dbData.isEmpty()) { + val emptyRowIndex = rowIndex + val r = sheet.createRow(rowIndex++) + r.heightInPoints = 22f + r.createCell(0).apply { + setCellValue("查無資料") + cellStyle = styles.center + } + for (c in 1 until totalColumns) { + r.createCell(c).cellStyle = styles.center + } + sheet.addMergedRegion(CellRangeAddress(emptyRowIndex, emptyRowIndex, 0, totalColumns - 1)) + } else { + dbData.forEach { m -> + val r = sheet.createRow(rowIndex++) + r.heightInPoints = 18f + setTextCell(r, 0, m["expiryAlert"], styles.center) + setTextCell(r, 1, m["storeId"], styles.center) + setTextCell(r, 2, m["expiryDate"], styles.center) + setTextCell(r, 3, m["itemCode"], styles.text) + setTextCell(r, 4, m["itemName"], styles.text) + setTextCell(r, 5, m["lotNumber"], styles.text) + setTextCell(r, 6, m["warehouse"], styles.center) + setTextCell(r, 7, m["area"], styles.center) + setTextCell(r, 8, m["slot"], styles.center) + setNumberCell(r, 9, m["remainingQty"], styles.number) + setTextCell(r, 10, m["unitOfMeasure"], styles.center) + } + } + + val lastRowIndex = rowIndex - 1 + if (lastRowIndex >= headerRowIndex) { + sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, totalColumns - 1)) + } + sheet.createFreezePane(0, headerRowIndex + 1) + intArrayOf(14, 10, 12, 14, 28, 18, 12, 10, 10, 12, 16) + .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + + val out = ByteArrayOutputStream() + workbook.write(out) + return out.toByteArray() + } finally { + workbook.dispose() + workbook.close() + } + } + + private fun createStyles(workbook: Workbook): ExcelStyles { + val numberFormat = workbook.createDataFormat().getFormat("#,##0.00;(#,##0.00)") + fun borders(style: CellStyle) { + style.borderTop = BorderStyle.THIN + style.borderBottom = BorderStyle.THIN + style.borderLeft = BorderStyle.THIN + style.borderRight = BorderStyle.THIN + style.verticalAlignment = VerticalAlignment.CENTER + } + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 16 + } + setFont(font) + } + val subtitleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + wrapText = true + val font = workbook.createFont().apply { + fontHeightInPoints = 10 + } + setFont(font) + } + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + wrapText = true + borders(this) + fillForegroundColor = IndexedColors.DARK_TEAL.index + fillPattern = FillPatternType.SOLID_FOREGROUND + val font = workbook.createFont().apply { + bold = true + color = IndexedColors.WHITE.index + fontHeightInPoints = 10 + } + setFont(font) + } + val textStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + borders(this) + } + val centerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + borders(this) + } + val numberStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + borders(this) + dataFormat = numberFormat + } + return ExcelStyles( + title = titleStyle, + subtitle = subtitleStyle, + header = headerStyle, + text = textStyle, + center = centerStyle, + number = numberStyle, + ) + } + + private fun displayFilter(raw: String?): String { + val v = raw?.trim().orEmpty() + return if (v.isEmpty()) "全部" else v + } + + private fun displayBucket(raw: String?, daysAhead: Int): String { + return when (raw?.trim()?.lowercase()) { + "expired" -> "過期尚未處理" + "today" -> "今日到期" + "upcoming" -> "未來 ${daysAhead} 日到期" + else -> "全部" + } + } + + private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { + row.createCell(col).apply { + setCellValue(value?.toString() ?: "") + cellStyle = style + } + } + + private fun setNumberCell(row: Row, col: Int, value: Any?, numberStyle: CellStyle) { + val cell = row.createCell(col) + val n = when (value) { + null -> null + is Number -> value.toDouble() + else -> value.toString().replace(",", "").toDoubleOrNull() + } + if (n == null) { + cell.setCellValue("") + cell.cellStyle = numberStyle + return + } + cell.setCellValue(n) + cell.cellStyle = numberStyle + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertReportService.kt new file mode 100644 index 0000000..b388c42 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/LotExpiryAlertReportService.kt @@ -0,0 +1,126 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Service + +@Service +open class LotExpiryAlertReportService( + private val jdbcDao: JdbcDao, +) { + data class SearchFilters( + val itemCode: String? = null, + val itemName: String? = null, + val lotNo: String? = null, + val bucket: String? = null, + val daysAhead: Int? = null, + ) { + fun resolvedDaysAhead(): Int { + val n = daysAhead ?: DEFAULT_DAYS_AHEAD + return n.coerceIn(0, MAX_DAYS_AHEAD) + } + + companion object { + const val DEFAULT_DAYS_AHEAD = 7 + const val MAX_DAYS_AHEAD = 365 + } + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ + fun search( + itemCode: String? = null, + itemName: String? = null, + lotNo: String? = null, + bucket: String? = null, + daysAhead: Int? = null, + ): List> = search( + SearchFilters( + itemCode = itemCode, + itemName = itemName, + lotNo = lotNo, + bucket = bucket, + daysAhead = daysAhead, + ), + ) + + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ + fun search(filters: SearchFilters): List> { + val args = mutableMapOf() + val daysAhead = filters.resolvedDaysAhead() + args["daysAhead"] = daysAhead + val extraWhere = buildString { + val itemCode = filters.itemCode?.trim().orEmpty() + if (itemCode.isNotEmpty()) { + args["itemCode"] = itemCode + append(" AND LOWER(i.code) LIKE LOWER(CONCAT('%', :itemCode, '%'))") + } + val itemName = filters.itemName?.trim().orEmpty() + if (itemName.isNotEmpty()) { + args["itemName"] = itemName + append(" AND LOWER(i.name) LIKE LOWER(CONCAT('%', :itemName, '%'))") + } + val lotNo = filters.lotNo?.trim().orEmpty() + if (lotNo.isNotEmpty()) { + args["lotNo"] = lotNo + append(" AND LOWER(il.lotNo) LIKE LOWER(CONCAT('%', :lotNo, '%'))") + } + when (filters.bucket?.trim()?.lowercase()) { + "expired" -> append(" AND il.expiryDate < CURRENT_DATE") + "today" -> append(" AND il.expiryDate = CURRENT_DATE") + "upcoming" -> append( + " AND il.expiryDate > CURRENT_DATE AND il.expiryDate <= DATE_ADD(CURRENT_DATE, INTERVAL :daysAhead DAY)", + ) + } + } + return jdbcDao.queryForList( + """ + SELECT + COALESCE(i.code, '') AS itemCode, + COALESCE(i.name, '') AS itemName, + COALESCE(il.lotNo, '') AS lotNumber, + COALESCE(w.store_id, '') AS storeId, + COALESCE(w.warehouse, '') AS warehouse, + COALESCE(w.area, '') AS area, + COALESCE(w.slot, '') AS slot, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS remainingQty, + COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, + CASE + WHEN il.expiryDate IS NULL THEN '' + WHEN il.expiryDate < CURRENT_DATE THEN '已過期' + WHEN il.expiryDate = CURRENT_DATE THEN '今日到期' + WHEN il.expiryDate <= DATE_ADD(CURRENT_DATE, INTERVAL :daysAhead DAY) + THEN '${daysAhead}日內到期' + ELSE '' + END AS expiryAlert + FROM inventory_lot_line ill + LEFT JOIN inventory_lot il ON ill.inventoryLotId = il.id + LEFT JOIN warehouse w ON ill.warehouseId = w.id + LEFT JOIN items i ON il.itemId = i.id + LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId AND iu.deleted = 0 + LEFT JOIN uom_conversion uc ON uc.id = iu.uomId + WHERE (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) > 0 + AND COALESCE(ill.deleted, 0) = 0 + AND COALESCE(il.deleted, 0) = 0 + AND COALESCE(i.deleted, 0) = 0 + AND il.expiryDate IS NOT NULL + AND il.expiryDate <= DATE_ADD(CURRENT_DATE, INTERVAL :daysAhead DAY) + $extraWhere + ORDER BY + CASE + WHEN il.expiryDate < CURRENT_DATE THEN 1 + WHEN il.expiryDate = CURRENT_DATE THEN 2 + ELSE 3 + END, + COALESCE(w.store_id, ''), + il.expiryDate, + COALESCE(i.code, '') + """.trimIndent(), + args, + ) as List> + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ + fun exportExcel(filters: SearchFilters = SearchFilters()): ByteArray { + return LotExpiryAlertExcelBuilder.build(search(filters), filters) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/LotExpiryAlertReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/LotExpiryAlertReportController.kt new file mode 100644 index 0000000..bcf7d29 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/LotExpiryAlertReportController.kt @@ -0,0 +1,54 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService +import com.ffii.fpsms.modules.report.service.LotExpiryAlertReportService.SearchFilters +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +internal fun lotExpiryAlertExcelResponse(bytes: ByteArray): ResponseEntity { + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + setContentDispositionFormData("attachment", "LotExpiryAlertReport.xlsx") + set("filename", "LotExpiryAlertReport.xlsx") + } + return ResponseEntity(bytes, headers, HttpStatus.OK) +} + +/** + * Lot expiry alert (Excel only). + * Excel: /report/print-lot-expiry-alert-excel + */ +@RestController +@RequestMapping("/report") +class LotExpiryAlertReportController( + private val lotExpiryAlertReportService: LotExpiryAlertReportService, +) { + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ + @GetMapping("/print-lot-expiry-alert-excel") + fun exportExcel( + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) itemName: String?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) bucket: String?, + @RequestParam(required = false) daysAhead: Int?, + ): ResponseEntity { + val excelBytes = lotExpiryAlertReportService.exportExcel( + SearchFilters( + itemCode = itemCode, + itemName = itemName, + lotNo = lotNo, + bucket = bucket, + daysAhead = daysAhead, + ), + ) + return lotExpiryAlertExcelResponse(excelBytes) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index 630b6d6..7b5ff3e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -220,27 +220,29 @@ WHERE ill.id = :id @EntityGraph( type = EntityGraph.EntityGraphType.FETCH, - attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"] + attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse", "stockUom", "stockUom.uom"] ) @Query(""" SELECT ill FROM InventoryLotLine ill JOIN ill.inventoryLot il JOIN il.item i - WHERE il.expiryDate < :today - AND (:expiryDate IS NULL OR il.expiryDate = :expiryDate) + WHERE il.expiryDate IS NOT NULL + AND il.expiryDate <= :untilDate AND (:itemCode IS NULL OR LOWER(i.code) LIKE LOWER(CONCAT('%', :itemCode, '%'))) AND (:itemName IS NULL OR LOWER(i.name) LIKE LOWER(CONCAT('%', :itemName, '%'))) - AND coalesce(ill.inQty, 0) <> coalesce(ill.outQty, 0) + AND (:lotNo IS NULL OR LOWER(il.lotNo) LIKE LOWER(CONCAT('%', :lotNo, '%'))) + AND coalesce(ill.inQty, 0) > coalesce(ill.outQty, 0) AND ill.deleted = false AND il.deleted = false AND i.deleted = false ORDER BY il.expiryDate ASC """) + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ fun findExpiredItems( - @Param("today") today: LocalDate, - @Param("expiryDate") expiryDate: LocalDate?, + @Param("untilDate") untilDate: LocalDate, @Param("itemCode") itemCode: String?, @Param("itemName") itemName: String?, + @Param("lotNo") lotNo: String?, ): List /** diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt index 1f4a276..f5b3027 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt @@ -103,6 +103,8 @@ fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLe AND (:lotNo IS NULL OR il.lotNo LIKE CONCAT('%', :lotNo, '%')) AND (:startDate IS NULL OR il.expiryDate >= :startDate) AND (:endDateExclusive IS NULL OR il.expiryDate < :endDateExclusive) + AND (:handledStartDate IS NULL OR sl.date >= :handledStartDate) + AND (:handledEndDateExclusive IS NULL OR sl.date < :handledEndDateExclusive) ORDER BY sl.date DESC, sl.id DESC """) fun findExpiryItemHandleRecords( @@ -111,6 +113,8 @@ fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLe @Param("lotNo") lotNo: String?, @Param("startDate") startDate: LocalDate?, @Param("endDateExclusive") endDateExclusive: LocalDate?, + @Param("handledStartDate") handledStartDate: LocalDate?, + @Param("handledEndDateExclusive") handledEndDateExclusive: LocalDate?, pageable: Pageable, ): Page } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt index bceb4dc..1ba9e02 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt @@ -86,8 +86,20 @@ open class StockIssueService( return searchHandleRecords(request, "Bad") } + /** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.1 | 2026-09-07 */ open fun getExpiryItemHandleRecords(request: SearchStockIssueRecordRequest): RecordsRes { - val (startDate, endDateExclusive) = resolveDateRange(request.startDate, request.endDate) + val hasHandledDateFilter = + request.handledStartDate != null || request.handledEndDate != null + val (startDate, endDateExclusive) = + if (hasHandledDateFilter && request.startDate == null && request.endDate == null) { + Pair(null, null) + } else { + resolveDateRange(request.startDate, request.endDate) + } + val (handledStartDate, handledEndDateExclusive) = resolveOptionalDateRange( + request.handledStartDate, + request.handledEndDate, + ) val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() } val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() } val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } @@ -103,6 +115,8 @@ open class StockIssueService( lotNo = lotNo, startDate = startDate, endDateExclusive = endDateExclusive, + handledStartDate = handledStartDate, + handledEndDateExclusive = handledEndDateExclusive, pageable = pageable, ) @@ -144,6 +158,14 @@ open class StockIssueService( return Pair(start, endInclusive.plusDays(1)) } + /** Inclusive start/end; no default window when both are empty. */ + private fun resolveOptionalDateRange(startDate: LocalDate?, endDate: LocalDate?): Pair { + if (startDate == null && endDate == null) return Pair(null, null) + val start = startDate ?: endDate + val endInclusive = endDate ?: startDate + return Pair(start, endInclusive?.plusDays(1)) + } + private fun toRecordResponse(ledger: StockLedger): StockIssueHandleRecordResponse { val stockOutLine = ledger.stockOutLine val lotLine = stockOutLine?.inventoryLotLine diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt index 33425fb..795123f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt @@ -48,6 +48,8 @@ class StockIssueController( fun getExpiryItemRecords( @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) handledStartDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) handledEndDate: LocalDate?, @RequestParam(required = false) itemCode: String?, @RequestParam(required = false) itemName: String?, @RequestParam(required = false) lotNo: String?, @@ -58,6 +60,8 @@ class StockIssueController( SearchStockIssueRecordRequest( startDate = startDate, endDate = endDate, + handledStartDate = handledStartDate, + handledEndDate = handledEndDate, itemCode = itemCode, itemName = itemName, lotNo = lotNo, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt index 91d7930..b8430bf 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt @@ -13,6 +13,8 @@ data class HandleBadItemRequest( data class SearchStockIssueRecordRequest( val startDate: LocalDate? = null, val endDate: LocalDate? = null, + val handledStartDate: LocalDate? = null, + val handledEndDate: LocalDate? = null, val itemCode: String? = null, val itemName: String? = null, val lotNo: String? = null, From d5b88deb09da09ef5ed71a360855191caf4a2fbb Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 8 Sep 2026 17:41:09 +0800 Subject: [PATCH 08/10] =?UTF-8?q?DO=20=E6=94=BE=E5=96=AE=20relate=20create?= =?UTF-8?q?d=20need=20write=20in=20username?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DoReleaseCoordinatorService.kt | 25 ++- .../service/DoWorkbenchReleaseService.kt | 183 +++++++++++------- 2 files changed, 142 insertions(+), 66 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt index e2ab2f4..c14a25f 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReleaseCoordinatorService.kt @@ -28,6 +28,8 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.AssignByDoPickOrderIdRequ import com.ffii.fpsms.modules.user.entity.UserRepository import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus import jakarta.persistence.OptimisticLockException import org.hibernate.StaleObjectStateException @@ -491,6 +493,7 @@ class DoReleaseCoordinatorService( return ids } } + /** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */ fun startBatchReleaseAsync(ids: List, userId: Long): MessageResponse { if (ids.isEmpty()) { return MessageResponse( @@ -502,10 +505,13 @@ class DoReleaseCoordinatorService( val jobId = UUID.randomUUID().toString() val status = BatchReleaseJobStatus(jobId = jobId, total = ids.size) jobs[jobId] = status + val workerSecurityContext = copySecurityContextForWorker() executor.submit { - batchReleaseConcurrencyGate.acquireUninterruptibly() try { + SecurityContextHolder.setContext(workerSecurityContext) + batchReleaseConcurrencyGate.acquireUninterruptibly() + try { try { println("Starting batch release for ${ids.size} orders (job $jobId)") val sortedIds = getOrderedDeliveryOrderIds(ids) @@ -643,6 +649,9 @@ class DoReleaseCoordinatorService( } finally { batchReleaseConcurrencyGate.release() } + } finally { + SecurityContextHolder.clearContext() + } } return MessageResponse( @@ -851,6 +860,7 @@ class DoReleaseCoordinatorService( ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */ fun startBatchReleaseAsyncSingle(doId: Long, userId: Long): MessageResponse { val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(doId) ?: return MessageResponse( @@ -858,8 +868,11 @@ class DoReleaseCoordinatorService( message = "Delivery Order not found", errorPosition = null, entity = null ) + val workerSecurityContext = copySecurityContextForWorker() + executor.submit { try { + SecurityContextHolder.setContext(workerSecurityContext) println("📦 Starting single release for DO $doId") // 调用 releaseDeliveryOrderWithoutTicket 创建 pick order @@ -958,6 +971,8 @@ class DoReleaseCoordinatorService( } catch (e: Exception) { println("❌ Single release exception: ${e.message}") e.printStackTrace() + } finally { + SecurityContextHolder.clearContext() } } @@ -968,6 +983,14 @@ class DoReleaseCoordinatorService( ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */ + private fun copySecurityContextForWorker(): SecurityContext { + val parent = SecurityContextHolder.getContext() + val copy = SecurityContextHolder.createEmptyContext() + copy.authentication = parent.authentication + return copy + } + private fun updateSingleTicketNumbers() { try { // 1. 查找所有 TEMP- 开头的 single release type 订单 diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt index c16ee15..f44fe12 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt @@ -9,6 +9,9 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchMergeTicketCandi import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService +import com.ffii.fpsms.modules.user.entity.UserRepository +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.Instant @@ -95,6 +98,7 @@ open class DoWorkbenchReleaseService( private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, + private val userRepository: UserRepository, ) { private val poolSize = Runtime.getRuntime().availableProcessors() private val executor = Executors.newFixedThreadPool(kotlin.math.min(poolSize, 4)) @@ -142,6 +146,7 @@ open class DoWorkbenchReleaseService( open fun startBatchReleaseAsyncSingleV2(ids: List, userId: Long): MessageResponse = startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "single") + /** FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 */ private fun startBatchReleaseAsyncInternal( ids: List, userId: Long, @@ -164,75 +169,83 @@ open class DoWorkbenchReleaseService( val jobId = UUID.randomUUID().toString() val status = WorkbenchBatchReleaseJobStatus(jobId = jobId, total = ids.size) jobs[jobId] = status + val actor = resolveReleaseActor(userId) + // Capture auth before worker thread: BaseEntity @PrePersist needs SecurityContext for pick_order / lines. + val workerSecurityContext = copySecurityContextForWorker() executor.submit { - batchReleaseConcurrencyGate.acquireUninterruptibly() try { - val orderedIds = getOrderedDeliveryOrderIds(ids) - val successResults = mutableListOf() + SecurityContextHolder.setContext(workerSecurityContext) + batchReleaseConcurrencyGate.acquireUninterruptibly() + try { + val orderedIds = getOrderedDeliveryOrderIds(ids) + val successResults = mutableListOf() - orderedIds.forEach { deliveryOrderId -> - try { - val statusRows = jdbcDao.queryForList( - """ - SELECT status - FROM fpsmsdb.delivery_order - WHERE id = :id AND deleted = 0 - """.trimIndent(), - mapOf("id" to deliveryOrderId) - ) - val currentStatus = statusRows.firstOrNull()?.get("status")?.toString()?.lowercase() - if (currentStatus == DeliveryOrderStatus.COMPLETED.value || currentStatus == DeliveryOrderStatus.RECEIVING.value) { - return@forEach - } + orderedIds.forEach { deliveryOrderId -> + try { + val statusRows = jdbcDao.queryForList( + """ + SELECT status + FROM fpsmsdb.delivery_order + WHERE id = :id AND deleted = 0 + """.trimIndent(), + mapOf("id" to deliveryOrderId) + ) + val currentStatus = statusRows.firstOrNull()?.get("status")?.toString()?.lowercase() + if (currentStatus == DeliveryOrderStatus.COMPLETED.value || currentStatus == DeliveryOrderStatus.RECEIVING.value) { + return@forEach + } - val released = releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId, userId, useV2) + val released = releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId, userId, useV2) - if (released != null) { - successResults += released - status.success.incrementAndGet() - } else { + if (released != null) { + successResults += released + status.success.incrementAndGet() + } else { + synchronized(status.failed) { + status.failed.add(deliveryOrderId to "Release returned null") + } + } + } catch (e: Exception) { synchronized(status.failed) { - status.failed.add(deliveryOrderId to "Release returned null") + status.failed.add(deliveryOrderId to (e.message ?: "Unknown error")) } } - } catch (e: Exception) { - synchronized(status.failed) { - status.failed.add(deliveryOrderId to (e.message ?: "Unknown error")) - } } - } - - try { - createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType, mergeExtraIntoLaneTicket) - } catch (e: Exception) { - // header-link failure shouldn't crash job; status.failed already includes per-DO failures - println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}") - } - val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults) + try { + createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType, mergeExtraIntoLaneTicket, actor) + } catch (e: Exception) { + // header-link failure shouldn't crash job; status.failed already includes per-DO failures + println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}") + } - if (!useV2) { - val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet() - pickOrdersForDownstream.forEach { pickOrderId -> - try { - suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrder(pickOrderId) - stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(pickOrderId, userId) - } catch (e: Exception) { - val deliveryOrderId = successResults.firstOrNull { it.pickOrderId == pickOrderId }?.deliveryOrderId - ?: 0L - synchronized(status.failed) { - status.failed.add( - deliveryOrderId to ("Downstream workbench step failed for pick order $pickOrderId: ${e.message}") - ) + val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults) + + if (!useV2) { + val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet() + pickOrdersForDownstream.forEach { pickOrderId -> + try { + suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrder(pickOrderId) + stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(pickOrderId, userId) + } catch (e: Exception) { + val deliveryOrderId = successResults.firstOrNull { it.pickOrderId == pickOrderId }?.deliveryOrderId + ?: 0L + synchronized(status.failed) { + status.failed.add( + deliveryOrderId to ("Downstream workbench step failed for pick order $pickOrderId: ${e.message}") + ) + } } } } + } finally { + status.running = false + status.finishedAt = Instant.now().toEpochMilli() + batchReleaseConcurrencyGate.release() } } finally { - status.running = false - status.finishedAt = Instant.now().toEpochMilli() - batchReleaseConcurrencyGate.release() + SecurityContextHolder.clearContext() } } @@ -318,6 +331,7 @@ open class DoWorkbenchReleaseService( ) } + val actor = resolveReleaseActor(userId) val orderedIds = getOrderedDeliveryOrderIds(ids) val successResults = mutableListOf() val failed = mutableListOf>() @@ -350,7 +364,7 @@ open class DoWorkbenchReleaseService( } } - val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch", mergeExtraIntoLaneTicket) + val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch", mergeExtraIntoLaneTicket, actor) val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults) if (!useV2) { val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet() @@ -504,6 +518,37 @@ open class DoWorkbenchReleaseService( } } + /** + * FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 + * Username for ticket createdBy/modifiedBy. Capture on the request thread, then copy onto the worker. + */ + private fun resolveReleaseActor(userId: Long): String { + val fromSecurity = SecurityContextHolder.getContext().authentication?.name + ?.trim() + ?.takeIf { it.isNotEmpty() && !it.equals("anonymousUser", ignoreCase = true) } + ?.take(30) + if (fromSecurity != null) return fromSecurity + if (userId > 0L) { + val fromUser = userRepository.findById(userId).orElse(null)?.username + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.take(30) + if (fromUser != null) return fromUser + } + return "system" + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 + * Copy auth for the worker thread so BaseEntity @PrePersist matches JO / consumable pick-order creation. + */ + private fun copySecurityContextForWorker(): SecurityContext { + val parent = SecurityContextHolder.getContext() + val copy = SecurityContextHolder.createEmptyContext() + copy.authentication = parent.authentication + return copy + } + private fun resolveTicketFloorSegment(storeId: String?, isDefaultTruckLane: Boolean): String = if (isDefaultTruckLane) { WORKBENCH_TICKET_FLOOR_SEGMENT_DEFAULT_TRUCK @@ -837,7 +882,7 @@ open class DoWorkbenchReleaseService( } } - private fun setRelationshipIdSelf(headerId: Long) { + private fun setRelationshipIdSelf(headerId: Long, actor: String) { jdbcDao.executeUpdate( """ UPDATE fpsmsdb.delivery_order_pick_order @@ -849,7 +894,7 @@ open class DoWorkbenchReleaseService( mapOf( "headerId" to headerId, "modified" to LocalDateTime.now(), - "modifiedBy" to "system", + "modifiedBy" to actor, ), ) } @@ -914,10 +959,11 @@ open class DoWorkbenchReleaseService( isSingleRelease: Boolean, requiredDate: LocalDate, ticketFloorSegment: String, + actor: String, ): Long? { val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment) val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease) - return insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseType, ticketNo) + return insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseType, ticketNo, actor) } private fun linkPickOrdersToHeader(headerId: Long, group: List) { @@ -941,6 +987,7 @@ open class DoWorkbenchReleaseService( storeId: String?, releaseTypeCol: String, ticketNo: String, + actor: String, ): Long? { val now = LocalDateTime.now() jdbcDao.executeUpdate( @@ -970,9 +1017,9 @@ open class DoWorkbenchReleaseService( "ticketNo" to ticketNo, "releaseType" to releaseTypeCol, "created" to now, - "createdBy" to "system", + "createdBy" to actor, "modified" to now, - "modifiedBy" to "system", + "modifiedBy" to actor, ) ) return jdbcDao.queryForList( @@ -984,7 +1031,7 @@ open class DoWorkbenchReleaseService( LIMIT 1 """.trimIndent(), mapOf("ticketNo" to ticketNo) - ).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it) } + ).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it, actor) } } /** @@ -997,6 +1044,7 @@ open class DoWorkbenchReleaseService( isSingleRelease: Boolean, requiredDate: LocalDate, ticketFloorSegment: String, + actor: String, ): Int { val activeMerge = findActiveMergeTicket(first, storeId, isSingleRelease) if (activeMerge != null) { @@ -1010,14 +1058,14 @@ open class DoWorkbenchReleaseService( val sourceToRetire = plainBatch ?: legacyUpgraded if (sourceToRetire != null) { - val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment) + val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment, actor) ?: return 0 retireSourceTicketIntoMergeHeader(sourceToRetire.id, mergeHeaderId) linkPickOrdersToHeader(mergeHeaderId, group) return 1 } - val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment) + val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment, actor) ?: return 0 linkPickOrdersToHeader(mergeHeaderId, group) return 1 @@ -1033,6 +1081,7 @@ open class DoWorkbenchReleaseService( results: List, dopReleaseType: String = "batch", mergeExtraIntoLaneTicket: Boolean = true, + actor: String, ): Int { if (results.isEmpty()) return 0 @@ -1064,6 +1113,7 @@ open class DoWorkbenchReleaseService( isSingleRelease, requiredDate, ticketFloorSegment, + actor, ) } @@ -1079,6 +1129,7 @@ open class DoWorkbenchReleaseService( storeId, WorkbenchReleaseTypeSupport.LEGACY_IS_EXTRA, ticketNo, + actor, ) ?: return 0 linkPickOrdersToHeader(headerId, group) return 1 @@ -1104,7 +1155,7 @@ open class DoWorkbenchReleaseService( } else { nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment) } - val headerId = insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseTypeCol, ticketNo) + val headerId = insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseTypeCol, ticketNo, actor) ?: return 0 linkPickOrdersToHeader(headerId, group) return 1 @@ -1206,6 +1257,7 @@ open class DoWorkbenchReleaseService( isSingleRelease = isSingleRelease, requiredDate = requiredDate, ticketFloorSegment = ticketFloorSegment, + actor = resolveReleaseActor(0L), ) ?: return mergeTicketsError("CREATE_FAILED", "Failed to create TI-M merge ticket") } @@ -1473,6 +1525,7 @@ open class DoWorkbenchReleaseService( isSingleRelease: Boolean, requiredDate: LocalDate, ticketFloorSegment: String, + actor: String, ): Long? { val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment) val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease) @@ -1504,9 +1557,9 @@ open class DoWorkbenchReleaseService( "ticketNo" to ticketNo, "releaseType" to releaseType, "created" to now, - "createdBy" to "system", + "createdBy" to actor, "modified" to now, - "modifiedBy" to "system", + "modifiedBy" to actor, ), ) return jdbcDao.queryForList( @@ -1515,6 +1568,6 @@ open class DoWorkbenchReleaseService( WHERE ticketNo = :ticketNo ORDER BY id DESC LIMIT 1 """.trimIndent(), mapOf("ticketNo" to ticketNo), - ).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it) } + ).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it, actor) } } } From 722e3a45c1e71cc4c5d79795399e7ef03ca38b33 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 8 Sep 2026 18:06:00 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=E5=BA=AB=E5=AD=98=E6=9F=A5=E8=A9=A2=20/?= =?UTF-8?q?=20=E7=9B=A4=E9=BB=9E=E8=AA=BF=E6=95=B4=E6=AC=8A=E9=99=90=20/?= =?UTF-8?q?=20=E6=89=B9=E6=AC=A1=E7=8F=BE=E6=B3=81=E5=A0=B1=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fpsms/config/security/SecurityConfig.java | 2 +- .../service/StockLotOnhandReportService.kt | 49 +++++++++++++++---- .../modules/report/web/ReportController.kt | 7 +-- .../web/StockLotOnhandReportController.kt | 7 ++- 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index 7dfc830..44c34b5 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -77,7 +77,7 @@ public class SecurityConfig { /** * FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 - * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 + * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.6 | 2026-09-07 * (stockAdjustment/submit → INVENTORY_ADJUST only) */ @Bean diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt index aecdd1f..15fffb4 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt @@ -9,6 +9,8 @@ import java.time.format.DateTimeFormatter * 庫存批次現況(Stock Balance):永遠今天。 * 現存讀 [inventory_lot_line](available、未過期、in-out > 0)。 * 最後異動:有 inventoryLotLineId 的帳本用 MAX(id);缺的比 SIL/SOL 時間。 + * 單位均價:貨品 AverageUnitPrice(HKD/stock UOM)用 item_uom 比率轉成該 lot 單位; + * 同一貨品同一 UOM 共用同一均價。庫存總價值 = 該 UOM 均價 × lot 數量。 * 單位均價/庫存總價值只填 root PO 為 PP/PF 的批(TRF 往回走);其他來源空白。 */ @Service @@ -70,6 +72,19 @@ lot_root_origin AS ( WHERE t.rn = 1 )""" + /** One stock-unit item_uom per item (same pick as item average-price SQL). */ + private const val STOCK_ONE_CTE_SQL = """ +stock_one AS ( + SELECT iu.* + FROM item_uom iu + INNER JOIN ( + SELECT itemId, MIN(id) AS id + FROM item_uom + WHERE deleted = 0 AND stockUnit = 1 + GROUP BY itemId + ) x ON x.id = iu.id +)""" + private const val ROOT_STOCK_IN_JOIN_SQL = """ LEFT JOIN lot_root_origin lro ON lro.lotId = il.id @@ -85,7 +100,7 @@ lot_root_origin AS ( val stockDate: String, ) - /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */ fun search( itemCode: String?, storeId: String?, @@ -133,12 +148,13 @@ lot_root_origin AS ( } val lotOriginFilterSql = buildLotOriginFilterSql(lotOrigin) val originJoinSql = ROOT_STOCK_IN_JOIN_SQL - val ctePrefix = "WITH RECURSIVE\n$LOT_ROOT_ORIGIN_CTE_SQL\n" + val ctePrefix = "WITH RECURSIVE\n$LOT_ROOT_ORIGIN_CTE_SQL,\n$STOCK_ONE_CTE_SQL\n" val liveLots = jdbcDao.queryForList( """ ${ctePrefix}SELECT ill.id AS inventoryLotLineId, + COALESCE(NULLIF(TRIM(ill.remarks), ''), '') AS remarks, COALESCE(it.code, '') AS itemNo, COALESCE(it.name, '') AS itemName, COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, @@ -149,7 +165,19 @@ lot_root_origin AS ( COALESCE(wh.area, '') AS areaPart, COALESCE(wh.slot, '') AS slotPart, (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS lotQtyRaw, - COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw, + CASE + WHEN iu.id IS NULL OR so.id IS NULL THEN + COALESCE(CAST(NULLIF(TRIM(it.AverageUnitPrice), '') AS DECIMAL(14, 4)), 0) + WHEN iu.uomId = so.uomId THEN + COALESCE(CAST(NULLIF(TRIM(it.AverageUnitPrice), '') AS DECIMAL(14, 4)), 0) + ELSE + ROUND( + COALESCE(CAST(NULLIF(TRIM(it.AverageUnitPrice), '') AS DECIMAL(14, 4)), 0) + * (COALESCE(iu.ratioN, 1) / COALESCE(NULLIF(iu.ratioD, 0), 1)) + * (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1)), + 4 + ) + END AS avgUnitPriceRaw, UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) AS rootPoPrefix FROM inventory_lot_line ill INNER JOIN inventory_lot il @@ -162,6 +190,8 @@ lot_root_origin AS ( ON iu.id = ill.stockItemUomId AND iu.deleted = 0 LEFT JOIN uom_conversion uc ON uc.id = iu.uomId + LEFT JOIN stock_one so + ON so.itemId = it.id $originJoinSql WHERE ill.deleted = 0 AND it.code IS NOT NULL AND it.code <> '' @@ -200,7 +230,7 @@ lot_root_origin AS ( private data class SilSolHit(val ts: String, val date: String, val kind: String) - /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */ private fun loadLastTrnFromLedger(lotIds: List, out: MutableMap>) { if (lotIds.isEmpty()) return // idx_ledger_lot_date_id is (lot, date, id) — bad for MAX(id); force the lot-only index @@ -233,7 +263,7 @@ lot_root_origin AS ( } } - /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */ private fun fillLastTrnFromLotHeaderSil(missing: List, hits: MutableMap): Int { var n = 0 for (chunk in missing.chunked(800)) { @@ -259,7 +289,7 @@ lot_root_origin AS ( return n } - /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */ private fun fillLastTrnFromSilLine(missing: List, hits: MutableMap): Int { var n = 0 for (chunk in missing.chunked(800)) { @@ -281,7 +311,7 @@ lot_root_origin AS ( return n } - /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */ private fun fillLastTrnFromSol(missing: List, hits: MutableMap): Int { var n = 0 for (chunk in missing.chunked(800)) { @@ -325,7 +355,7 @@ lot_root_origin AS ( else -> "" } - /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */ private fun assembleRows( liveLots: List>, lastTrnByLot: Map>, @@ -344,8 +374,9 @@ lot_root_origin AS ( val last = lastTrnByLot[lotId] val origin = r["rootPoPrefix"]?.toString().orEmpty().uppercase() val showPrice = origin == "PP" || origin == "PF" - val row = HashMap(22) + val row = HashMap(24) row["inventoryLotLineId"] = lotId.toString() + row["remarks"] = r["remarks"] ?: "" row["itemNo"] = r["itemNo"] ?: "" row["itemName"] = r["itemName"] ?: "" row["unitOfMeasure"] = r["unitOfMeasure"] ?: "" diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index fc24362..dfcc5ba 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -540,11 +540,12 @@ class ReportController( numberStyle: XSSFCellStyle, dashStyle: XSSFCellStyle, preferInt: Boolean = false, + emptyAsBlank: Boolean = false, ) { val cell = row.createCell(col) val parsed = parseSignedNumber(value) if (parsed == null) { - cell.setCellValue("-") + cell.setCellValue(if (emptyAsBlank) "" else "-") cell.cellStyle = dashStyle return } @@ -914,8 +915,8 @@ class ReportController( setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.int, styles.dash, preferInt = true) setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], styles.int, styles.dash, preferInt = true) setNumberCellFromFormatted(r, 9, m["totalCurrentBalance"], styles.int, styles.dash, preferInt = true) - setNumberCellFromFormatted(r, 10, m["avgUnitPrice"], styles.number, styles.dash, preferInt = false) - setNumberCellFromFormatted(r, 11, m["totalStockBalance"], styles.number, styles.dash, preferInt = false) + setNumberCellFromFormatted(r, 10, m["avgUnitPrice"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true) + setNumberCellFromFormatted(r, 11, m["totalStockBalance"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true) } } diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt index 7aac550..01efc8f 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt @@ -43,7 +43,7 @@ class StockLotOnhandReportController( val number: CellStyle, ) - /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */ @GetMapping("/print-stock-lot-onhand-excel") fun exportExcel( @RequestParam(required = false) itemCode: String?, @@ -191,6 +191,7 @@ class StockLotOnhandReportController( return v } + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */ private fun createExcel( dbData: List>, reportDate: String, @@ -215,6 +216,7 @@ class StockLotOnhandReportController( "樓層", "倉庫", "區域", "儲位", "最後異動日", "最後異動(入庫/出庫)", "批號結餘", "單位均價", "庫存總價值", + "備註", ) val totalColumns = headers.size var rowIndex = 0 @@ -299,6 +301,7 @@ class StockLotOnhandReportController( setNumberCell(r, 12, m["lotQtyRaw"], styles.number) setNumberCell(r, 13, m["avgUnitPriceRaw"], styles.number) setNumberCell(r, 14, m["stockValueRaw"], styles.number) + setTextCell(r, 15, m["remarks"], styles.text) } } @@ -307,7 +310,7 @@ class StockLotOnhandReportController( sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, totalColumns - 1)) } sheet.createFreezePane(0, headerRowIndex + 1) - intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) + intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14, 28) .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } val out = ByteArrayOutputStream() From 4d839530a5095abfffc2150485ee8939f4794fa0 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 8 Sep 2026 22:24:50 +0800 Subject: [PATCH 10/10] =?UTF-8?q?=E5=BA=AB=E5=AD=98=E8=AA=BF=E6=95=B4=20Re?= =?UTF-8?q?marks=20=E5=AF=AB=E5=85=A5=20DB=20=E4=B8=A6=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fpsms/config/security/SecurityConfig.java | 6 ++-- .../web/StockLotOnhandReportController.kt | 4 +-- .../stock/entity/StockAdjustmentRecord.kt | 3 ++ .../entity/StockAdjustmentRecordRepository.kt | 15 ++++++++- .../stock/service/StockAdjustmentService.kt | 32 +++++++++++++++---- .../stock/web/StockAdjustmentController.kt | 8 +++++ .../stock/web/model/StockAdjustmentRequest.kt | 8 ++++- .../01_add_remarks.sql | 6 ++++ 8 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260908_stock_adjustment_record_remarks/01_add_remarks.sql diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index 44c34b5..b18b847 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -77,8 +77,8 @@ public class SecurityConfig { /** * FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 - * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.6 | 2026-09-07 - * (stockAdjustment/submit → INVENTORY_ADJUST only) + * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 + * (stockAdjustment/submit and GET /latestRemarks → INVENTORY_ADJUST) */ @Bean @Order(1) @@ -119,6 +119,8 @@ public class SecurityConfig { .hasAnyAuthority("TESTING", "ADMIN") .requestMatchers(HttpMethod.POST, "/stockAdjustment/submit") .hasAuthority("INVENTORY_ADJUST") + .requestMatchers(HttpMethod.GET, "/stockAdjustment/latestRemarks") + .hasAuthority("INVENTORY_ADJUST") .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace") .hasAuthority("ITEM_TRACING") .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**") diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt index 01efc8f..041b6ca 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt @@ -216,7 +216,6 @@ class StockLotOnhandReportController( "樓層", "倉庫", "區域", "儲位", "最後異動日", "最後異動(入庫/出庫)", "批號結餘", "單位均價", "庫存總價值", - "備註", ) val totalColumns = headers.size var rowIndex = 0 @@ -301,7 +300,6 @@ class StockLotOnhandReportController( setNumberCell(r, 12, m["lotQtyRaw"], styles.number) setNumberCell(r, 13, m["avgUnitPriceRaw"], styles.number) setNumberCell(r, 14, m["stockValueRaw"], styles.number) - setTextCell(r, 15, m["remarks"], styles.text) } } @@ -310,7 +308,7 @@ class StockLotOnhandReportController( sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, totalColumns - 1)) } sheet.createFreezePane(0, headerRowIndex + 1) - intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14, 28) + intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } val out = ByteArrayOutputStream() diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecord.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecord.kt index dc711d4..2166583 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecord.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecord.kt @@ -31,6 +31,9 @@ open class StockAdjustmentRecord : BaseEntity() { @Column(name = "outQty", precision = 14, scale = 2) open var outQty: BigDecimal? = null + @Column(name = "remarks", length = 500) + open var remarks: String? = null + @JsonBackReference @ManyToOne @JoinColumn(name = "stockInLineId") diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecordRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecordRepository.kt index 4a087bd..e14ba7e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecordRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockAdjustmentRecordRepository.kt @@ -1,7 +1,20 @@ package com.ffii.fpsms.modules.stock.entity import com.ffii.core.support.AbstractRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository @Repository -interface StockAdjustmentRecordRepository : AbstractRepository \ No newline at end of file +interface StockAdjustmentRecordRepository : AbstractRepository { + @Query( + """ + SELECT r FROM StockAdjustmentRecord r + WHERE r.item.id = :itemId + AND r.deleted = false + AND r.remarks IS NOT NULL + ORDER BY r.id DESC + """ + ) + fun findRemarkRecordsByItemId(@Param("itemId") itemId: Long): List +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt index f997f28..6b192dc 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt @@ -4,6 +4,7 @@ import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.stock.entity.InventoryLotLine import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRequest +import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRemarksResponse import com.ffii.fpsms.modules.stock.web.model.StockInRequest import com.ffii.fpsms.modules.stock.web.model.StockOutRequest import org.springframework.stereotype.Service @@ -24,6 +25,7 @@ open class StockAdjustmentService( private val stockAdjustmentRecordRepository: StockAdjustmentRecordRepository ) { + /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ @Transactional open fun submit(request: StockAdjustmentRequest): MessageResponse { val originalById = request.originalLines.filter { it.id > 0 }.associateBy { it.id } @@ -36,7 +38,7 @@ open class StockAdjustmentService( inventoryLotLineId = line.id, type = "ADJ" ) - saveAdjustmentRecordForStockOut(stockOutLine) + saveAdjustmentRecordForStockOut(stockOutLine, line.remarks) } for (current in request.currentLines) { // Branch 2: New entry — createStockIn (OPEN or ADJ) @@ -56,7 +58,7 @@ open class StockAdjustmentService( warehouseId = current.warehouseId ) ) - saveAdjustmentRecordForStockIn(stockInLine) + saveAdjustmentRecordForStockIn(stockInLine, current.remarks) continue } @@ -74,7 +76,7 @@ open class StockAdjustmentService( stockInRequest, inventoryLotLine ) - saveAdjustmentRecordForStockIn(stockInLine) + saveAdjustmentRecordForStockIn(stockInLine, current.remarks) } else { // Branch 3 (qty down): adjustment outbound only (not pick createStockOut) val stockOutLine = stockOutLineService.createStockOutForAdjustment( @@ -84,7 +86,7 @@ open class StockAdjustmentService( type = "ADJ" ) ) - saveAdjustmentRecordForStockOut(stockOutLine) + saveAdjustmentRecordForStockOut(stockOutLine, current.remarks) } } @@ -98,6 +100,19 @@ open class StockAdjustmentService( ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ + open fun findLatestRemarksByItemId(itemId: Long): List { + val seenLotNos = HashSet() + val result = mutableListOf() + for (row in stockAdjustmentRecordRepository.findRemarkRecordsByItemId(itemId)) { + val remarks = normalizeRemarks(row.remarks) ?: continue + val lotNo = row.lotNo?.trim().orEmpty() + if (lotNo.isEmpty() || !seenLotNos.add(lotNo)) continue + result.add(StockAdjustmentRemarksResponse(lotNo = lotNo, remarks = remarks)) + } + return result + } + private fun buildStockInRequestFromExistingLotLine( inventoryLotLine: InventoryLotLine, acceptedQty: BigDecimal @@ -131,7 +146,7 @@ open class StockAdjustmentService( warehouseId = warehouseId ) } - private fun saveAdjustmentRecordForStockIn(stockInLine: StockInLine) { + private fun saveAdjustmentRecordForStockIn(stockInLine: StockInLine, remarks: String?) { val item = stockInLine.item ?: return val record = StockAdjustmentRecord().apply { this.item = item @@ -140,13 +155,14 @@ open class StockAdjustmentService( this.lotNo = stockInLine.lotNo this.inQty = stockInLine.acceptedQty this.outQty = null + this.remarks = normalizeRemarks(remarks) this.stockInLine = stockInLine this.stockOutLine = null } stockAdjustmentRecordRepository.save(record) } - private fun saveAdjustmentRecordForStockOut(stockOutLine: StockOutLine) { + private fun saveAdjustmentRecordForStockOut(stockOutLine: StockOutLine, remarks: String?) { val item = stockOutLine.item ?: return val lotNo = stockOutLine.inventoryLotLine?.inventoryLot?.lotNo val record = StockAdjustmentRecord().apply { @@ -156,9 +172,13 @@ open class StockAdjustmentService( this.lotNo = lotNo this.inQty = null this.outQty = BigDecimal.valueOf(stockOutLine.qty ?: 0.0) + this.remarks = normalizeRemarks(remarks) this.stockInLine = null this.stockOutLine = stockOutLine } stockAdjustmentRecordRepository.save(record) } + + private fun normalizeRemarks(remarks: String?): String? = + remarks?.trim()?.takeIf { it.isNotEmpty() } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/StockAdjustmentController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/StockAdjustmentController.kt index c76f6ec..71551cf 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/StockAdjustmentController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/StockAdjustmentController.kt @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.stock.web import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.stock.service.StockAdjustmentService import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRequest +import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRemarksResponse import jakarta.validation.Valid import org.springframework.web.bind.annotation.* @@ -11,8 +12,15 @@ import org.springframework.web.bind.annotation.* class StockAdjustmentController( private val stockAdjustmentService: StockAdjustmentService ) { + /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ @PostMapping("/submit") fun submit(@Valid @RequestBody request: StockAdjustmentRequest): MessageResponse { return stockAdjustmentService.submit(request) } + + /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ + @GetMapping("/latestRemarks") + fun latestRemarks(@RequestParam itemId: Long): List { + return stockAdjustmentService.findLatestRemarksByItemId(itemId) + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockAdjustmentRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockAdjustmentRequest.kt index fc60c4b..570985c 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockAdjustmentRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockAdjustmentRequest.kt @@ -14,11 +14,17 @@ data class StockAdjustmentLineRequest( val itemNo: String, val expiryDate: String, val warehouseId: Long, - val uom: String? = null + val uom: String? = null, + val remarks: String? = null ) data class StockAdjustmentRequest( val itemId: Long, val originalLines: List, val currentLines: List +) + +data class StockAdjustmentRemarksResponse( + val lotNo: String?, + val remarks: String ) \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20260908_stock_adjustment_record_remarks/01_add_remarks.sql b/src/main/resources/db/changelog/changes/20260908_stock_adjustment_record_remarks/01_add_remarks.sql new file mode 100644 index 0000000..fd2673d --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260908_stock_adjustment_record_remarks/01_add_remarks.sql @@ -0,0 +1,6 @@ +--liquibase formatted sql +--changeset fpsms:stock_adjustment_record_remarks +--comment: Store stock adjustment reason/remarks + +ALTER TABLE `stock_adjustment_record` + ADD COLUMN `remarks` VARCHAR(500) NULL AFTER `outQty`;