diff --git a/.cursor/rules/frontend-location.mdc b/.cursor/rules/frontend-location.mdc new file mode 100644 index 0000000..f7f2611 --- /dev/null +++ b/.cursor/rules/frontend-location.mdc @@ -0,0 +1,20 @@ +--- +description: Frontend repo location and cross-repo workflow +alwaysApply: true +--- + +# Frontend Location + +- Backend repository: `FPSMS-backend` +- Frontend repository: `../FPSMS-frontend` + +# When a request involves UI behavior + +- Check whether the change belongs to backend, frontend, or both. +- If chart/page click behavior is requested, update frontend handlers in `../FPSMS-frontend`. +- Keep API contracts consistent between backend endpoints and frontend client calls. + +# Cross-repo coordination + +- When adding backend endpoints used by frontend, also add/update frontend API client functions. +- Validate impacted repo(s): backend compile/tests and frontend type-check/lint as needed. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e675c03 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +# FPSMS Agent Notes + +## Repository Relationship + +- Current backend repo: `FPSMS-backend` +- Paired frontend repo location: `../FPSMS-frontend` + +## Working Convention + +- If a task is about UI behavior (charts, clicks, page rendering, dialogs), check `../FPSMS-frontend`. +- If a task is about API/business logic/data query, check this backend repo. +- For end-to-end changes, update both repos and keep API request/response fields aligned. diff --git a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt index fe11dd4..cf5bc35 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt @@ -69,26 +69,439 @@ open class ChartService( /** * Purchase orders: count by status (pending, receiving, completed). * targetDate: when set, only POs whose orderDate is on that date; when null, all POs. + * Optional multi-filters: supplierIds, itemCodes (line itemNo), purchaseOrderNos (po.code). */ - fun getPurchaseOrderByStatus(targetDate: LocalDate?): List> { + fun getPurchaseOrderByStatus( + targetDate: LocalDate?, + supplierIds: List?, + itemCodes: List?, + purchaseOrderNos: List?, + ): List> { val args = mutableMapOf() val dateSql = if (targetDate != null) { args["targetDate"] = targetDate.toString() "AND DATE(po.orderDate) = :targetDate" } else "" + val multiSql = buildPoMultiFiltersSql(supplierIds, itemCodes, purchaseOrderNos, null, args) val sql = """ SELECT COALESCE(po.status, 'unknown') AS status, COUNT(po.id) AS count FROM purchase_order po WHERE po.deleted = 0 - $dateSql + $dateSql $multiSql GROUP BY po.status ORDER BY count DESC """.trimIndent() return jdbcDao.queryForList(sql, args) } + /** + * Filter options for purchase chart (distinct suppliers / items / PO numbers on orderDate). + */ + fun getPurchaseOrderFilterOptions(targetDate: LocalDate?): Map { + if (targetDate == null) { + return mapOf( + "suppliers" to emptyList(), + "items" to emptyList(), + "poNos" to emptyList(), + ) + } + val args = mutableMapOf("targetDate" to targetDate.toString()) + val suppliersSql = """ + SELECT DISTINCT po.supplierId AS supplierId, s.code AS code, COALESCE(s.name, '') AS name + FROM purchase_order po + LEFT JOIN shop s ON s.id = po.supplierId AND s.deleted = 0 + WHERE po.deleted = 0 AND DATE(po.orderDate) = :targetDate AND po.supplierId IS NOT NULL + ORDER BY s.code + """.trimIndent() + val itemsSql = """ + SELECT DISTINCT pol.itemNo AS itemCode, COALESCE(it.name, '') AS itemName + FROM purchase_order_line pol + INNER JOIN purchase_order po ON po.id = pol.purchaseOrderId AND po.deleted = 0 + LEFT JOIN items it ON it.id = pol.itemId AND it.deleted = 0 + WHERE pol.deleted = 0 AND DATE(po.orderDate) = :targetDate + ORDER BY pol.itemNo + """.trimIndent() + val poNosSql = """ + SELECT DISTINCT po.code AS poNo + FROM purchase_order po + WHERE po.deleted = 0 AND DATE(po.orderDate) = :targetDate + ORDER BY po.code + """.trimIndent() + return mapOf( + "suppliers" to jdbcDao.queryForList(suppliersSql, args), + "items" to jdbcDao.queryForList(itemsSql, args), + "poNos" to jdbcDao.queryForList(poNosSql, args), + ) + } + + /** + * 預計送貨: POs with estimatedArrivalDate on targetDate, grouped by 已送 / 未送 / 已取消. + * 已取消: deleted = 1; 已送: deleted = 0 and status = completed; 未送: deleted = 0 and pending/receiving. + * other: remaining active statuses (if any). + */ + fun getPurchaseOrderEstimatedArrivalSummary( + targetDate: LocalDate?, + supplierIds: List?, + itemCodes: List?, + purchaseOrderNos: List?, + ): List> { + if (targetDate == null) return emptyList() + val args = mutableMapOf("targetDate" to targetDate.toString()) + val multiSql = buildPoMultiFiltersSql(supplierIds, itemCodes, purchaseOrderNos, null, args) + val sql = """ + SELECT + CASE + WHEN COALESCE(po.deleted, 0) = 1 THEN 'cancelled' + WHEN COALESCE(po.deleted, 0) = 0 AND LOWER(COALESCE(po.status, '')) = 'completed' THEN 'delivered' + WHEN COALESCE(po.deleted, 0) = 0 AND LOWER(COALESCE(po.status, '')) IN ('pending', 'receiving') THEN 'not_delivered' + ELSE 'other' + END AS bucket, + COUNT(DISTINCT po.id) AS count + FROM purchase_order po + WHERE DATE(po.estimatedArrivalDate) = :targetDate + AND po.estimatedArrivalDate IS NOT NULL + $multiSql + GROUP BY bucket + ORDER BY count DESC + """.trimIndent() + return jdbcDao.queryForList(sql, args) + } + + /** + * 預計送貨: for a single bucket (delivered / not_delivered / cancelled / other) on [targetDate] + * (DATE(estimatedArrivalDate)), return distinct suppliers, items, and PO headers matching the same rules as + * [getPurchaseOrderEstimatedArrivalSummary] plus [buildPoMultiFiltersSql] bar filters. + */ + fun getPurchaseOrderEstimatedArrivalBreakdown( + targetDate: LocalDate?, + estimatedArrivalBucket: String?, + supplierIds: List?, + itemCodes: List?, + purchaseOrderNos: List?, + ): Map { + if (targetDate == null || estimatedArrivalBucket.isNullOrBlank()) { + return mapOf( + "suppliers" to emptyList(), + "items" to emptyList(), + "purchaseOrders" to emptyList(), + ) + } + val args = mutableMapOf() + val bucketSql = buildPoDeletedAndEstimatedArrivalBucketSql(estimatedArrivalBucket, targetDate, args) + val multiSql = buildPoMultiFiltersSql(supplierIds, itemCodes, purchaseOrderNos, null, args) + val suppliersSql = """ + SELECT + po.supplierId AS supplierId, + s.code AS supplierCode, + COALESCE(s.name, '') AS supplierName, + COUNT(DISTINCT po.id) AS poCount + FROM purchase_order po + LEFT JOIN shop s ON s.id = po.supplierId AND s.deleted = 0 + WHERE $bucketSql + $multiSql + GROUP BY po.supplierId, s.code, s.name + ORDER BY poCount DESC, supplierCode + """.trimIndent() + val itemsSql = """ + SELECT + pol.itemNo AS itemCode, + COALESCE(it.name, '') AS itemName, + COUNT(DISTINCT po.id) AS poCount, + COALESCE(SUM(pol.qty), 0) AS totalQty + FROM purchase_order po + INNER JOIN purchase_order_line pol ON pol.purchaseOrderId = po.id AND pol.deleted = 0 + LEFT JOIN items it ON it.id = pol.itemId AND it.deleted = 0 + WHERE $bucketSql + $multiSql + GROUP BY pol.itemNo, it.name + ORDER BY totalQty DESC, pol.itemNo + """.trimIndent() + val posSql = """ + SELECT + po.id AS purchaseOrderId, + po.code AS purchaseOrderNo, + COALESCE(po.status, '') AS status, + DATE_FORMAT(po.orderDate, '%Y-%m-%d') AS orderDate, + po.supplierId AS supplierId, + s.code AS supplierCode, + COALESCE(s.name, '') AS supplierName + FROM purchase_order po + LEFT JOIN shop s ON s.id = po.supplierId AND s.deleted = 0 + WHERE $bucketSql + $multiSql + ORDER BY po.code + """.trimIndent() + return mapOf( + "suppliers" to jdbcDao.queryForList(suppliersSql, args), + "items" to jdbcDao.queryForList(itemsSql, args), + "purchaseOrders" to jdbcDao.queryForList(posSql, args), + ) + } + + private fun buildPoMultiFiltersSql( + supplierIds: List?, + itemCodes: List?, + purchaseOrderNos: List?, + supplierCode: String?, + args: MutableMap, + ): String { + val sb = StringBuilder() + val sids = supplierIds?.mapNotNull { it }?.filter { it > 0 }?.distinct() ?: emptyList() + val scode = supplierCode?.trim()?.takeIf { it.isNotEmpty() } + if (sids.isNotEmpty()) { + args["filterSupplierIds"] = sids + sb.append(" AND po.supplierId IN (:filterSupplierIds)") + } else if (scode != null) { + args["filterSupplierCode"] = scode + sb.append( + " AND EXISTS (SELECT 1 FROM shop sfc WHERE sfc.id = po.supplierId AND sfc.deleted = 0 AND sfc.code = :filterSupplierCode)" + ) + } + val icodes = itemCodes?.map { it.trim() }?.filter { it.isNotEmpty() }?.distinct() ?: emptyList() + if (icodes.isNotEmpty()) { + args["filterItemCodes"] = icodes + sb.append( + """ AND EXISTS ( + SELECT 1 FROM purchase_order_line polf + WHERE polf.purchaseOrderId = po.id AND polf.deleted = 0 AND polf.itemNo IN (:filterItemCodes) + )""" + ) + } + val poNos = purchaseOrderNos?.map { it.trim() }?.filter { it.isNotEmpty() }?.distinct() ?: emptyList() + if (poNos.isNotEmpty()) { + args["filterPoNos"] = poNos + sb.append(" AND po.code IN (:filterPoNos)") + } + return sb.toString() + } + + /** + * Purchase orders detail rows by status for chart drill-down. + * Example use: click "pending" segment -> show matching PO headers. + * targetDate: optional filter — by order date or complete date (see dateFilter). + * dateFilter: "order" = DATE(po.orderDate); "complete" = DATE(po.completeDate) (for received/completed on a day). + * supplierIds / itemCodes / purchaseOrderNos: optional multi-filters (same as chart filter bar). + * supplierCode: optional when supplierId list is empty (e.g. drill-down with code only). + */ + fun getPurchaseOrderDetailsByStatus( + status: String, + targetDate: LocalDate?, + dateFilter: String, + supplierIds: List?, + itemCodes: List?, + purchaseOrderNos: List?, + supplierCode: String?, + estimatedArrivalBucket: String?, + ): List> { + val args = mutableMapOf( + "status" to status.trim().lowercase() + ) + val dateSql = buildPurchaseOrderDrillOrderDateSql(estimatedArrivalBucket, targetDate, dateFilter, args) + val multiSql = buildPoMultiFiltersSql(supplierIds, itemCodes, purchaseOrderNos, supplierCode, args) + val deletedAndEaSql = buildPoDeletedAndEstimatedArrivalBucketSql(estimatedArrivalBucket, targetDate, args) + /** When 預計送貨 bucket is active, [buildPoDeletedAndEstimatedArrivalBucketSql] already fixes status shape (e.g. 未送 = pending|receiving). Do not AND with :status (would wrongly require e.g. completed). */ + val statusSingleFilterSql = if (estimatedArrivalBucket.isNullOrBlank() || targetDate == null) { + "AND LOWER(COALESCE(po.status, 'unknown')) = :status" + } else { + "" + } + val sql = """ + SELECT + po.id AS purchaseOrderId, + po.code AS purchaseOrderNo, + COALESCE(po.status, 'unknown') AS status, + DATE_FORMAT(po.orderDate, '%Y-%m-%d') AS orderDate, + DATE_FORMAT(po.estimatedArrivalDate, '%Y-%m-%d') AS estimatedArrivalDate, + po.supplierId AS supplierId, + s.code AS supplierCode, + COALESCE(s.name, '') AS supplierName, + COUNT(DISTINCT pol.id) AS itemCount, + COALESCE(SUM(pol.qty), 0) AS totalQty + FROM purchase_order po + LEFT JOIN purchase_order_line pol ON pol.purchaseOrderId = po.id AND pol.deleted = 0 + LEFT JOIN shop s ON s.id = po.supplierId AND s.deleted = 0 + WHERE $deletedAndEaSql + $statusSingleFilterSql + $dateSql $multiSql + GROUP BY + po.id, po.code, po.status, po.orderDate, po.estimatedArrivalDate, po.supplierId, s.code, s.name + ORDER BY po.orderDate DESC, po.code DESC + """.trimIndent() + return jdbcDao.queryForList(sql, args) + } + + /** + * When [estimatedArrivalBucket] is set (delivered / not_delivered / cancelled / other), filter by + * [getPurchaseOrderEstimatedArrivalSummary] bucket rules on [targetDate] (estimated arrival date). + * Includes soft-deleted rows when bucket is cancelled. + */ + private fun buildPoDeletedAndEstimatedArrivalBucketSql( + estimatedArrivalBucket: String?, + targetDate: LocalDate?, + args: MutableMap, + ): String { + val b = estimatedArrivalBucket?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } + if (b == null || targetDate == null) { + return "po.deleted = 0" + } + args["eaBucket"] = b + args["eaTargetDate"] = targetDate.toString() + return """ + 1=1 + AND po.estimatedArrivalDate IS NOT NULL + AND DATE(po.estimatedArrivalDate) = :eaTargetDate + AND ( + CASE + WHEN COALESCE(po.deleted, 0) = 1 THEN 'cancelled' + WHEN COALESCE(po.deleted, 0) = 0 AND LOWER(COALESCE(po.status, '')) = 'completed' THEN 'delivered' + WHEN COALESCE(po.deleted, 0) = 0 AND LOWER(COALESCE(po.status, '')) IN ('pending', 'receiving') THEN 'not_delivered' + ELSE 'other' + END + ) = :eaBucket + """.trimIndent() + } + + private fun buildPurchaseOrderTargetDateSql( + targetDate: LocalDate?, + dateFilter: String, + args: MutableMap, + ): String { + if (targetDate == null) return "" + args["targetDate"] = targetDate.toString() + return if (dateFilter.equals("complete", ignoreCase = true)) { + "AND po.completeDate IS NOT NULL AND DATE(po.completeDate) = :targetDate" + } else { + "AND DATE(po.orderDate) = :targetDate" + } + } + + /** + * Drill-down rows for 預計送貨 must match [getPurchaseOrderEstimatedArrivalSummary] (estimated arrival date only). + * If we also applied [buildPurchaseOrderTargetDateSql], many POs (orderDate ≠ 預計到貨日) would drop out and + * supplier / item charts would collapse incorrectly. + * + * For 實際已送貨 (no bucket), [getPurchaseOrderByStatus] is always filtered by **order date** only. If drill used + * [dateFilter] = complete, rows would be restricted to completeDate = targetDate and supplier/item charts would + * collapse to a tiny set (often one supplier) vs the donut counts. + */ + private fun buildPurchaseOrderDrillOrderDateSql( + estimatedArrivalBucket: String?, + targetDate: LocalDate?, + @Suppress("UNUSED_PARAMETER") dateFilter: String, + args: MutableMap, + ): String { + if (!estimatedArrivalBucket.isNullOrBlank() && targetDate != null) { + return "" + } + return buildPurchaseOrderTargetDateSql(targetDate, "order", args) + } + + /** + * Purchase order item lines for drill-down. + * Includes ordered qty, received qty and pending qty. + */ + fun getPurchaseOrderItems(purchaseOrderId: Long): List> { + val args = mutableMapOf("purchaseOrderId" to purchaseOrderId) + val sql = """ + SELECT + pol.id AS purchaseOrderLineId, + pol.itemNo AS itemCode, + COALESCE(it.name, '') AS itemName, + COALESCE(pol.qty, 0) AS orderedQty, + COALESCE(u.udfudesc, u.code, u.udfShortDesc, '') AS uom, + COALESCE(SUM( + CASE + WHEN sil.id IS NOT NULL THEN COALESCE(sil.acceptedQty, 0) + ELSE 0 + END + ), 0) AS receivedQty, + GREATEST( + COALESCE(pol.qty, 0) - COALESCE(SUM( + CASE + WHEN sil.id IS NOT NULL THEN COALESCE(sil.acceptedQty, 0) + ELSE 0 + END + ), 0), + 0 + ) AS pendingQty + FROM purchase_order_line pol + INNER JOIN purchase_order po ON po.id = pol.purchaseOrderId AND po.deleted = 0 + LEFT JOIN items it ON it.id = pol.itemId AND it.deleted = 0 + LEFT JOIN uom_conversion u ON u.id = pol.uomId + LEFT JOIN stock_in_line sil ON sil.purchaseOrderLineId = pol.id AND sil.deleted = 0 + WHERE pol.deleted = 0 + AND pol.purchaseOrderId = :purchaseOrderId + GROUP BY pol.id, pol.itemNo, it.name, pol.qty, u.udfudesc, u.code, u.udfShortDesc + ORDER BY pol.id + """.trimIndent() + return jdbcDao.queryForList(sql, args) + } + + /** + * Purchase order items grouped by item for a status (and optional targetDate). + * Used for default drill-down chart when no PO is selected. + * supplierIds / itemCodes / purchaseOrderNos: optional multi-filters. + * supplierCode: optional when supplierId list is empty (e.g. drill-down with code only). + */ + fun getPurchaseOrderItemsByStatus( + status: String, + targetDate: LocalDate?, + dateFilter: String, + supplierIds: List?, + itemCodes: List?, + purchaseOrderNos: List?, + supplierCode: String?, + estimatedArrivalBucket: String?, + ): List> { + val args = mutableMapOf( + "status" to status.trim().lowercase() + ) + val dateSql = buildPurchaseOrderDrillOrderDateSql(estimatedArrivalBucket, targetDate, dateFilter, args) + val multiSql = buildPoMultiFiltersSql(supplierIds, itemCodes, purchaseOrderNos, supplierCode, args) + val deletedAndEaSql = buildPoDeletedAndEstimatedArrivalBucketSql(estimatedArrivalBucket, targetDate, args) + val statusSingleFilterSql = if (estimatedArrivalBucket.isNullOrBlank() || targetDate == null) { + "AND LOWER(COALESCE(po.status, 'unknown')) = :status" + } else { + "" + } + val sql = """ + SELECT + pol.itemNo AS itemCode, + COALESCE(it.name, '') AS itemName, + COALESCE(u.udfudesc, u.code, u.udfShortDesc, '') AS uom, + COALESCE(SUM(pol.qty), 0) AS orderedQty, + COALESCE(SUM( + CASE + WHEN sil.id IS NOT NULL THEN COALESCE(sil.acceptedQty, 0) + ELSE 0 + END + ), 0) AS receivedQty, + GREATEST( + COALESCE(SUM(pol.qty), 0) - COALESCE(SUM( + CASE + WHEN sil.id IS NOT NULL THEN COALESCE(sil.acceptedQty, 0) + ELSE 0 + END + ), 0), + 0 + ) AS pendingQty + FROM purchase_order po + INNER JOIN purchase_order_line pol ON pol.purchaseOrderId = po.id AND pol.deleted = 0 + LEFT JOIN shop s ON s.id = po.supplierId AND s.deleted = 0 + LEFT JOIN items it ON it.id = pol.itemId AND it.deleted = 0 + LEFT JOIN uom_conversion u ON u.id = pol.uomId + LEFT JOIN stock_in_line sil ON sil.purchaseOrderLineId = pol.id AND sil.deleted = 0 + WHERE $deletedAndEaSql + $statusSingleFilterSql + $dateSql $multiSql + GROUP BY pol.itemNo, it.name, u.udfudesc, u.code, u.udfShortDesc + ORDER BY orderedQty DESC, pol.itemNo + """.trimIndent() + return jdbcDao.queryForList(sql, args) + } + /** * Stock in vs stock out by date. * Stock in: stock_in_line.acceptedQty, date from stock_in.completeDate or receiptDate/created. diff --git a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt index 0a7c4d5..afdea12 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt @@ -35,14 +35,97 @@ class ChartController( ): List> = chartService.getDeliveryOrderByDate(startDate, endDate) /** - * GET /chart/purchase-order-by-status?targetDate=2025-03-15 + * GET /chart/purchase-order-by-status?targetDate=2025-03-15&supplierId=1&supplierId=2&itemCode=A&purchaseOrderNo=PO001 * Returns [{ status, count }]. targetDate defaults to today on frontend; when set, only POs with orderDate on that date. */ @GetMapping("/purchase-order-by-status") fun getPurchaseOrderByStatus( @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) targetDate: LocalDate?, + @RequestParam(required = false) supplierId: List?, + @RequestParam(required = false) itemCode: List?, + @RequestParam(required = false) purchaseOrderNo: List?, + ): List> = + chartService.getPurchaseOrderByStatus(targetDate, supplierId, itemCode, purchaseOrderNo) + + /** + * GET /chart/purchase-order-filter-options?targetDate=2025-03-15 + * Returns { suppliers, items, poNos } for multi-select (POs with orderDate on targetDate, deleted = 0). + */ + @GetMapping("/purchase-order-filter-options") + fun getPurchaseOrderFilterOptions( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) targetDate: LocalDate?, + ): Map = chartService.getPurchaseOrderFilterOptions(targetDate) + + /** + * GET /chart/purchase-order-estimated-arrival-summary?targetDate=2025-03-15 + * 預計送貨: POs with estimatedArrivalDate on targetDate; buckets cancelled / delivered / not_delivered / other. + */ + @GetMapping("/purchase-order-estimated-arrival-summary") + fun getPurchaseOrderEstimatedArrivalSummary( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) targetDate: LocalDate?, + @RequestParam(required = false) supplierId: List?, + @RequestParam(required = false) itemCode: List?, + @RequestParam(required = false) purchaseOrderNo: List?, + ): List> = + chartService.getPurchaseOrderEstimatedArrivalSummary(targetDate, supplierId, itemCode, purchaseOrderNo) + + /** + * GET /chart/purchase-order-estimated-arrival-breakdown?targetDate=&estimatedArrivalBucket=not_delivered + * Related suppliers / items / POs for that 預計送貨 segment (same filters as summary). + */ + @GetMapping("/purchase-order-estimated-arrival-breakdown") + fun getPurchaseOrderEstimatedArrivalBreakdown( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) targetDate: LocalDate, + @RequestParam estimatedArrivalBucket: String, + @RequestParam(required = false) supplierId: List?, + @RequestParam(required = false) itemCode: List?, + @RequestParam(required = false) purchaseOrderNo: List?, + ): Map = + chartService.getPurchaseOrderEstimatedArrivalBreakdown(targetDate, estimatedArrivalBucket, supplierId, itemCode, purchaseOrderNo) + + /** + * GET /chart/purchase-order-details-by-status?status=pending&targetDate=2025-03-15 + * Returns PO header rows for status drill-down. + */ + @GetMapping("/purchase-order-details-by-status") + fun getPurchaseOrderDetailsByStatus( + @RequestParam status: String, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) targetDate: LocalDate?, + @RequestParam(required = false, defaultValue = "order") dateFilter: String, + @RequestParam(required = false) supplierId: List?, + @RequestParam(required = false) itemCode: List?, + @RequestParam(required = false) purchaseOrderNo: List?, + @RequestParam(required = false) supplierCode: String?, + @RequestParam(required = false) estimatedArrivalBucket: String?, + ): List> = + chartService.getPurchaseOrderDetailsByStatus(status, targetDate, dateFilter, supplierId, itemCode, purchaseOrderNo, supplierCode, estimatedArrivalBucket) + + /** + * GET /chart/purchase-order-items?purchaseOrderId=123 + * Returns PO item line rows with ordered/received/pending qty and uom. + */ + @GetMapping("/purchase-order-items") + fun getPurchaseOrderItems( + @RequestParam purchaseOrderId: Long, + ): List> = + chartService.getPurchaseOrderItems(purchaseOrderId) + + /** + * GET /chart/purchase-order-items-by-status?status=completed&targetDate=2025-03-15 + * Returns item rows aggregated across matching purchase orders. + */ + @GetMapping("/purchase-order-items-by-status") + fun getPurchaseOrderItemsByStatus( + @RequestParam status: String, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) targetDate: LocalDate?, + @RequestParam(required = false, defaultValue = "order") dateFilter: String, + @RequestParam(required = false) supplierId: List?, + @RequestParam(required = false) itemCode: List?, + @RequestParam(required = false) purchaseOrderNo: List?, + @RequestParam(required = false) supplierCode: String?, + @RequestParam(required = false) estimatedArrivalBucket: String?, ): List> = - chartService.getPurchaseOrderByStatus(targetDate) + chartService.getPurchaseOrderItemsByStatus(status, targetDate, dateFilter, supplierId, itemCode, purchaseOrderNo, supplierCode, estimatedArrivalBucket) /** * GET /chart/stock-in-out-by-date?startDate=&endDate=