ソースを参照

庫存品質檢測報告 add qc item select control, search date change from stock in line date to qc reuslt create date

庫存明細報告 improve speed

送貨訂單與倉存單位不符 excel 預計到貨→預計送貨
fix負數倉
CANCERYS\kw093 1ヶ月前
コミット
01d5cde8c8
6個のファイルの変更403行の追加376行の削除
  1. +6
    -2
      src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt
  2. +182
    -194
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt
  3. +2
    -2
      src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt
  4. +11
    -5
      src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt
  5. +201
    -172
      src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt
  6. +1
    -1
      src/main/resources/jasper/StockLedgarReport.jrxml

+ 6
- 2
src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt ファイルの表示

@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service
open class ItemQcFailReportService(
private val jdbcDao: JdbcDao,
) {
/** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */
fun searchItemQcFailReport(
stockCategory: String?,
itemCode: String?,
@@ -33,16 +34,19 @@ open class ItemQcFailReportService(
val qcItemScopeSql = buildQcItemScopeClause(measurable, other)
val measuredValueSql = buildMeasuredValueClause(measurable, scope)

// Date filter = QC result created time (true QC finish), not receiptDate / put-away.
// JO EPQC: receiptDate is often SIL create day; put-away ≈ QC via auto put-away.
// PO IQC: put-away can be days after QC — must not use ill.created for this filter.
val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) {
val formattedDate = lastInDateStart.replace("/", "-")
args["lastInDateStart"] = formattedDate
"AND DATE(sil.receiptDate) >= DATE(:lastInDateStart)"
"AND DATE(qr.created) >= DATE(:lastInDateStart)"
} else ""

val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) {
val formattedDate = lastInDateEnd.replace("/", "-")
args["lastInDateEnd"] = formattedDate
"AND DATE(sil.receiptDate) <= DATE(:lastInDateEnd)"
"AND DATE(qr.created) <= DATE(:lastInDateEnd)"
} else ""

val sql = """


+ 182
- 194
src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt ファイルの表示

@@ -4,17 +4,25 @@ import com.ffii.core.support.JdbcDao
import org.springframework.stereotype.Service
import java.time.LocalDate
import java.time.format.DateTimeFormatter

@Service
open class StockLedgerReportService(
private val jdbcDao: JdbcDao,
) {

private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd")

/**
* FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11
* Stock Ledger 報表查詢
*
* - stockSubCategory = items.type
* - trnDate = stock_ledger.date
* - trnRefNo = stock_ledger.type
* - cumBalance = stock_ledger.balance(異動後纍計存量)
* - cumOpeningBal = balance - inQty + outQty(異動前纍計期初)
*
* 只查 [start, end] 期間列,不掃起日以前全歷史。
*/
fun searchStockLedgerReport(
stockCategory: String?,
@@ -23,18 +31,15 @@ open class StockLedgerReportService(
reportPeriodStart: String?,
reportPeriodEnd: String?,
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
// 1) 先決定 reportPeriodEnd:如果有填 end,就用使用者的;否則用今天

val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-")
?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
// 2) 如果有填 start,就用使用者的;否則從 DB 查最早一筆日期
?: LocalDate.now().format(dateFmt))

val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) {
reportPeriodStart.replace("/", "-")
} else {
// 用簡單 SQL 查全表最早一筆日期(可加上類似 stockCategory/itemCode 過濾)
val minDateSql = """
SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate
FROM stock_ledger sl
@@ -42,188 +47,39 @@ open class StockLedgerReportService(
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
""".trimIndent()
val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap<String, Any>()).firstOrNull()
(minDateRow?.get("firstDate") as? String)
?: reportPeriodEnd // 如果表是空的,就退回用今天
?: reportPeriodEnd
}
// 3) 把 from/to 塞到 args,供後面 SQL 使用
val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt)
args["reportPeriodStart"] = reportPeriodStart
args["reportPeriodEnd"] = reportPeriodEnd
// 4) 之後再用你原來的 stockCategorySql / itemCodeSql / storeLocationSql
args["reportPeriodEndExclusive"] = endExclusive

val stockCategorySql = buildMultiValueExactClause(
stockCategory,
"it.type",
"stockCategory",
args
)
val itemCodeSql = buildMultiValueLikeClause(
itemCode,
"sl.itemCode",
"itemCode",
args
)

// 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數
val storeLocationSql = if (!storeLocation.isNullOrBlank()) {
args["storeLocation"] = "%$storeLocation%"
"AND (wh_in.code LIKE :storeLocation OR wh_out.code LIKE :storeLocation)"
"AND lot.storeLocation LIKE :storeLocation"
} else {
""
}
val reportPeriodEndSql = "AND DATE(sl.date) <= :reportPeriodEnd"

val sql = """
WITH base AS (
SELECT
sl.id AS slId,
DATE(sl.date) AS trnDateRaw,
DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate,
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE'
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ'
AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE'
ELSE COALESCE(sl.type, '')
END AS trnRefNoRaw,

sl.itemId AS itemId,
sl.itemCode AS itemCode,

COALESCE(sl.inQty, 0) AS inQty,
COALESCE(sl.outQty, 0) AS outQty,
(COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS delta,

it.type AS stockSubCategory,
it.code AS itemNo,
it.name AS itemName,
uc.udfudesc AS unitOfMeasure,

lot.lotNo AS lotNo,
COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
lot.storeLocation AS storeLocation,

COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo,
COALESCE(TRIM(jo.code), '') AS jobOrderNo,

'' AS openingBalance,
'' AS cumStockIn,
'' AS cumStockOut,
'' AS currentBalance,
'' AS lastInDate,
'' AS lastOutDate,
'' AS reOrderLevel,
'' AS reOrderQty
FROM stock_ledger sl
LEFT JOIN stock_in_line sil
ON sl.stockInLineId = sil.id
AND sil.deleted = 0
LEFT JOIN inventory_lot il_in
ON sil.inventoryLotId = il_in.id
AND il_in.deleted = 0

LEFT JOIN stock_out_line sol
ON sl.stockOutLineId = sol.id
AND sol.deleted = 0
LEFT JOIN inventory_lot_line ill_out
ON sol.inventoryLotLineId = ill_out.id
AND ill_out.deleted = 0
LEFT JOIN inventory_lot il_out
ON ill_out.inventoryLotId = il_out.id
AND il_out.deleted = 0

LEFT JOIN (
SELECT
il.id AS lotId,
il.lotNo AS lotNo,
il.expiryDate AS expiryDate,
MAX(wh.code) AS storeLocation
FROM inventory_lot il
LEFT JOIN inventory_lot_line ill
ON ill.inventoryLotId = il.id
AND ill.deleted = 0
LEFT JOIN warehouse wh
ON ill.warehouseId = wh.id
AND wh.deleted = 0
GROUP BY
il.id, il.lotNo, il.expiryDate
) lot
ON lot.lotId = COALESCE(il_in.id, il_out.id)

LEFT JOIN items it
ON sl.itemId = it.id
AND it.deleted = 0
LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id

-- 這兩個 alias 是為了配合你上面 storeLocationSql 的 wh_in / wh_out
LEFT JOIN inventory_lot_line ill_in
ON il_in.id = ill_in.inventoryLotId
AND ill_in.deleted = 0
LEFT JOIN warehouse wh_in
ON ill_in.warehouseId = wh_in.id
AND wh_in.deleted = 0
LEFT JOIN warehouse wh_out
ON ill_out.warehouseId = wh_out.id
AND wh_out.deleted = 0
LEFT JOIN stock_out so
ON sol.stockOutId = so.id
AND so.deleted = 0
LEFT JOIN pick_order_line pol
ON sol.pickOrderLineId = pol.id
AND pol.deleted = 0
LEFT JOIN pick_order po_out
ON pol.poId = po_out.id
AND po_out.deleted = 0
LEFT JOIN job_order jo_po
ON po_out.joId = jo_po.id
AND jo_po.deleted = 0
LEFT JOIN delivery_order do
ON po_out.doId = do.id
AND do.deleted = 0
LEFT JOIN stock_in si
ON sil.stockInId = si.id
AND si.deleted = 0
LEFT JOIN job_order jo
ON sil.jobOrderId = jo.id
AND jo.deleted = 0
LEFT JOIN purchase_order po
ON sil.purchaseOrderId = po.id
AND po.deleted = 0
WHERE
sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
AND DATE(sl.date) <= :reportPeriodEnd
$stockCategorySql
$itemCodeSql
$storeLocationSql
AND lot.lotId IS NOT NULL
),
opening AS (
SELECT
itemCode,
COALESCE(SUM(delta), 0) AS openingBeforeStart
FROM base
WHERE trnDateRaw < :reportPeriodStart
GROUP BY itemCode
),
period AS (
SELECT
b.*,
COALESCE(o.openingBeforeStart, 0) AS openingBeforeStart
FROM base b
LEFT JOIN opening o
ON o.itemCode = b.itemCode
WHERE b.trnDateRaw BETWEEN :reportPeriodStart AND :reportPeriodEnd
)
SELECT
stockSubCategory,
itemNo,
@@ -231,7 +87,7 @@ SELECT
unitOfMeasure,
lotNo,
expiryDate,
trnDate,
trnDate,
CASE trnRefNoRaw
WHEN 'OPEN' THEN '開倉'
WHEN 'NOR' THEN '出入倉'
@@ -255,39 +111,171 @@ SELECT
reOrderLevel,
reOrderQty,

-- jrxml 需要 String;負數括號顯示,無小數
CASE WHEN COALESCE(inQty, 0) < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(COALESCE(inQty, 0), 0) END AS stockIn,
CASE WHEN COALESCE(outQty, 0) < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(COALESCE(outQty, 0), 0) END AS stockOut,

-- 累計存量(跨 lot:只用 itemCode 分區)
CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)) < 0
THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)), 0), ')')
ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId), 0) END AS cumBalance,

-- 累計期初存量 = 本行累計 - 本行異動
CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta) < 0
THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta), 0), ')')
ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta, 0) END AS cumOpeningBal,

-- footer totals(同樣輸出 String)
CASE WHEN COALESCE(openingBeforeStart, 0) < 0 THEN CONCAT('(', FORMAT(-openingBeforeStart, 0), ')') ELSE FORMAT(COALESCE(openingBeforeStart, 0), 0) END AS totalCumOpeningBal,
CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockIn,
CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockOut,
CASE WHEN (openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)) < 0
THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)), 0), ')')
ELSE FORMAT(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode), 0) END AS totalCumBalance

FROM period
CASE WHEN inQty < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(inQty, 0) END AS stockIn,
CASE WHEN outQty < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(outQty, 0) END AS stockOut,

CASE WHEN cumOpeningBalRaw < 0
THEN CONCAT('(', FORMAT(-cumOpeningBalRaw, 0), ')')
ELSE FORMAT(cumOpeningBalRaw, 0) END AS cumOpeningBal,
CASE WHEN bal < 0
THEN CONCAT('(', FORMAT(-bal, 0), ')')
ELSE FORMAT(bal, 0) END AS cumBalance,

CASE WHEN totalCumOpeningBalRaw < 0
THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')')
ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal,
CASE WHEN totalStockInRaw < 0
THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')')
ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn,
CASE WHEN totalStockOutRaw < 0
THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')')
ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut,
CASE WHEN totalCumBalanceRaw < 0
THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')')
ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance

FROM (
SELECT
x.*,
FIRST_VALUE(cumOpeningBalRaw) OVER (
PARTITION BY itemCode ORDER BY trnDateRaw, slId
) AS totalCumOpeningBalRaw,
SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw,
SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw,
FIRST_VALUE(bal) OVER (
PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC
) AS totalCumBalanceRaw
FROM (
SELECT
sl.id AS slId,
DATE(sl.date) AS trnDateRaw,
DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate,
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE'
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ'
AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE'
ELSE COALESCE(sl.type, '')
END AS trnRefNoRaw,

sl.itemCode AS itemCode,
COALESCE(sl.inQty, 0) AS inQty,
COALESCE(sl.outQty, 0) AS outQty,
COALESCE(sl.balance, 0) AS bal,
COALESCE(sl.balance, 0)
- COALESCE(sl.inQty, 0)
+ COALESCE(sl.outQty, 0) AS cumOpeningBalRaw,

it.type AS stockSubCategory,
it.code AS itemNo,
it.name AS itemName,
uc.udfudesc AS unitOfMeasure,

lot.lotNo AS lotNo,
COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
lot.storeLocation AS storeLocation,

COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo,
COALESCE(TRIM(jo.code), '') AS jobOrderNo,

'' AS openingBalance,
'' AS cumStockIn,
'' AS cumStockOut,
'' AS currentBalance,
'' AS lastInDate,
'' AS lastOutDate,
'' AS reOrderLevel,
'' AS reOrderQty
FROM stock_ledger sl
LEFT JOIN stock_in_line sil
ON sl.stockInLineId = sil.id
AND sil.deleted = 0
LEFT JOIN inventory_lot il_in
ON sil.inventoryLotId = il_in.id
AND il_in.deleted = 0

LEFT JOIN stock_out_line sol
ON sl.stockOutLineId = sol.id
AND sol.deleted = 0
LEFT JOIN inventory_lot_line ill_out
ON sol.inventoryLotLineId = ill_out.id
AND ill_out.deleted = 0
LEFT JOIN inventory_lot il_out
ON ill_out.inventoryLotId = il_out.id
AND il_out.deleted = 0

LEFT JOIN (
SELECT
il.id AS lotId,
il.lotNo AS lotNo,
il.expiryDate AS expiryDate,
MAX(wh.code) AS storeLocation
FROM inventory_lot il
LEFT JOIN inventory_lot_line ill
ON ill.inventoryLotId = il.id
AND ill.deleted = 0
LEFT JOIN warehouse wh
ON ill.warehouseId = wh.id
AND wh.deleted = 0
WHERE il.deleted = 0
GROUP BY
il.id, il.lotNo, il.expiryDate
) lot
ON lot.lotId = COALESCE(il_in.id, il_out.id)

LEFT JOIN items it
ON sl.itemId = it.id
AND it.deleted = 0
LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id

LEFT JOIN stock_out so
ON sol.stockOutId = so.id
AND so.deleted = 0
LEFT JOIN pick_order_line pol
ON sol.pickOrderLineId = pol.id
AND pol.deleted = 0
LEFT JOIN pick_order po_out
ON pol.poId = po_out.id
AND po_out.deleted = 0
LEFT JOIN job_order jo_po
ON po_out.joId = jo_po.id
AND jo_po.deleted = 0
LEFT JOIN delivery_order do
ON po_out.doId = do.id
AND do.deleted = 0
LEFT JOIN stock_in si
ON sil.stockInId = si.id
AND si.deleted = 0
LEFT JOIN job_order jo
ON sil.jobOrderId = jo.id
AND jo.deleted = 0
LEFT JOIN purchase_order po
ON sil.purchaseOrderId = po.id
AND po.deleted = 0
WHERE
sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
AND sl.date >= :reportPeriodStart
AND sl.date < :reportPeriodEndExclusive
$stockCategorySql
$itemCodeSql
$storeLocationSql
AND lot.lotId IS NOT NULL
) x
) y
ORDER BY
itemNo,
trnDateRaw,
slId,
lotNo
""".trimIndent()
val result = jdbcDao.queryForList(sql, args)

return result
return jdbcDao.queryForList(sql, args)
}

/** LIKE 多值工具方法 */
@@ -327,4 +315,4 @@ ORDER BY
}
return "AND (${conditions.joinToString(" OR ")})"
}
}
}

+ 2
- 2
src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt ファイルの表示

@@ -23,7 +23,7 @@ import java.io.ByteArrayOutputStream
class DoInventoryUomMismatchReportController(
private val doInventoryUomMismatchReportService: DoInventoryUomMismatchReportService,
) {
/** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.0 | 2026-08-10 */
/** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */
@GetMapping("/print-do-inventory-uom-mismatch-excel")
fun exportExcel(
@RequestParam(required = false) deliveryDate: String?,
@@ -69,7 +69,7 @@ class DoInventoryUomMismatchReportController(
}

val columns = listOf(
"deliveryDate" to "預計貨日期",
"deliveryDate" to "預計貨日期",
"doCode" to "送貨單號",
"supplierCode" to "供應商編號",
"supplierName" to "供應商名稱",


+ 11
- 5
src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt ファイルの表示

@@ -21,6 +21,7 @@ class ItemQcFailReportController(
private val itemQcFailReportService: ItemQcFailReportService,
) {

/** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */
@GetMapping("/print-item-qc-fail")
fun generateItemQcFailReport(
@RequestParam(required = false) stockCategory: String?,
@@ -29,7 +30,7 @@ class ItemQcFailReportController(
@RequestParam(required = false) lastInDateEnd: String?,
@RequestParam(required = false) qcType: String?,
@RequestParam(required = false, defaultValue = "true") includeMeasurable: String?,
@RequestParam(required = false, defaultValue = "false") includeOther: String?,
@RequestParam(required = false, defaultValue = "true") includeOther: String?,
@RequestParam(required = false, defaultValue = "all") measurableScope: String?,
): ResponseEntity<ByteArray> {
val dbData = itemQcFailReportService.searchItemQcFailReport(
@@ -70,6 +71,7 @@ class ItemQcFailReportController(
return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */
@GetMapping("/print-item-qc-fail-excel")
fun exportItemQcFailReportExcel(
@RequestParam(required = false) stockCategory: String?,
@@ -78,7 +80,7 @@ class ItemQcFailReportController(
@RequestParam(required = false) lastInDateEnd: String?,
@RequestParam(required = false) qcType: String?,
@RequestParam(required = false, defaultValue = "true") includeMeasurable: String?,
@RequestParam(required = false, defaultValue = "false") includeOther: String?,
@RequestParam(required = false, defaultValue = "true") includeOther: String?,
@RequestParam(required = false, defaultValue = "all") measurableScope: String?,
): ResponseEntity<ByteArray> {
val dbData = itemQcFailReportService.searchItemQcFailReport(
@@ -297,7 +299,7 @@ class ItemQcFailReportController(
"不合格數量",
"實測值",
"備註",
"訂單/工單"
"訂單/工單",
)

run {
@@ -321,12 +323,16 @@ class ItemQcFailReportController(

fun writeNumber(col: Int, value: Any?) {
val raw = value?.toString()?.trim() ?: ""
val cleaned = raw.removeSuffix(".")
// SQL FORMAT() may emit thousand separators (e.g. "1,021") — strip before parse.
val cleaned = raw
.replace(",", "")
.replace(" ", "")
.removeSuffix(".")
val bd = cleaned.toBigDecimalOrNull()
val cell = row.createCell(col)

if (bd == null) {
cell.setCellValue(cleaned)
cell.setCellValue(raw)
cell.cellStyle = textStyle
} else {
val stripped = bd.stripTrailingZeros()


+ 201
- 172
src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt ファイルの表示

@@ -14,10 +14,10 @@ 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.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.apache.poi.xssf.streaming.SXSSFWorkbook
import java.io.ByteArrayOutputStream

@RestController
@@ -27,66 +27,66 @@ class StockLedgerReportController(
private val stockLedgerReportService: StockLedgerReportService,
) {
private data class ExcelStyles(
val title: XSSFCellStyle,
val subtitle: XSSFCellStyle,
val header: XSSFCellStyle,
val text: XSSFCellStyle,
val center: XSSFCellStyle,
val int: XSSFCellStyle,
val dash: XSSFCellStyle,
val sumQty: XSSFCellStyle,
val sumLabel: XSSFCellStyle,
val sumEmpty: XSSFCellStyle,
val sumHidden: XSSFCellStyle,
val title: CellStyle,
val subtitle: CellStyle,
val header: CellStyle,
val text: CellStyle,
val center: CellStyle,
val int: CellStyle,
val dash: CellStyle,
val sumQty: CellStyle,
val sumLabel: CellStyle,
val sumEmpty: CellStyle,
val sumHidden: CellStyle,
)

/** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */
@GetMapping("/print-stock-ledger")
fun generateStockLedgerReport(
@RequestParam(required = false) stockCategory: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) storeLocation: String?,
// URL 參數名仍然是 lastInDateStart / lastInDateEnd
@RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?,
@RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()

parameters["stockCategory"] = stockCategory ?: "All"
parameters["stockSubCategory"] = stockCategory ?: "All"
parameters["itemNo"] = itemCode ?: "All"
parameters["year"] = LocalDate.now().year.toString()
parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
fun generateStockLedgerReport(
@RequestParam(required = false) stockCategory: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) storeLocation: String?,
@RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?,
@RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()

parameters["storeLocation"] = storeLocation ?: ""
parameters["balanceFilterStart"] = ""
parameters["balanceFilterEnd"] = ""
parameters["reportPeriodStart"] = reportPeriodStart ?: ""
parameters["reportPeriodEnd"] = reportPeriodEnd ?: ""
parameters["stockCategory"] = stockCategory ?: "All"
parameters["stockSubCategory"] = stockCategory ?: "All"
parameters["itemNo"] = itemCode ?: "All"
parameters["year"] = LocalDate.now().year.toString()
parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))

parameters["storeLocation"] = storeLocation ?: ""
parameters["balanceFilterStart"] = ""
parameters["balanceFilterEnd"] = ""
parameters["reportPeriodStart"] = reportPeriodStart ?: ""
parameters["reportPeriodEnd"] = reportPeriodEnd ?: ""

val dbData = stockLedgerReportService.searchStockLedgerReport(
stockCategory = stockCategory,
itemCode = itemCode,
storeLocation = storeLocation,
reportPeriodStart = reportPeriodStart,
reportPeriodEnd = reportPeriodEnd,
)
val dbData = stockLedgerReportService.searchStockLedgerReport(
stockCategory = stockCategory,
itemCode = itemCode,
storeLocation = storeLocation,
reportPeriodStart = reportPeriodStart,
reportPeriodEnd = reportPeriodEnd,
)

val pdfBytes = reportService.createPdfResponse(
"/jasper/StockLedgarReport.jrxml",
parameters,
dbData
)
val pdfBytes = reportService.createPdfResponse(
"/jasper/StockLedgarReport.jrxml",
parameters,
dbData,
)

val headers = HttpHeaders().apply {
contentType = MediaType.APPLICATION_PDF
setContentDispositionFormData("attachment", "StockLedgerReport.pdf")
set("filename", "StockLedgerReport.pdf")
val headers = HttpHeaders().apply {
contentType = MediaType.APPLICATION_PDF
setContentDispositionFormData("attachment", "StockLedgerReport.pdf")
set("filename", "StockLedgerReport.pdf")
}
return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}
return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */
@GetMapping("/print-stock-ledger-excel")
fun exportStockLedgerReportExcel(
@RequestParam(required = false) stockCategory: String?,
@@ -119,8 +119,8 @@ fun generateStockLedgerReport(
return ResponseEntity(excelBytes, headers, HttpStatus.OK)
}

private fun createStyles(workbook: XSSFWorkbook): ExcelStyles {
val titleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
private fun createStyles(workbook: Workbook): ExcelStyles {
val titleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
@@ -129,7 +129,7 @@ fun generateStockLedgerReport(
}
setFont(font)
}
val subtitleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
val subtitleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
@@ -138,7 +138,7 @@ fun generateStockLedgerReport(
}
setFont(font)
}
val headerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
val headerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
@@ -150,7 +150,7 @@ fun generateStockLedgerReport(
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val textStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
val textStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
@@ -158,7 +158,7 @@ fun generateStockLedgerReport(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val centerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
val centerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
@@ -166,7 +166,7 @@ fun generateStockLedgerReport(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val intStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
val intStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
@@ -176,7 +176,7 @@ fun generateStockLedgerReport(
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
}
val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
val dashStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
@@ -184,7 +184,7 @@ fun generateStockLedgerReport(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val sumQty = (workbook.createCellStyle() as XSSFCellStyle).apply {
val sumQty = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
val df: DataFormat = workbook.createDataFormat()
@@ -196,7 +196,7 @@ fun generateStockLedgerReport(
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val sumLabel = (workbook.createCellStyle() as XSSFCellStyle).apply {
val sumLabel = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THICK
@@ -206,7 +206,7 @@ fun generateStockLedgerReport(
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val sumEmpty = (workbook.createCellStyle() as XSSFCellStyle).apply {
val sumEmpty = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THICK
@@ -214,7 +214,7 @@ fun generateStockLedgerReport(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val sumHidden = (workbook.createCellStyle() as XSSFCellStyle).apply {
val sumHidden = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THICK
@@ -239,7 +239,7 @@ fun generateStockLedgerReport(
)
}

private fun setTextCell(row: Row, col: Int, value: Any?, style: XSSFCellStyle) {
private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) {
row.createCell(col).apply {
setCellValue(value?.toString() ?: "")
cellStyle = style
@@ -259,8 +259,8 @@ fun generateStockLedgerReport(
row: Row,
col: Int,
value: Any?,
intStyle: XSSFCellStyle,
dashStyle: XSSFCellStyle,
intStyle: CellStyle,
dashStyle: CellStyle,
) {
val cell = row.createCell(col)
val parsed = parseSignedNumber(value)
@@ -279,131 +279,160 @@ fun generateStockLedgerReport(
}
}

/**
* SXSSF keeps only a sliding window of rows in memory to avoid OOM on large exports.
*/
private fun createStockLedgerExcel(
dbData: List<Map<String, Any>>,
reportPeriodStart: String,
reportPeriodEnd: String,
): ByteArray {
val workbook = XSSFWorkbook()
val styles = createStyles(workbook)
val reportTitle = "庫存明細報告"
val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle))

val headers = listOf(
"貨品編號", "貨品名稱", "單位",
"出入賬日期", "類型", "批號", "到期日",
"纍計期初存量", "入庫", "出庫", "纍計存量",
"參考編號", "存貨位置",
)
val totalColumns = headers.size
var rowIndex = 0
val workbook = SXSSFWorkbook(100)
workbook.setCompressTempFiles(true)
try {
val styles = createStyles(workbook)
val reportTitle = "庫存明細報告"
val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle))

val titleRow = sheet.createRow(rowIndex++)
titleRow.createCell(0).apply {
setCellValue(reportTitle)
cellStyle = styles.title
}
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1))

val reportDateTime =
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) +
"(" +
LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) +
")"
val subtitleRow = sheet.createRow(rowIndex++)
subtitleRow.createCell(0).apply {
setCellValue("報告日期:$reportDateTime")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4))
subtitleRow.createCell(5).apply {
setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1))
sheet.createRow(rowIndex++)
val headers = listOf(
"貨品編號", "貨品名稱", "單位",
"出入賬日期", "類型", "批號", "到期日",
"纍計期初存量", "入庫", "出庫", "纍計存量",
"參考編號", "存貨位置",
)
val totalColumns = headers.size
var rowIndex = 0

val headerRowIndex = rowIndex
val headerRow = sheet.createRow(rowIndex++)
headers.forEachIndexed { i, h ->
headerRow.createCell(i).apply {
setCellValue(h)
cellStyle = styles.header
val titleRow = sheet.createRow(rowIndex++)
titleRow.createCell(0).apply {
setCellValue(reportTitle)
cellStyle = styles.title
}
}
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1))

fun addItemSummaryRow(itemNo: String, itemName: String, uom: String, totalIn: Any?, totalOut: Any?, totalBal: Any?) {
val r = sheet.createRow(rowIndex++)
r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden }
r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden }
r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden }
for (c in 3 until totalColumns) {
r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty }
val reportDateTime =
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) +
"(" +
LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) +
")"
val subtitleRow = sheet.createRow(rowIndex++)
subtitleRow.createCell(0).apply {
setCellValue("報告日期:$reportDateTime")
cellStyle = styles.subtitle
}
// totals should align with numeric columns (shift right by 1)
r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel }
setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash)
setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash)
setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash)
}

if (dbData.isEmpty()) {
val r = sheet.createRow(rowIndex++)
for (c in 0 until totalColumns) {
r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text }
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4))
subtitleRow.createCell(5).apply {
setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}")
cellStyle = styles.subtitle
}
} else {
var currentItemNo: String? = null
var currentItemName = ""
var currentUom = ""
var lastTotals: Triple<Any?, Any?, Any?> = Triple(null, null, null)
sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1))
sheet.createRow(rowIndex++)

dbData.forEach { m ->
val itemNo = m["itemNo"]?.toString().orEmpty()
val itemName = m["itemName"]?.toString().orEmpty()
val uom = m["unitOfMeasure"]?.toString().orEmpty()
val headerRowIndex = rowIndex
val headerRow = sheet.createRow(rowIndex++)
headers.forEachIndexed { i, h ->
headerRow.createCell(i).apply {
setCellValue(h)
cellStyle = styles.header
}
}

if (currentItemNo != null && itemNo != currentItemNo) {
addItemSummaryRow(currentItemNo!!, currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third)
sheet.createRow(rowIndex++)
fun addItemSummaryRow(
itemNo: String,
itemName: String,
uom: String,
totalIn: Any?,
totalOut: Any?,
totalBal: Any?,
) {
val r = sheet.createRow(rowIndex++)
r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden }
r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden }
r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden }
for (c in 3 until totalColumns) {
r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty }
}
r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel }
setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash)
setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash)
setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash)
}

if (dbData.isEmpty()) {
val r = sheet.createRow(rowIndex++)
setTextCell(r, 0, itemNo, styles.text)
setTextCell(r, 1, itemName, styles.text)
setTextCell(r, 2, uom, styles.center)
setTextCell(r, 3, m["trnDate"], styles.center)
val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t ->
if (t.equals("Expiry", ignoreCase = true)) "過期" else t
for (c in 0 until totalColumns) {
r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text }
}
setTextCell(r, 4, typeText, styles.center)
setTextCell(r, 5, m["lotNo"], styles.text)
setTextCell(r, 6, m["expiryDate"], styles.center)
setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash)
setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash)
setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash)
setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash)
setTextCell(r, 11, m["orderRefNo"], styles.text)
setTextCell(r, 12, m["storeLocation"], styles.center)
} else {
var currentItemNo: String? = null
var currentItemName = ""
var currentUom = ""
var lastTotals: Triple<Any?, Any?, Any?> = Triple(null, null, null)

dbData.forEach { m ->
val itemNo = m["itemNo"]?.toString().orEmpty()
val itemName = m["itemName"]?.toString().orEmpty()
val uom = m["unitOfMeasure"]?.toString().orEmpty()

currentItemNo = itemNo
currentItemName = itemName
currentUom = uom
lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"])
if (currentItemNo != null && itemNo != currentItemNo) {
addItemSummaryRow(
currentItemNo!!,
currentItemName,
currentUom,
lastTotals.first,
lastTotals.second,
lastTotals.third,
)
sheet.createRow(rowIndex++)
}

val r = sheet.createRow(rowIndex++)
setTextCell(r, 0, itemNo, styles.text)
setTextCell(r, 1, itemName, styles.text)
setTextCell(r, 2, uom, styles.center)
setTextCell(r, 3, m["trnDate"], styles.center)
val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t ->
if (t.equals("Expiry", ignoreCase = true)) "過期" else t
}
setTextCell(r, 4, typeText, styles.center)
setTextCell(r, 5, m["lotNo"], styles.text)
setTextCell(r, 6, m["expiryDate"], styles.center)
setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash)
setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash)
setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash)
setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash)
setTextCell(r, 11, m["orderRefNo"], styles.text)
setTextCell(r, 12, m["storeLocation"], styles.center)

currentItemNo = itemNo
currentItemName = itemName
currentUom = uom
lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"])
}

addItemSummaryRow(
currentItemNo ?: "",
currentItemName,
currentUom,
lastTotals.first,
lastTotals.second,
lastTotals.third,
)
}

addItemSummaryRow(currentItemNo ?: "", currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third)
}
val lastRowIndex = rowIndex - 1
if (lastRowIndex >= headerRowIndex) {
sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0))
}
val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12)
widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val lastRowIndex = rowIndex - 1
if (lastRowIndex >= headerRowIndex) {
sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0))
val out = ByteArrayOutputStream()
workbook.write(out)
return out.toByteArray()
} finally {
workbook.dispose()
workbook.close()
}
val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12)
widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val out = ByteArrayOutputStream()
workbook.use { it.write(out) }
return out.toByteArray()
}
}
}

+ 1
- 1
src/main/resources/jasper/StockLedgarReport.jrxml ファイルの表示

@@ -79,7 +79,7 @@
<band height="18">
<textField textAdjust="StretchHeight">
<reportElement isPrintRepeatedValues="false" x="11" y="0" width="579" height="18" uuid="5b2d2e95-26eb-4e8c-93ba-99eaed3490df"/>
<textElement textAlignment="Left" verticalAlignment="Top" markup="styled">
<textElement textAlignment="Left" verticalAlignment="Top" markup="none">
<font fontName="微軟正黑體" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{itemNo}+" "+$F{itemName}+" "+$F{unitOfMeasure}]]></textFieldExpression>


読み込み中…
キャンセル
保存