Преглед изворни кода

added unit showing refine in stock take, allow user to set a warning percent to remind stock take difference when inputing the stock qty, try to quicken the transaction inventory report for PDF and excel

production
Fai Luk пре 17 часа
родитељ
комит
586bb09805
11 измењених фајлова са 578 додато и 225 уклоњено
  1. +6
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  2. +12
    -10
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  3. +257
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt
  4. +225
    -187
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt
  5. +7
    -27
      src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt
  6. +13
    -0
      src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java
  7. +6
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  8. +2
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt
  9. +9
    -0
      src/main/resources/db/changelog/changes/20260923_stock_take_qty_gap/01_setting.sql
  10. +1
    -1
      src/main/resources/jasper/StockLedgarReport.jrxml
  11. +40
    -0
      src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt

+ 6
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java Прегледај датотеку

@@ -77,6 +77,12 @@ public abstract class SettingNames {
* Job order plan-start overdue batch (default 00:00:15 daily): hide or reschedule JOs whose plan day was yesterday.
*/
public static final String SCHEDULE_JO_PLAN_START = "SCHEDULE.jo.planStart";

/**
* Stock-take count vs on-hand gap (percent) that shows a non-blocking warning.
* Either direction. Default 50.
*/
public static final String STOCK_TAKE_QTY_GAP_WARN_PERCENT = "STOCK_TAKE.qtyGapWarnPercent";
/*
* Mail settings
*/


+ 12
- 10
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt Прегледај датотеку

@@ -4,7 +4,7 @@ import org.springframework.stereotype.Service
import net.sf.jasperreports.engine.*
import net.sf.jasperreports.engine.data.JRMapCollectionDataSource
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.util.concurrent.ConcurrentHashMap
import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.m18.M18GrnRules
import com.ffii.fpsms.modules.master.entity.ShopRepository
@@ -28,6 +28,15 @@ open class ReportService(
private val shopRepository: ShopRepository,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) {
private val compiledReports = ConcurrentHashMap<String, JasperReport>()

private fun compiledReport(templatePath: String): JasperReport =
compiledReports.computeIfAbsent(templatePath) { path ->
val stream = this::class.java.getResourceAsStream(path)
?: throw RuntimeException("Report template not found: $path")
stream.use { JasperCompileManager.compileReport(it) }
}

/**
* Queries the database for inventory data based on dates and optional item type.
*/
@@ -1731,9 +1740,7 @@ return result
* Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template.
*/
fun createExcelResponse(templatePath: String, params: Map<String, Any>, dataList: List<Map<String, Any>>): ByteArray {
val stream = this::class.java.getResourceAsStream(templatePath)
?: throw RuntimeException("Report template not found: $templatePath")
val jasperReport = JasperCompileManager.compileReport(stream)
val jasperReport = compiledReport(templatePath)
val dataSource = JRMapCollectionDataSource(dataList)
val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource)
val out = ByteArrayOutputStream()
@@ -1748,13 +1755,8 @@ return result
* Compiles and fills a Jasper Report, returning the PDF as a ByteArray.
*/
fun createPdfResponse(templatePath: String, params: Map<String, Any>, dataList: List<Map<String, Any>>): ByteArray {
val stream = this::class.java.getResourceAsStream(templatePath)
?: throw RuntimeException("Report template not found: $templatePath")
val jasperReport = JasperCompileManager.compileReport(stream)
val jasperReport = compiledReport(templatePath)
val dataSource = JRMapCollectionDataSource(dataList)
val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource)
return JasperExportManager.exportReportToPdf(jasperPrint)
}


+ 257
- 0
src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt Прегледај датотеку

@@ -0,0 +1,257 @@
package com.ffii.fpsms.modules.report.service

import org.apache.pdfbox.pdmodel.PDDocument
import org.apache.pdfbox.pdmodel.PDPage
import org.apache.pdfbox.pdmodel.PDPageContentStream
import org.apache.pdfbox.pdmodel.common.PDRectangle
import org.apache.pdfbox.pdmodel.font.PDFont
import org.apache.pdfbox.pdmodel.font.PDType0Font
import org.apache.pdfbox.util.Matrix
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream

/**
* Draws 庫存明細報告 directly. Jasper measured each cell and kept whole item groups
* together, which is what made large PDFs take tens of seconds.
*/
object StockLedgerPdf {
private const val PAGE_W = 842f
private const val PAGE_H = 595f
private const val ROW_H = 13f
private const val BOTTOM = 22f
private const val BODY_SIZE = 8f
private const val HEADER_SIZE = 8f

private val fontBytes: ByteArray by lazy {
StockLedgerPdf::class.java.getResourceAsStream("/fonts/msjh_0.ttf")?.readBytes()
?: error("Font not found: /fonts/msjh_0.ttf")
}

private data class Col(val x: Float, val w: Float, val title: String, val alignRight: Boolean = false)

private val columns = listOf(
Col(11f, 78f, "出入賬日期"),
Col(91f, 58f, "類型"),
Col(151f, 120f, "批號"),
Col(273f, 72f, "到期日"),
Col(347f, 58f, "纍計期初", alignRight = true),
Col(407f, 48f, "入庫", alignRight = true),
Col(457f, 48f, "出庫", alignRight = true),
Col(507f, 62f, "纍計存量", alignRight = true),
Col(571f, 140f, "參考編號"),
Col(713f, 112f, "存貨位置"),
)

fun write(
rows: List<Map<String, Any>>,
reportDate: String,
reportTime: String,
periodStart: String,
periodEnd: String,
): ByteArray {
PDDocument().use { doc ->
val font = PDType0Font.load(doc, ByteArrayInputStream(fontBytes), true)
val canvas = Canvas(doc, font, reportDate, reportTime, periodStart, periodEnd)
if (rows.isEmpty()) {
canvas.ensureRow()
canvas.text(columns[0].x, canvas.y, "-", BODY_SIZE)
} else {
var itemNo: String? = null
var count = 0
var totalIn = ""
var totalOut = ""
var totalBal = ""
fun closeGroup() {
if (count > 1) {
canvas.ensureRow()
canvas.text(250f, canvas.y, "貨品總量:", BODY_SIZE, 95f, alignRight = true)
canvas.text(columns[5].x, canvas.y, totalIn, BODY_SIZE, columns[5].w, alignRight = true)
canvas.text(columns[6].x, canvas.y, totalOut, BODY_SIZE, columns[6].w, alignRight = true)
canvas.text(columns[7].x, canvas.y, totalBal, BODY_SIZE, columns[7].w, alignRight = true)
canvas.advance()
} else if (count == 1) {
canvas.advance(4f)
}
}
for (row in rows) {
val nextItem = row["itemNo"]?.toString().orEmpty()
if (nextItem != itemNo) {
closeGroup()
itemNo = nextItem
count = 0
val name = row["itemName"]?.toString().orEmpty()
val uom = row["unitOfMeasure"]?.toString().orEmpty()
val itemTitle = listOf(nextItem, name, uom).filter { it.isNotBlank() }.joinToString(" ")
canvas.ensureRow()
canvas.text(columns[0].x, canvas.y, itemTitle, BODY_SIZE + 1f)
canvas.advance()
}
canvas.ensureRow()
canvas.cell(0, row["trnDate"])
canvas.cell(1, row["trnRefNo"])
canvas.cell(2, row["lotNo"])
canvas.cell(3, row["expiryDate"])
canvas.cell(4, row["cumOpeningBal"])
canvas.cell(5, row["stockIn"])
canvas.cell(6, row["stockOut"])
canvas.cell(7, row["cumBalance"])
canvas.cell(8, row["orderRefNo"])
canvas.cell(9, row["storeLocation"])
canvas.advance()
count++
totalIn = row["totalStockIn"]?.toString().orEmpty()
totalOut = row["totalStockOut"]?.toString().orEmpty()
totalBal = row["totalCumBalance"]?.toString().orEmpty()
}
closeGroup()
}
canvas.closePage()
canvas.stampPageNumbers()
val out = ByteArrayOutputStream()
doc.save(out)
return out.toByteArray()
}
}

private class Canvas(
private val doc: PDDocument,
private val font: PDFont,
private val reportDate: String,
private val reportTime: String,
private val periodStart: String,
private val periodEnd: String,
) {
private val pages = ArrayList<PDPage>()
private var stream: PDPageContentStream? = null
private var textOpen = false
var y = 0f
private set

init {
newPage()
}

fun ensureRow() {
if (y < BOTTOM + ROW_H) newPage()
}

fun advance(dy: Float = ROW_H) {
y -= dy
}

fun cell(index: Int, value: Any?) {
val col = columns[index]
text(col.x, y, value?.toString().orEmpty(), BODY_SIZE, col.w, col.alignRight)
}

fun text(x: Float, baseline: Float, raw: String, size: Float, width: Float = 700f, alignRight: Boolean = false) {
val value = fit(raw, size, width - 2f)
if (value.isEmpty()) return
openText()
val drawX = if (alignRight) {
x + width - 2f - widthOf(value, size)
} else {
x
}
stream!!.setFont(font, size)
stream!!.setTextMatrix(Matrix(1f, 0f, 0f, 1f, drawX, baseline))
stream!!.showText(value)
}

fun closePage() {
endText()
stream?.close()
stream = null
}

fun stampPageNumbers() {
val total = pages.size
pages.forEachIndexed { index, page ->
PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
val label = "頁數 ${index + 1} / $total"
cs.beginText()
cs.setFont(font, 9f)
val labelX = PAGE_W - 18f - widthOf(label, 9f)
cs.setTextMatrix(Matrix(1f, 0f, 0f, 1f, labelX, PAGE_H - 28f))
cs.showText(label)
cs.endText()
}
}
}

private fun newPage() {
closePage()
val page = PDPage(PDRectangle(PAGE_W, PAGE_H))
doc.addPage(page)
pages.add(page)
stream = PDPageContentStream(doc, page)
y = PAGE_H - 36f
openText()
text(300f, y, "庫存明細報告", 14f)
y -= 18f
text(18f, y, "報告日期:$reportDate 報告時間:$reportTime", 9f)
val period = if (periodStart.isNotBlank() || periodEnd.isNotBlank()) {
"報告期間:${periodStart.ifBlank { "—" }} 至 ${periodEnd.ifBlank { "—" }}"
} else {
""
}
if (period.isNotEmpty()) {
text(420f, y, period, 9f)
}
y -= 16f
endText()
val lineY = y + 4f
stream!!.moveTo(11f, lineY)
stream!!.lineTo(PAGE_W - 16f, lineY)
stream!!.stroke()
y -= 12f
openText()
for (col in columns) {
text(col.x, y, col.title, HEADER_SIZE, col.w, col.alignRight)
}
y -= 4f
endText()
val lineY2 = y
stream!!.moveTo(11f, lineY2)
stream!!.lineTo(PAGE_W - 16f, lineY2)
stream!!.stroke()
y -= 14f
}

private fun openText() {
if (!textOpen) {
stream!!.beginText()
textOpen = true
}
}

private fun endText() {
if (textOpen) {
stream!!.endText()
textOpen = false
}
}

private fun fit(raw: String, size: Float, maxWidth: Float): String {
val cleaned = raw.replace('\n', ' ').replace('\r', ' ')
if (cleaned.isEmpty() || fits(cleaned, size, maxWidth)) return cleaned
var end = cleaned.length
while (end > 0 && widthOf(cleaned.substring(0, end), size) > maxWidth) {
end--
}
return if (end == cleaned.length) cleaned else cleaned.substring(0, end)
}

private fun fits(text: String, size: Float, maxWidth: Float): Boolean {
var units = 0f
for (ch in text) {
units += if (ch.code < 128) 0.56f else 1f
if (units * size > maxWidth) return false
}
return true
}

private fun widthOf(text: String, size: Float): Float =
font.getStringWidth(text) / 1000f * size
}
}

+ 225
- 187
src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt Прегледај датотеку

@@ -2,8 +2,13 @@ package com.ffii.fpsms.modules.report.service

import com.ffii.core.support.JdbcDao
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale

@Service
open class StockLedgerReportService(
@@ -23,6 +28,8 @@ open class StockLedgerReportService(
* - cumOpeningBal = balance - inQty + outQty(異動前纍計期初)
*
* 只查 [start, end] 期間列,不掃起日以前全歷史。
* 批號/位置直接跟這筆出入庫的批次,不再先彙整全庫批次。
* 貨品總量在取出明細後計算,避免對寬結果做 window sort。
*/
fun searchStockLedgerReport(
stockCategory: String?,
@@ -71,213 +78,244 @@ open class StockLedgerReportService(
args
)

// 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數
val storeLocationSql = if (!storeLocation.isNullOrBlank()) {
args["storeLocation"] = "%$storeLocation%"
"AND lot.storeLocation LIKE :storeLocation"
"AND wh.code LIKE :storeLocation"
} else {
""
}

// Wide reports are a date range over the whole ledger. Pin that index so MySQL
// does not start from another stock_ledger index and scan extra rows.
val ledgerIndex = if (itemCode.isNullOrBlank()) "FORCE INDEX (idx_sl_deleted_date)" else ""

val sql = """
SELECT
stockSubCategory,
sl.id AS slId,
sl.itemCode AS itemCode,
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,
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,
uom.unitOfMeasure AS unitOfMeasure,
COALESCE(il_in.lotNo, il_out.lotNo) AS lotNo,
COALESCE(
DATE_FORMAT(il_in.expiryDate, '%Y-%m-%d'),
DATE_FORMAT(il_out.expiryDate, '%Y-%m-%d'),
''
) AS expiryDate,
wh.code AS storeLocation,
COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo,
COALESCE(TRIM(jo.code), '') AS jobOrderNo
FROM stock_ledger sl $ledgerIndex
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 inventory_lot_line ill_in_loc
ON ill_in_loc.inventoryLotId = il_in.id
AND ill_in_loc.deleted = 0
LEFT JOIN warehouse wh
ON wh.id = COALESCE(ill_in_loc.warehouseId, ill_out.warehouseId)
AND wh.deleted = 0
LEFT JOIN items it
ON sl.itemId = it.id
AND it.deleted = 0
LEFT JOIN (
SELECT iu.itemId, MIN(uc.udfudesc) AS unitOfMeasure
FROM item_uom iu
JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE iu.stockUnit = 1
AND iu.deleted = 0
GROUP BY iu.itemId
) uom ON uom.itemId = it.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 COALESCE(il_in.id, il_out.id) IS NOT NULL
ORDER BY
itemNo,
itemName,
unitOfMeasure,
lotNo,
expiryDate,
trnDate,
CASE trnRefNoRaw
WHEN 'OPEN' THEN '開倉'
WHEN 'NOR' THEN '出入倉'
WHEN 'TRF' THEN '轉倉'
WHEN 'ADJ' THEN '調整'
WHEN 'MISS' THEN '遺失'
WHEN 'BAD' THEN '不良品'
WHEN 'TKE' THEN '盤點'
ELSE trnRefNoRaw
END AS trnRefNo,
storeLocation,
orderRefNo,
jobOrderNo,

openingBalance,
cumStockIn,
cumStockOut,
currentBalance,
lastInDate,
lastOutDate,
reOrderLevel,
reOrderQty,

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,
slId,
lotNo
""".trimIndent()

lot.lotNo AS lotNo,
COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
lot.storeLocation AS storeLocation,
return attachItemTotals(jdbcDao.queryForList(sql, args))
}

COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo,
COALESCE(TRIM(jo.code), '') AS jobOrderNo,
/**
* Item totals match the old window: first opening, sum in/out, last balance per itemCode.
* Rows stay ordered by item, date, id.
*/
private fun attachItemTotals(rows: List<Map<String, Any>>): List<Map<String, Any>> {
if (rows.isEmpty()) return rows
val totals = HashMap<String, LedgerItemTotals>(rows.size.coerceAtMost(4096))
for (row in rows) {
val key = row["itemCode"]?.toString().orEmpty()
val bucket = totals.getOrPut(key) { LedgerItemTotals() }
val inQty = toQty(row["inQty"])
val outQty = toQty(row["outQty"])
val opening = toQty(row["cumOpeningBalRaw"])
val closing = toQty(row["bal"])
val date = row["trnDate"]?.toString().orEmpty()
val id = toLongId(row["slId"])
bucket.inSum = bucket.inSum.add(inQty)
bucket.outSum = bucket.outSum.add(outQty)
if (!bucket.seen || earlier(date, id, bucket.firstDate, bucket.firstId)) {
bucket.opening = opening
bucket.firstDate = date
bucket.firstId = id
}
if (!bucket.seen || later(date, id, bucket.lastDate, bucket.lastId)) {
bucket.closing = closing
bucket.lastDate = date
bucket.lastId = id
}
bucket.seen = true
}

'' 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
val fmt = DecimalFormat("#,##0", DecimalFormatSymbols(Locale.US))
val out = ArrayList<Map<String, Any>>(rows.size)
for (row in rows) {
val bucket = totals.getValue(row["itemCode"]?.toString().orEmpty())
val trnRefNoRaw = row["trnRefNoRaw"]?.toString().orEmpty()
out.add(
linkedMapOf(
"stockSubCategory" to text(row["stockSubCategory"]),
"itemNo" to text(row["itemNo"]),
"itemName" to text(row["itemName"]),
"unitOfMeasure" to text(row["unitOfMeasure"]),
"lotNo" to text(row["lotNo"]),
"expiryDate" to text(row["expiryDate"]),
"trnDate" to text(row["trnDate"]),
"trnRefNo" to ledgerTypeLabel(trnRefNoRaw),
"storeLocation" to text(row["storeLocation"]),
"orderRefNo" to text(row["orderRefNo"]),
"jobOrderNo" to text(row["jobOrderNo"]),
"openingBalance" to "",
"cumStockIn" to "",
"cumStockOut" to "",
"currentBalance" to "",
"lastInDate" to "",
"lastOutDate" to "",
"reOrderLevel" to "",
"reOrderQty" to "",
"stockIn" to formatLedgerQty(toQty(row["inQty"]), fmt),
"stockOut" to formatLedgerQty(toQty(row["outQty"]), fmt),
"cumOpeningBal" to formatLedgerQty(toQty(row["cumOpeningBalRaw"]), fmt),
"cumBalance" to formatLedgerQty(toQty(row["bal"]), fmt),
"totalCumOpeningBal" to formatLedgerQty(bucket.opening, fmt),
"totalStockIn" to formatLedgerQty(bucket.inSum, fmt),
"totalStockOut" to formatLedgerQty(bucket.outSum, fmt),
"totalCumBalance" to formatLedgerQty(bucket.closing, fmt),
)
)
}
return out
}

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
private class LedgerItemTotals {
var inSum: BigDecimal = BigDecimal.ZERO
var outSum: BigDecimal = BigDecimal.ZERO
var opening: BigDecimal = BigDecimal.ZERO
var closing: BigDecimal = BigDecimal.ZERO
var firstDate: String = ""
var firstId: Long = 0
var lastDate: String = ""
var lastId: Long = 0
var seen: Boolean = false
}

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)
private fun earlier(date: String, id: Long, otherDate: String, otherId: Long): Boolean =
date < otherDate || (date == otherDate && id < otherId)

private fun later(date: String, id: Long, otherDate: String, otherId: Long): Boolean =
date > otherDate || (date == otherDate && id > otherId)

private fun ledgerTypeLabel(raw: String): String = when (raw) {
"OPEN" -> "開倉"
"NOR" -> "出入倉"
"TRF" -> "轉倉"
"ADJ" -> "調整"
"MISS" -> "遺失"
"BAD" -> "不良品"
"TKE" -> "盤點"
else -> raw
}

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
private fun formatLedgerQty(value: BigDecimal, fmt: DecimalFormat): String {
val rounded = value.setScale(0, RoundingMode.HALF_UP)
val text = fmt.format(rounded.abs())
return if (rounded.signum() < 0) "($text)" else text
}

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()
private fun toQty(value: Any?): BigDecimal = when (value) {
null -> BigDecimal.ZERO
is BigDecimal -> value
is Long -> BigDecimal.valueOf(value)
is Int -> BigDecimal.valueOf(value.toLong())
is Number -> BigDecimal(value.toString())
else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO
}

return jdbcDao.queryForList(sql, args)
private fun toLongId(value: Any?): Long = when (value) {
is Number -> value.toLong()
else -> value?.toString()?.toLongOrNull() ?: 0L
}

private fun text(value: Any?): String = value?.toString().orEmpty()

/** Full codes use equality; short tokens use prefix LIKE. */
private fun buildItemCodeFilterClause(
paramValue: String?,


+ 7
- 27
src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt Прегледај датотеку

@@ -5,8 +5,8 @@ import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens
import com.ffii.fpsms.modules.report.service.StockLedgerPdf
import com.ffii.fpsms.modules.report.service.StockLedgerReportService
import com.ffii.fpsms.modules.report.service.ReportService
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.CellStyle
import org.apache.poi.ss.usermodel.DataFormat
@@ -24,7 +24,6 @@ import java.io.ByteArrayOutputStream
@RestController
@RequestMapping("/report")
class StockLedgerReportController(
private val reportService: ReportService,
private val stockLedgerReportService: StockLedgerReportService,
) {
private data class ExcelStyles(
@@ -98,27 +97,6 @@ class StockLedgerReportController(
reportPeriodStart: String?,
reportPeriodEnd: String?,
): ResponseEntity<ByteArray> {
val tokenCount = ReportMultiValueTokens.split(itemCode).size
val parameters = mutableMapOf<String, Any>()

parameters["stockCategory"] = stockCategory ?: "All"
parameters["stockSubCategory"] = stockCategory ?: "All"
parameters["itemNo"] =
when {
itemCode.isNullOrBlank() -> "All"
tokenCount > 30 -> "$tokenCount items"
else -> itemCode
}
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,
@@ -127,10 +105,12 @@ class StockLedgerReportController(
reportPeriodEnd = reportPeriodEnd,
)

val pdfBytes = reportService.createPdfResponse(
"/jasper/StockLedgarReport.jrxml",
parameters,
dbData,
val pdfBytes = StockLedgerPdf.write(
rows = dbData,
reportDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")),
reportTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")),
periodStart = reportPeriodStart?.trim().orEmpty(),
periodEnd = reportPeriodEnd?.trim().orEmpty(),
)

val headers = HttpHeaders().apply {


+ 13
- 0
src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java Прегледај датотеку

@@ -3,6 +3,9 @@ package com.ffii.fpsms.modules.settings.web;
import java.util.List;

import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -14,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController;

import com.ffii.core.exception.BadRequestException;
import com.ffii.core.exception.NotFoundException;
import com.ffii.fpsms.modules.common.SettingNames;
import com.ffii.fpsms.modules.settings.entity.Settings;
import com.ffii.fpsms.modules.settings.service.SettingsService;

@@ -53,6 +57,9 @@ public class SettingsController{
}

private void applyUpdate(String name, UpdateReq body) {
if (SettingNames.STOCK_TAKE_QTY_GAP_WARN_PERCENT.equals(name) && !isAdmin()) {
throw new AccessDeniedException("ADMIN only");
}
Settings entity = this.settingsService.findByName(name)
.orElseThrow(NotFoundException::new);
if (!this.settingsService.validateType(entity.getType(), body.getValue())) {
@@ -63,6 +70,12 @@ public class SettingsController{
this.settingsService.save(entity);
}

private static boolean isAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) return false;
return auth.getAuthorities().stream().anyMatch(a -> "ADMIN".equals(a.getAuthority()));
}

public static class UpdateReq {
@NotBlank
private String value;


+ 6
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt Прегледај датотеку

@@ -805,6 +805,7 @@ open class StockTakeRecordService(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseName = warehouse?.name,
status = ill.status?.name,
@@ -1065,6 +1066,7 @@ open class StockTakeRecordService(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseName = warehouse?.name,
status = ill.status?.name,
@@ -1175,6 +1177,7 @@ open class StockTakeRecordService(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseArea = warehouse?.area,
warehouseName = warehouse?.name,
@@ -1270,6 +1273,7 @@ open class StockTakeRecordService(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseArea = warehouse?.area,
warehouseName = warehouse?.name,
@@ -2854,6 +2858,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseName = warehouse?.name,
status = ill.status?.name,
@@ -2954,6 +2959,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseName = warehouse?.name,
status = ill.status?.name,


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt Прегледај датотеку

@@ -78,6 +78,8 @@ data class InventoryLotDetailResponse(
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val approverTime: LocalDateTime? = null,
val lastSelect: Int? = null,
/** Stock UoM short label (e.g. 包), shown beside the count input. */
val uomShortDesc: String? = null,
)
data class InventoryLotLineListRequest(
val warehouseCode: String


+ 9
- 0
src/main/resources/db/changelog/changes/20260923_stock_take_qty_gap/01_setting.sql Прегледај датотеку

@@ -0,0 +1,9 @@
--liquibase formatted sql
--changeset fpsms:20260923_stock_take_qty_gap_warn_percent

INSERT INTO `settings` (`name`, `value`, `category`, `type`)
SELECT 'STOCK_TAKE.qtyGapWarnPercent', '50', 'STOCK_TAKE', 'integer'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `settings` WHERE `name` = 'STOCK_TAKE.qtyGapWarnPercent'
);

+ 1
- 1
src/main/resources/jasper/StockLedgarReport.jrxml Прегледај датотеку

@@ -73,7 +73,7 @@
<field name="cumBalance" class="java.lang.String"/>
<field name="jobOrderNo" class="java.lang.String"/>
<field name="orderRefNo" class="java.lang.String"/>
<group name="Group1" keepTogether="true">
<group name="Group1" keepTogether="false">
<groupExpression><![CDATA[$F{itemNo}]]></groupExpression>
<groupHeader>
<band height="18">


+ 40
- 0
src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt Прегледај датотеку

@@ -0,0 +1,40 @@
package com.ffii.fpsms.modules.report.service

import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class StockLedgerPdfSpeedTest {
@Test
fun writesLargeLedgerWithoutJasperLayout() {
val rows = ArrayList<Map<String, Any>>(40_000)
repeat(40_000) { i ->
val item = "PP%04d".format(i / 40)
rows.add(
linkedMapOf(
"itemNo" to item,
"itemName" to "測試貨品$item",
"unitOfMeasure" to "包",
"trnDate" to "2026-09-01",
"trnRefNo" to "出入倉",
"lotNo" to "LT-$i",
"expiryDate" to "2026-12-31",
"cumOpeningBal" to "1,200",
"stockIn" to "10",
"stockOut" to "0",
"cumBalance" to "1,210",
"orderRefNo" to "DO-20260901-${i % 1000}",
"storeLocation" to "W201",
"totalStockIn" to "400",
"totalStockOut" to "0",
"totalCumBalance" to "1,210",
)
)
}
val started = System.nanoTime()
val pdf = StockLedgerPdf.write(rows, "2026-09-23", "23:40:00", "2026-09-01", "2026-09-07")
val seconds = (System.nanoTime() - started) / 1_000_000_000.0
assertTrue(pdf.size > 1000, "pdf bytes=${pdf.size}")
assertTrue(pdf[0] == '%'.code.toByte())
assertTrue(seconds < 15.0, "pdf took ${"%.2f".format(seconds)}s for 40000 rows")
}
}

Loading…
Откажи
Сачувај