Ver código fonte

Refining the PO stock in report

master
Fai Luk 23 horas atrás
pai
commit
8b4ce93de3
7 arquivos alterados com 324 adições e 11 exclusões
  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 Ver arquivo

@@ -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 Ver arquivo

@@ -1,7 +1,10 @@
package com.ffii.core.utils; package com.ffii.core.utils;


import java.io.Serializable; import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.security.Key; import java.security.Key;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant; import java.time.Instant;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
@@ -20,9 +23,10 @@ import com.ffii.fpsms.model.RefreshToken;


import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;


import jakarta.annotation.PostConstruct;

@Component @Component
@Scope(value = ConfigurableBeanFactory. SCOPE_SINGLETON) @Scope(value = ConfigurableBeanFactory. SCOPE_SINGLETON)
public class JwtTokenUtil implements Serializable { public class JwtTokenUtil implements Serializable {
@@ -44,7 +48,25 @@ public class JwtTokenUtil implements Serializable {
@Value("${jwt.clock-skew-seconds:30}") @Value("${jwt.clock-skew-seconds:30}")
private long clockSkewSeconds = 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 // retrieve username from jwt token
public String getUsernameFromToken(String token) { public String getUsernameFromToken(String token) {


+ 5
- 3
src/main/java/com/ffii/fpsms/modules/master/entity/projections/ShopCombo.kt Ver arquivo

@@ -3,9 +3,11 @@ package com.ffii.fpsms.modules.master.entity.projections
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value


interface ShopCombo { interface ShopCombo {
val id: Long;
val id: Long
val code: String
val name: String
@get:Value("#{target.id}") @get:Value("#{target.id}")
val value: Long;
val value: Long
@get:Value("#{target.code} - #{target.name}") @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 Ver arquivo

@@ -6,6 +6,8 @@ import net.sf.jasperreports.engine.data.JRMapCollectionDataSource
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import com.ffii.core.support.JdbcDao 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 com.ffii.fpsms.modules.master.service.ItemUomService
import java.math.BigDecimal import java.math.BigDecimal
import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter
@@ -16,6 +18,7 @@ import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput
open class ReportService( open class ReportService(
private val jdbcDao: JdbcDao, private val jdbcDao: JdbcDao,
private val itemUomService: ItemUomService, private val itemUomService: ItemUomService,
private val shopRepository: ShopRepository,
) { ) {
/** /**
* Queries the database for inventory data based on dates and optional item type. * Queries the database for inventory data based on dates and optional item type.
@@ -536,6 +539,91 @@ return result
return "AND (${conditions.joinToString(" OR ")})" 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. * 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. * 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( fun searchGrnReport(
receiptDateStart: String?, receiptDateStart: String?,
receiptDateEnd: String?, receiptDateEnd: String?,
itemCode: String?
itemCode: String?,
supplier: String?,
poCode: String?,
grnCode: String?,
): List<Map<String, Any?>> { ): List<Map<String, Any?>> {
val args = mutableMapOf<String, Any>() val args = mutableMapOf<String, Any>()
val receiptDateStartSql = if (!receiptDateStart.isNullOrBlank()) { val receiptDateStartSql = if (!receiptDateStart.isNullOrBlank()) {
@@ -651,7 +742,10 @@ return result
args["receiptDateEnd"] = formatted args["receiptDateEnd"] = formatted
"AND DATE(sil.receiptDate) <= DATE(:receiptDateEnd)" "AND DATE(sil.receiptDate) <= DATE(:receiptDateEnd)"
} else "" } 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 = """ val sql = """
SELECT SELECT
@@ -673,10 +767,15 @@ return result
COALESCE(sp.code, '') AS supplierCode, COALESCE(sp.code, '') AS supplierCode,
COALESCE(sp.name, '') AS supplier, COALESCE(sp.name, '') AS supplier,
COALESCE(sil.status, '') AS status, 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 MAX(grn.m18_record_id) AS grnId
FROM stock_in_line sil FROM stock_in_line sil
LEFT JOIN items it ON sil.itemId = it.id LEFT JOIN items it ON sil.itemId = it.id
LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.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 shop sp ON po.supplierId = sp.id
LEFT JOIN purchase_order_line pol ON sil.purchaseOrderLineId = pol.id LEFT JOIN purchase_order_line pol ON sil.purchaseOrderLineId = pol.id
LEFT JOIN uom_conversion uc_pol ON pol.uomId = uc_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 AND sil.purchaseOrderId IS NOT NULL
$receiptDateStartSql $receiptDateStartSql
$receiptDateEndSql $receiptDateEndSql
$itemCodeSql
$itemSql
$supplierSql
$poCodeSql
$grnExistsSql
GROUP BY GROUP BY
po.code, po.code,
deliveryNoteNo, deliveryNoteNo,
@@ -727,11 +829,104 @@ return result
"supplierCode" to row["supplierCode"], "supplierCode" to row["supplierCode"],
"supplier" to row["supplier"], "supplier" to row["supplier"],
"status" to row["status"], "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"] "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. * 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). * This is a DRY-RUN preview only (does not call M18).


+ 28
- 1
src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt Ver arquivo

@@ -8,6 +8,7 @@ import java.io.InputStream
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.common.SecurityUtils
import com.ffii.fpsms.modules.report.service.ReportService import com.ffii.fpsms.modules.report.service.ReportService


@RestController @RestController
@@ -279,7 +280,33 @@ class ReportController(
@RequestParam(required = false) itemCode: String? @RequestParam(required = false) itemCode: String?
): Map<String, Any> { ): Map<String, Any> {
val rows = reportService.searchGrnReport(receiptDateStart, receiptDateEnd, itemCode) 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 Ver arquivo

@@ -2,6 +2,8 @@
jwt: jwt:
expiration-minutes: 480 # 8 hours access token (was 30 min); increase if users need longer AFK expiration-minutes: 480 # 8 hours access token (was 30 min); increase if users need longer AFK
refresh-expiration-days: 7 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: spring:
datasource: datasource:


+ 2
- 0
src/main/resources/application.yml Ver arquivo

@@ -36,9 +36,11 @@ spring:
storage_engine: innodb storage_engine: innodb


# JWT: access token expiry and refresh token expiry. Frontend should call /refresh-token before access token expires. # 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: jwt:
expiration-minutes: 14400 # access token: 10 days (default); override in application-prod for shorter session expiration-minutes: 14400 # access token: 10 days (default); override in application-prod for shorter session
refresh-expiration-days: 30 # refresh token validity (days) 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: logging:
config: 'classpath:log4j2.yml' config: 'classpath:log4j2.yml'


Carregando…
Cancelar
Salvar