From 8b4ce93de340847e7dbf01b91a5da5657480b44e Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sun, 22 Mar 2026 18:52:54 +0800 Subject: [PATCH] Refining the PO stock in report --- docs/EXCEL_EXPORT_STANDARD.md | 63 ++++++ .../com/ffii/core/utils/JwtTokenUtil.java | 26 ++- .../master/entity/projections/ShopCombo.kt | 8 +- .../modules/report/service/ReportService.kt | 205 +++++++++++++++++- .../modules/report/web/ReportController.kt | 29 ++- src/main/resources/application-prod.yml | 2 + src/main/resources/application.yml | 2 + 7 files changed, 324 insertions(+), 11 deletions(-) create mode 100644 docs/EXCEL_EXPORT_STANDARD.md diff --git a/docs/EXCEL_EXPORT_STANDARD.md b/docs/EXCEL_EXPORT_STANDARD.md new file mode 100644 index 0000000..cd719e2 --- /dev/null +++ b/docs/EXCEL_EXPORT_STANDARD.md @@ -0,0 +1,63 @@ +# Excel export standard (FPSMS backend) + +This document mirrors the **visual and formatting rules** used on the frontend for `.xlsx` exports. +**It does not run automatically:** backend code paths (JasperReports XLSX export, Apache POI, etc.) are separate from the Next.js `xlsx-js-style` pipeline. Apply these rules **manually** when implementing or changing server-side Excel so outputs stay consistent with the UI standard. + +**Frontend reference (implementation details):** +`FPSMS-frontend/src/app/(main)/chart/_components/EXCEL_EXPORT_STANDARD.md` + +--- + +## When backend Excel is used + +Typical cases: + +- JasperReports templates configured for **XLSX** output (`JRXlsxExporter` or similar). +- **Apache POI** (`XSSFWorkbook`, `SXSSFWorkbook`) building sheets in Kotlin/Java services (e.g. production schedule export, BOM, item templates). + +Reports that return **JSON only** and let the **browser** build the file (e.g. GRN `rep-014`) are **not** covered here — those follow the frontend doc. + +--- + +## Rules to mirror (parity with frontend) + +| Element | Target | +|--------|--------| +| **Header row** | Bold, black text; fill **light grey** `#D9D9D9` (RGB); vertical center; for header text use horizontal **center** + wrap unless the column is numeric (see below). | +| **Money / amount columns** | Excel number format **`#,##0.00`** (thousands separator, 2 decimals). **Right-align** header and data. | +| **Quantity columns** | **Right-align** header and data (no fixed decimal rule on frontend; match business need). | +| **Column width** | At least readable for bilingual headers; frontend uses `max(12, headerLength + 4)` character width — approximate in POI with `setColumnWidth`. | + +### Column detection (naming) + +Align with frontend header keywords so exports **feel** the same: + +- **Money:** headers containing `金額`, `單價`, `Amount`, `Unit Price`, `Total Amount` (including bilingual labels like `Amount / 金額`). +- **Quantity:** headers containing `Qty`, `數量`, `Demand`. + +--- + +## Apache POI (sketch) + +- Create `CellStyle` for header: `setFillForegroundColor` (indexed or XSSFColor for `#D9D9D9`), `setFillPattern`, **bold** font, alignment. +- Data format for money: `DataFormat` → `"#,##0.00"` on numeric cells. +- `setAlignment(HorizontalAlignment.RIGHT)` for amount/qty columns (and header cells in those columns if desired). + +--- + +## JasperReports + +- Apply styles in the report template (fonts, background, borders) and numeric **pattern** for amount fields equivalent to `#,##0.00` where supported. +- Verify in Excel output: Jasper’s XLSX styling capabilities differ slightly from POI — test the generated file. + +--- + +## Checklist for new backend Excel features + +1. Confirm whether the report is **backend-only** Excel or **JSON + frontend** Excel — only the latter uses the TypeScript standard automatically. +2. For backend-only, apply header fill, money format, and alignment per the table above. +3. Keep naming of column titles consistent with the keywords in **Column detection** when possible. + +--- + +*Aligned with frontend `EXCEL_EXPORT_STANDARD.md` (grey `#D9D9D9`, `#,##0.00` for amounts, right-align numeric columns).* diff --git a/src/main/java/com/ffii/core/utils/JwtTokenUtil.java b/src/main/java/com/ffii/core/utils/JwtTokenUtil.java index a75cf96..f3778b6 100644 --- a/src/main/java/com/ffii/core/utils/JwtTokenUtil.java +++ b/src/main/java/com/ffii/core/utils/JwtTokenUtil.java @@ -1,7 +1,10 @@ package com.ffii.core.utils; import java.io.Serializable; +import java.nio.charset.StandardCharsets; import java.security.Key; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Instant; import java.util.Date; import java.util.HashMap; @@ -20,9 +23,10 @@ import com.ffii.fpsms.model.RefreshToken; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; + @Component @Scope(value = ConfigurableBeanFactory. SCOPE_SINGLETON) public class JwtTokenUtil implements Serializable { @@ -44,7 +48,25 @@ public class JwtTokenUtil implements Serializable { @Value("${jwt.clock-skew-seconds:30}") private long clockSkewSeconds = 30; - private static final Key secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS512); + /** + * Stable signing key (HS512). Must be derived from configuration so tokens survive restarts. + * Previously {@code Keys.secretKeyFor(HS512)} generated a new key every JVM start and broke all existing JWTs. + */ + @Value("${jwt.secret}") + private String jwtSecret; + + private Key secretKey; + + @PostConstruct + void initSigningKey() { + try { + byte[] keyBytes = MessageDigest.getInstance("SHA-512") + .digest(jwtSecret.getBytes(StandardCharsets.UTF_8)); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Cannot initialize JWT signing key", e); + } + } // retrieve username from jwt token public String getUsernameFromToken(String token) { diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/projections/ShopCombo.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/projections/ShopCombo.kt index 7024e59..609ca79 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/projections/ShopCombo.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/projections/ShopCombo.kt @@ -3,9 +3,11 @@ package com.ffii.fpsms.modules.master.entity.projections import org.springframework.beans.factory.annotation.Value interface ShopCombo { - val id: Long; + val id: Long + val code: String + val name: String @get:Value("#{target.id}") - val value: Long; + val value: Long @get:Value("#{target.code} - #{target.name}") - val label: String; + val label: String } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index fdb047b..37b38dd 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -6,6 +6,8 @@ import net.sf.jasperreports.engine.data.JRMapCollectionDataSource import java.io.ByteArrayOutputStream import java.io.InputStream import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.master.entity.ShopRepository +import com.ffii.fpsms.modules.master.enums.ShopType import com.ffii.fpsms.modules.master.service.ItemUomService import java.math.BigDecimal import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter @@ -16,6 +18,7 @@ import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput open class ReportService( private val jdbcDao: JdbcDao, private val itemUomService: ItemUomService, + private val shopRepository: ShopRepository, ) { /** * Queries the database for inventory data based on dates and optional item type. @@ -536,6 +539,91 @@ return result return "AND (${conditions.joinToString(" OR ")})" } + /** + * Comma-separated tokens; each token matches **either** [codeColumn] or [nameColumn] (LIKE %token%). + * Rows match if **any** token matches (OR across tokens). + */ + private fun buildMultiValueCodeOrNameLikeClause( + paramValue: String?, + codeColumn: String, + nameColumn: String, + paramPrefix: String, + args: MutableMap + ): String { + if (paramValue.isNullOrBlank()) return "" + val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } + if (values.isEmpty()) return "" + val tokenClauses = values.mapIndexed { index, value -> + val p1 = "${paramPrefix}_${index}_c" + val p2 = "${paramPrefix}_${index}_n" + args[p1] = "%$value%" + args[p2] = "%$value%" + "($codeColumn LIKE :$p1 OR $nameColumn LIKE :$p2)" + } + return "AND (${tokenClauses.joinToString(" OR ")})" + } + + /** + * Comma-separated tokens; stock-in line matches if EXISTS a GRN log row whose grn_code matches **any** token (LIKE). + */ + private fun buildMultiValueGrnExistsClause( + paramValue: String?, + paramPrefix: String, + args: MutableMap + ): String { + if (paramValue.isNullOrBlank()) return "" + val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } + if (values.isEmpty()) return "" + val inner = values.mapIndexed { i, v -> + val p = "${paramPrefix}_$i" + args[p] = "%$v%" + "g.grn_code LIKE :$p" + }.joinToString(" OR ") + return "AND EXISTS (SELECT 1 FROM m18_goods_receipt_note_log g WHERE g.stock_in_line_id = sil.id AND ($inner))" + } + + /** + * Supplier combo options for GRN report (code as value for filtering). + */ + fun getGrnSupplierOptions(): List> { + return shopRepository.findShopComboByTypeAndDeletedIsFalse(ShopType.SUPPLIER) + .map { combo -> + mapOf( + "label" to combo.label, + "value" to (combo.code ?: ""), + ) + } + .filter { it["value"]!!.isNotBlank() } + } + + /** + * Item code/name options for GRN report autocomplete (partial match). + */ + fun getGrnItemOptions(q: String?): List> { + val args = mutableMapOf() + val sql = if (q.isNullOrBlank()) { + """ + SELECT code, name FROM items WHERE deleted = false ORDER BY code LIMIT 200 + """.trimIndent() + } else { + args["q"] = "%${q.trim()}%" + """ + SELECT code, name FROM items WHERE deleted = false + AND (code LIKE :q OR name LIKE :q) + ORDER BY code LIMIT 200 + """.trimIndent() + } + val rows = jdbcDao.queryForList(sql, args) + return rows.map { row -> + val code = (row["code"] ?: "").toString() + val name = (row["name"] ?: "").toString() + mapOf( + "label" to if (name.isNotBlank()) "$code - $name" else code, + "value" to code, + ) + } + } + /** * Queries the database for Stock In Traceability Report data. * Joins stock_in_line, stock_in, items, qc_result, inventory_lot, inventory_lot_line, warehouse, and shop tables. @@ -632,13 +720,16 @@ return result } /** - * GRN (Goods Received Note) report: stock-in lines with PO/delivery note, filterable by receipt date range and item code. - * Returns rows for Excel export: poCode, deliveryNoteNo, receiptDate, itemCode, itemName, acceptedQty, demandQty, uom, etc. + * GRN (Goods Received Note) report: stock-in lines with PO/delivery note. + * Filters: receipt date range, item (code or name), supplier (code or name), PO code, GRN code (partial match, comma-separated OR). */ fun searchGrnReport( receiptDateStart: String?, receiptDateEnd: String?, - itemCode: String? + itemCode: String?, + supplier: String?, + poCode: String?, + grnCode: String?, ): List> { val args = mutableMapOf() val receiptDateStartSql = if (!receiptDateStart.isNullOrBlank()) { @@ -651,7 +742,10 @@ return result args["receiptDateEnd"] = formatted "AND DATE(sil.receiptDate) <= DATE(:receiptDateEnd)" } else "" - val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) + val itemSql = buildMultiValueCodeOrNameLikeClause(itemCode, "it.code", "it.name", "grnItem", args) + val supplierSql = buildMultiValueCodeOrNameLikeClause(supplier, "sp.code", "sp.name", "grnSupp", args) + val poCodeSql = buildMultiValueLikeClause(poCode, "po.code", "grnPo", args) + val grnExistsSql = buildMultiValueGrnExistsClause(grnCode, "grnG", args) val sql = """ SELECT @@ -673,10 +767,15 @@ return result COALESCE(sp.code, '') AS supplierCode, COALESCE(sp.name, '') AS supplier, COALESCE(sil.status, '') AS status, + MAX(COALESCE(pol.up, 0)) AS unitPrice, + MAX(ROUND(COALESCE(pol.up, 0) * COALESCE(sil.acceptedQty, 0), 2)) AS lineAmount, + MAX(COALESCE(cur.code, '')) AS currencyCode, + MAX(grn.grn_code) AS grnCode, MAX(grn.m18_record_id) AS grnId FROM stock_in_line sil LEFT JOIN items it ON sil.itemId = it.id LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id + LEFT JOIN currency cur ON po.currencyId = cur.id LEFT JOIN shop sp ON po.supplierId = sp.id LEFT JOIN purchase_order_line pol ON sil.purchaseOrderLineId = pol.id LEFT JOIN uom_conversion uc_pol ON pol.uomId = uc_pol.id @@ -689,7 +788,10 @@ return result AND sil.purchaseOrderId IS NOT NULL $receiptDateStartSql $receiptDateEndSql - $itemCodeSql + $itemSql + $supplierSql + $poCodeSql + $grnExistsSql GROUP BY po.code, deliveryNoteNo, @@ -727,11 +829,104 @@ return result "supplierCode" to row["supplierCode"], "supplier" to row["supplier"], "status" to row["status"], + "unitPrice" to (row["unitPrice"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0), + "lineAmount" to (row["lineAmount"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0), + "currencyCode" to row["currencyCode"], + "grnCode" to row["grnCode"], "grnId" to row["grnId"] ) } } + /** + * GRN report sheet "已上架PO金額": totals grouped by [receiptDate] (calendar day of stock-in receipt), + * plus currency and PO. Only lines with PO line status `completed`. Same filters as [searchGrnReport]. + */ + fun searchGrnListedPoAmounts( + receiptDateStart: String?, + receiptDateEnd: String?, + itemCode: String? + ): Map { + val args = mutableMapOf() + val receiptDateStartSql = if (!receiptDateStart.isNullOrBlank()) { + val formatted = receiptDateStart.replace("/", "-") + args["receiptDateStart"] = formatted + "AND DATE(sil.receiptDate) >= DATE(:receiptDateStart)" + } else "" + val receiptDateEndSql = if (!receiptDateEnd.isNullOrBlank()) { + val formatted = receiptDateEnd.replace("/", "-") + args["receiptDateEnd"] = formatted + "AND DATE(sil.receiptDate) <= DATE(:receiptDateEnd)" + } else "" + val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) + + val lineSubquery = """ + SELECT + sil.id AS sil_id, + sil.purchaseOrderId AS purchaseOrderId, + DATE_FORMAT(sil.receiptDate, '%Y-%m-%d') AS receiptDate, + ROUND(COALESCE(pol.up, 0) * COALESCE(sil.acceptedQty, 0), 2) AS line_amt + FROM stock_in_line sil + INNER JOIN purchase_order_line pol ON sil.purchaseOrderLineId = pol.id + LEFT JOIN items it ON sil.itemId = it.id + WHERE sil.deleted = false + AND sil.receiptDate IS NOT NULL + AND sil.purchaseOrderId IS NOT NULL + AND pol.status = 'completed' + $receiptDateStartSql + $receiptDateEndSql + $itemCodeSql + """.trimIndent() + + val currencySql = """ + SELECT + s.receiptDate AS receiptDate, + COALESCE(cur.code, '') AS currencyCode, + SUM(s.line_amt) AS totalAmount + FROM ( $lineSubquery ) s + INNER JOIN purchase_order po ON s.purchaseOrderId = po.id + LEFT JOIN currency cur ON po.currencyId = cur.id + GROUP BY s.receiptDate, cur.code + ORDER BY s.receiptDate, currencyCode + """.trimIndent() + + val byPoSql = """ + SELECT + s.receiptDate AS receiptDate, + po.code AS poCode, + COALESCE(cur.code, '') AS currencyCode, + SUM(s.line_amt) AS totalAmount, + GROUP_CONCAT(DISTINCT NULLIF(grn.grn_code, '') ORDER BY grn.grn_code SEPARATOR ', ') AS grnCodes + FROM ( $lineSubquery ) s + INNER JOIN purchase_order po ON s.purchaseOrderId = po.id + LEFT JOIN currency cur ON po.currencyId = cur.id + LEFT JOIN m18_goods_receipt_note_log grn ON grn.stock_in_line_id = s.sil_id + GROUP BY s.receiptDate, po.id, po.code, cur.code + ORDER BY s.receiptDate, currencyCode, poCode + """.trimIndent() + + val currencyRows = jdbcDao.queryForList(currencySql, args).map { row -> + mapOf( + "receiptDate" to row["receiptDate"], + "currencyCode" to row["currencyCode"], + "totalAmount" to (row["totalAmount"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0), + ) + } + val poRows = jdbcDao.queryForList(byPoSql, args).map { row -> + mapOf( + "receiptDate" to row["receiptDate"], + "poCode" to row["poCode"], + "currencyCode" to row["currencyCode"], + "totalAmount" to (row["totalAmount"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0), + "grnCodes" to (row["grnCodes"] ?: ""), + ) + } + return mapOf( + "currencyTotals" to currencyRows, + "byPurchaseOrder" to poRows, + ) + } + /** * GRN preview for M18: show both stock qty (acceptedQty) and purchase qty (converted) for a specific receipt date. * This is a DRY-RUN preview only (does not call M18). 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 54e935f..7b811ff 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 @@ -8,6 +8,7 @@ import java.io.InputStream import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter +import com.ffii.fpsms.modules.common.SecurityUtils import com.ffii.fpsms.modules.report.service.ReportService @RestController @@ -279,7 +280,33 @@ class ReportController( @RequestParam(required = false) itemCode: String? ): Map { val rows = reportService.searchGrnReport(receiptDateStart, receiptDateEnd, itemCode) - return mapOf("rows" to rows) + val isAdmin = SecurityUtils.isGranted("ADMIN") + val sanitizedRows = + if (isAdmin) { + rows + } else { + rows.map { row -> + row.filterKeys { it !in GRN_FINANCIAL_KEYS } + } + } + val listedPoAmounts = + if (isAdmin) { + reportService.searchGrnListedPoAmounts(receiptDateStart, receiptDateEnd, itemCode) + } else { + mapOf( + "currencyTotals" to emptyList>(), + "byPurchaseOrder" to emptyList>(), + ) + } + return mapOf( + "rows" to sanitizedRows, + "listedPoAmounts" to listedPoAmounts, + ) + } + + companion object { + /** GRN report fields only users with ADMIN authority may see */ + private val GRN_FINANCIAL_KEYS = setOf("unitPrice", "lineAmount", "currencyCode") } /** diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 0f7d713..587684a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -2,6 +2,8 @@ jwt: expiration-minutes: 480 # 8 hours access token (was 30 min); increase if users need longer AFK refresh-expiration-days: 7 + # Required for stable JWT across restarts; set JWT_SECRET in the deployment environment (long random string). + secret: ${JWT_SECRET:fpsms-dev-jwt-signing-secret-change-for-production-use-long-random-JWT_SECRET} spring: datasource: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1e8b8f6..e39a204 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,9 +36,11 @@ spring: storage_engine: innodb # JWT: access token expiry and refresh token expiry. Frontend should call /refresh-token before access token expires. +# Signing key must be STABLE across server restarts (do not use a random key per boot). Override with env JWT_SECRET in production. jwt: expiration-minutes: 14400 # access token: 10 days (default); override in application-prod for shorter session refresh-expiration-days: 30 # refresh token validity (days) + secret: ${JWT_SECRET:fpsms-dev-jwt-signing-secret-change-for-production-use-long-random-JWT_SECRET} logging: config: 'classpath:log4j2.yml'