@@ -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).