| @@ -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.service.ItemUomService | |||||
| import java.math.BigDecimal | |||||
| import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter | import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter | ||||
| import net.sf.jasperreports.export.SimpleExporterInput | import net.sf.jasperreports.export.SimpleExporterInput | ||||
| import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput | import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput | ||||
| @@ -13,6 +15,7 @@ import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput | |||||
| @Service | @Service | ||||
| open class ReportService( | open class ReportService( | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| private val itemUomService: ItemUomService, | |||||
| ) { | ) { | ||||
| /** | /** | ||||
| * 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. | ||||
| @@ -916,6 +919,72 @@ fun searchMaterialStockOutTraceabilityReport( | |||||
| } | } | ||||
| } | } | ||||
| /** | |||||
| * 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). | |||||
| */ | |||||
| fun searchGrnPreviewM18(receiptDate: String): List<Map<String, Any?>> { | |||||
| val formatted = receiptDate.replace("/", "-") | |||||
| val args = mutableMapOf<String, Any>("receiptDate" to formatted) | |||||
| val sql = """ | |||||
| SELECT | |||||
| sil.id AS stockInLineId, | |||||
| po.id AS purchaseOrderId, | |||||
| po.code AS poCode, | |||||
| pol.id AS purchaseOrderLineId, | |||||
| CASE | |||||
| WHEN sil.dnNo = 'DN00000' OR sil.dnNo IS NULL THEN '' | |||||
| ELSE sil.dnNo | |||||
| END AS deliveryNoteNo, | |||||
| DATE_FORMAT(sil.receiptDate, '%Y-%m-%d') AS receiptDate, | |||||
| it.id AS itemId, | |||||
| COALESCE(it.code, '') AS itemCode, | |||||
| COALESCE(it.name, '') AS itemName, | |||||
| COALESCE(sil.acceptedQty, 0) AS acceptedQty, | |||||
| COALESCE(uc_pol.udfudesc, '') AS purchaseUomDesc, | |||||
| COALESCE(uc_stock.udfudesc, '') AS stockUomDesc, | |||||
| COALESCE(sil.status, '') AS status | |||||
| FROM stock_in_line sil | |||||
| LEFT JOIN items it ON sil.itemId = it.id | |||||
| LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id | |||||
| LEFT JOIN purchase_order_line pol ON sil.purchaseOrderLineId = pol.id | |||||
| LEFT JOIN uom_conversion uc_pol ON pol.uomId = uc_pol.id | |||||
| LEFT JOIN item_uom iu_stock ON it.id = iu_stock.itemId AND iu_stock.stockUnit = true AND iu_stock.deleted = false | |||||
| LEFT JOIN uom_conversion uc_stock ON iu_stock.uomId = uc_stock.id | |||||
| WHERE sil.deleted = false | |||||
| AND sil.receiptDate IS NOT NULL | |||||
| AND DATE(sil.receiptDate) = DATE(:receiptDate) | |||||
| AND sil.purchaseOrderId IS NOT NULL | |||||
| AND sil.status = 'completed' | |||||
| ORDER BY sil.purchaseOrderId, sil.purchaseOrderLineId, sil.id | |||||
| """.trimIndent() | |||||
| val rows = jdbcDao.queryForList(sql, args) | |||||
| return rows.map { row -> | |||||
| val itemId = (row["itemId"] as? Number)?.toLong() | |||||
| val acceptedQtyBd = when (val v = row["acceptedQty"]) { | |||||
| is BigDecimal -> v | |||||
| is Number -> BigDecimal(v.toString()) | |||||
| is String -> v.toBigDecimalOrNull() ?: BigDecimal.ZERO | |||||
| else -> BigDecimal.ZERO | |||||
| } | |||||
| val purchaseQtyBd = if (itemId != null) itemUomService.convertStockQtyToPurchaseQty(itemId, acceptedQtyBd) else acceptedQtyBd | |||||
| mapOf( | |||||
| "receiptDate" to row["receiptDate"], | |||||
| "poCode" to row["poCode"], | |||||
| "deliveryNoteNo" to row["deliveryNoteNo"], | |||||
| "stockInLineId" to row["stockInLineId"], | |||||
| "purchaseOrderLineId" to row["purchaseOrderLineId"], | |||||
| "itemCode" to row["itemCode"], | |||||
| "itemName" to row["itemName"], | |||||
| "stockQty" to acceptedQtyBd.toDouble(), | |||||
| "purchaseQty" to purchaseQtyBd.toDouble(), | |||||
| "purchaseUomDesc" to row["purchaseUomDesc"], | |||||
| "stockUomDesc" to row["stockUomDesc"], | |||||
| "status" to row["status"], | |||||
| ) | |||||
| } | |||||
| } | |||||
| /** | /** | ||||
| * Queries the database for Stock Balance Report data (one summarized row per item). | * Queries the database for Stock Balance Report data (one summarized row per item). | ||||
| * Uses stock_ledger with report period (fromDate/toDate): opening = before fromDate, cum in/out = in period, current = up to toDate. | * Uses stock_ledger with report period (fromDate/toDate): opening = before fromDate, cum in/out = in period, current = up to toDate. | ||||
| @@ -334,4 +334,51 @@ class ReportController( | |||||
| return mapOf("rows" to rows) | return mapOf("rows" to rows) | ||||
| } | } | ||||
| /** | |||||
| * DRY-RUN GRN preview for M18 (shows stock qty vs converted purchase qty). | |||||
| * | |||||
| * Example: `/report/grn-preview-m18?receiptDate=2026-03-16` | |||||
| * CSV (Excel-openable): `/report/grn-preview-m18?receiptDate=2026-03-16&format=csv` | |||||
| */ | |||||
| @GetMapping("/grn-preview-m18") | |||||
| fun getGrnPreviewM18( | |||||
| @RequestParam receiptDate: String, | |||||
| @RequestParam(required = false, defaultValue = "json") format: String, | |||||
| ): Any { | |||||
| val rows = reportService.searchGrnPreviewM18(receiptDate) | |||||
| if (format.equals("csv", ignoreCase = true)) { | |||||
| val headers = listOf( | |||||
| "receiptDate", | |||||
| "poCode", | |||||
| "deliveryNoteNo", | |||||
| "stockInLineId", | |||||
| "purchaseOrderLineId", | |||||
| "itemCode", | |||||
| "itemName", | |||||
| "stockQty", | |||||
| "purchaseQty", | |||||
| "stockUomDesc", | |||||
| "purchaseUomDesc", | |||||
| "status", | |||||
| ) | |||||
| val sb = StringBuilder() | |||||
| sb.append(headers.joinToString(",")).append("\n") | |||||
| rows.forEach { r -> | |||||
| val line = headers.joinToString(",") { h -> | |||||
| val v = r[h] | |||||
| val s = (v?.toString() ?: "").replace("\"", "\"\"") | |||||
| "\"$s\"" | |||||
| } | |||||
| sb.append(line).append("\n") | |||||
| } | |||||
| val bytes = sb.toString().toByteArray(Charsets.UTF_8) | |||||
| val httpHeaders = HttpHeaders().apply { | |||||
| contentType = MediaType("text", "csv") | |||||
| set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=grn-preview-m18-$receiptDate.csv") | |||||
| } | |||||
| return ResponseEntity(bytes, httpHeaders, HttpStatus.OK) | |||||
| } | |||||
| return mapOf("rows" to rows) | |||||
| } | |||||
| } | } | ||||
| @@ -68,6 +68,18 @@ fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): StockInLine? | |||||
| """) | """) | ||||
| fun findAllByPurchaseOrderIdAndDeletedFalseWithItemNames(@Param("purchaseOrderId") purchaseOrderId: Long): List<StockInLine> | fun findAllByPurchaseOrderIdAndDeletedFalseWithItemNames(@Param("purchaseOrderId") purchaseOrderId: Long): List<StockInLine> | ||||
| @Query(""" | |||||
| SELECT DISTINCT sil FROM StockInLine sil | |||||
| LEFT JOIN FETCH sil.item | |||||
| LEFT JOIN FETCH sil.purchaseOrderLine pol | |||||
| LEFT JOIN FETCH pol.item | |||||
| WHERE sil.purchaseOrder.id = :purchaseOrderId | |||||
| AND sil.deleted = false | |||||
| AND sil.status = 'completed' | |||||
| ORDER BY sil.id | |||||
| """) | |||||
| fun findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(@Param("purchaseOrderId") purchaseOrderId: Long): List<StockInLine> | |||||
| @Query(""" | @Query(""" | ||||
| SELECT sil FROM StockInLine sil | SELECT sil FROM StockInLine sil | ||||
| WHERE sil.receiptDate IS NOT NULL | WHERE sil.receiptDate IS NOT NULL | ||||
| @@ -520,12 +520,15 @@ open class StockInLineService( | |||||
| val antValues = byPol.map { (_, silList) -> | val antValues = byPol.map { (_, silList) -> | ||||
| val sil = silList.first() | val sil = silList.first() | ||||
| val pol = sil.purchaseOrderLine!! | val pol = sil.purchaseOrderLine!! | ||||
| // acceptedQty on StockInLine is in stock unit; M18 ant expects purchase unit qty | |||||
| // M18 GRN ant expects purchase unit qty; acceptedQty on StockInLine is in stock unit | |||||
| val totalStockQty = silList.sumOf { it.acceptedQty ?: BigDecimal.ZERO } | val totalStockQty = silList.sumOf { it.acceptedQty ?: BigDecimal.ZERO } | ||||
| val itemId = sil.item?.id ?: pol.item?.id | val itemId = sil.item?.id ?: pol.item?.id | ||||
| val totalQtyInPurchaseUnit = if (itemId != null) { | val totalQtyInPurchaseUnit = if (itemId != null) { | ||||
| itemUomService.convertStockQtyToPurchaseQty(itemId, totalStockQty) | itemUomService.convertStockQtyToPurchaseQty(itemId, totalStockQty) | ||||
| } else totalStockQty | |||||
| } else { | |||||
| logger.warn("[buildGoodsReceiptNoteRequest] No itemId for POL id=${pol.id}, using stock qty as fallback (may be wrong unit for M18)") | |||||
| totalStockQty | |||||
| } | |||||
| val unitIdFromDataLog = (pol.m18DataLog?.dataLog?.get("unitId") as? Number)?.toLong()?.toInt() | val unitIdFromDataLog = (pol.m18DataLog?.dataLog?.get("unitId") as? Number)?.toLong()?.toInt() | ||||
| val itemName = (sil.item?.name ?: pol.item?.name).orEmpty() // always non-null for M18 bDesc/bDesc_en | val itemName = (sil.item?.name ?: pol.item?.name).orEmpty() // always non-null for M18 bDesc/bDesc_en | ||||
| GoodsReceiptNoteAntValue( | GoodsReceiptNoteAntValue( | ||||
| @@ -605,8 +608,8 @@ open class StockInLineService( | |||||
| logger.info("[updatePurchaseOrderStatus] savedPo id=${savedPo.id}, status=${savedPo.status}") | logger.info("[updatePurchaseOrderStatus] savedPo id=${savedPo.id}, status=${savedPo.status}") | ||||
| // TODO: For test only - normally check savedPo.status == PurchaseOrderStatus.COMPLETED and use only COMPLETE lines | // TODO: For test only - normally check savedPo.status == PurchaseOrderStatus.COMPLETED and use only COMPLETE lines | ||||
| try { | try { | ||||
| val allLines = stockInLineRepository.findAllByPurchaseOrderIdAndDeletedFalseWithItemNames(savedPo.id!!) | |||||
| val linesForGrn = allLines // TODO test: use all lines; normally .filter { it.status == StockInLineStatus.COMPLETE.status } | |||||
| // Defensive: load only completed stock-in lines for the PO, so GRN payload can't include pending/escalated. | |||||
| val linesForGrn = stockInLineRepository.findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(savedPo.id!!) | |||||
| if (linesForGrn.isEmpty()) { | if (linesForGrn.isEmpty()) { | ||||
| logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}") | logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}") | ||||
| return | return | ||||