| @@ -6,6 +6,8 @@ import net.sf.jasperreports.engine.data.JRMapCollectionDataSource | |||
| import java.io.ByteArrayOutputStream | |||
| import java.io.InputStream | |||
| 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.export.SimpleExporterInput | |||
| import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput | |||
| @@ -13,6 +15,7 @@ import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput | |||
| @Service | |||
| open class ReportService( | |||
| private val jdbcDao: JdbcDao, | |||
| private val itemUomService: ItemUomService, | |||
| ) { | |||
| /** | |||
| * 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). | |||
| * 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) | |||
| } | |||
| /** | |||
| * 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> | |||
| @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(""" | |||
| SELECT sil FROM StockInLine sil | |||
| WHERE sil.receiptDate IS NOT NULL | |||
| @@ -520,12 +520,15 @@ open class StockInLineService( | |||
| val antValues = byPol.map { (_, silList) -> | |||
| val sil = silList.first() | |||
| 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 itemId = sil.item?.id ?: pol.item?.id | |||
| val totalQtyInPurchaseUnit = if (itemId != null) { | |||
| 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 itemName = (sil.item?.name ?: pol.item?.name).orEmpty() // always non-null for M18 bDesc/bDesc_en | |||
| GoodsReceiptNoteAntValue( | |||
| @@ -605,8 +608,8 @@ open class StockInLineService( | |||
| 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 | |||
| 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()) { | |||
| logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}") | |||
| return | |||