浏览代码

Refining the PO stock in report

master
Fai Luk 8 小时前
父节点
当前提交
8b4ce93de3
共有 7 个文件被更改,包括 324 次插入11 次删除
  1. +63
    -0
      docs/EXCEL_EXPORT_STANDARD.md
  2. +24
    -2
      src/main/java/com/ffii/core/utils/JwtTokenUtil.java
  3. +5
    -3
      src/main/java/com/ffii/fpsms/modules/master/entity/projections/ShopCombo.kt
  4. +200
    -5
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  5. +28
    -1
      src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt
  6. +2
    -0
      src/main/resources/application-prod.yml
  7. +2
    -0
      src/main/resources/application.yml

+ 63
- 0
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).*

+ 24
- 2
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) {


+ 5
- 3
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
}

+ 200
- 5
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, Any>
): 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, Any>
): 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<Map<String, String>> {
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<Map<String, String>> {
val args = mutableMapOf<String, Any>()
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<Map<String, Any?>> {
val args = mutableMapOf<String, Any>()
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<String, Any> {
val args = mutableMapOf<String, Any>()
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).


+ 28
- 1
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<String, Any> {
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<Map<String, Any?>>(),
"byPurchaseOrder" to emptyList<Map<String, Any?>>(),
)
}
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")
}

/**


+ 2
- 0
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:


+ 2
- 0
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'


正在加载...
取消
保存