diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 64b1ab0..a2e8f6a 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -123,6 +123,12 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->post('packaging-units/delete/(:num)', 'Admin\PackagingUnit::delete/$1');
$routes->get('packaging-units/history/(:num)', 'Admin\PackagingUnit::history/$1');
+ // 현황/리포트 (Phase 5)
+ $routes->get('reports/sales-ledger', 'Admin\SalesReport::salesLedger');
+ $routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary');
+ $routes->get('reports/period-sales', 'Admin\SalesReport::periodSales');
+ $routes->get('reports/supply-demand', 'Admin\SalesReport::supplyDemand');
+
// 판매 대행소 관리 (P2-07/08)
$routes->get('sales-agencies', 'Admin\SalesAgency::index');
$routes->get('sales-agencies/create', 'Admin\SalesAgency::create');
diff --git a/app/Controllers/Admin/SalesReport.php b/app/Controllers/Admin/SalesReport.php
new file mode 100644
index 0000000..77a1389
--- /dev/null
+++ b/app/Controllers/Admin/SalesReport.php
@@ -0,0 +1,180 @@
+to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
+
+ $startDate = $this->request->getGet('start_date') ?? date('Y-m-01');
+ $endDate = $this->request->getGet('end_date') ?? date('Y-m-d');
+ $mode = $this->request->getGet('mode') ?? 'daily'; // daily or period
+
+ $saleModel = model(BagSaleModel::class);
+ $db = \Config\Database::connect();
+
+ if ($mode === 'daily') {
+ $result = $db->query("
+ SELECT bs_sale_date, bs_ds_name, bs_bag_code, bs_bag_name, bs_type,
+ SUM(ABS(bs_qty)) as total_qty,
+ SUM(bs_amount) as total_amount
+ FROM bag_sale
+ WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ? AND bs_type IN('sale','return')
+ GROUP BY bs_sale_date, bs_ds_name, bs_bag_code, bs_bag_name, bs_type
+ ORDER BY bs_sale_date DESC, bs_ds_name, bs_bag_code
+ ", [$lgIdx, $startDate, $endDate])->getResult();
+ } else {
+ $result = $db->query("
+ SELECT bs_ds_name, bs_bag_code, bs_bag_name,
+ SUM(CASE WHEN bs_type='sale' THEN ABS(bs_qty) ELSE 0 END) as sale_qty,
+ SUM(CASE WHEN bs_type='sale' THEN bs_amount ELSE 0 END) as sale_amount,
+ SUM(CASE WHEN bs_type='return' THEN ABS(bs_qty) ELSE 0 END) as return_qty,
+ SUM(CASE WHEN bs_type='return' THEN bs_amount ELSE 0 END) as return_amount
+ FROM bag_sale
+ WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ?
+ GROUP BY bs_ds_name, bs_bag_code, bs_bag_name
+ ORDER BY bs_ds_name, bs_bag_code
+ ", [$lgIdx, $startDate, $endDate])->getResult();
+ }
+
+ return view('admin/layout', [
+ 'title' => '판매 대장',
+ 'content' => view('admin/sales_report/sales_ledger', compact('result', 'startDate', 'endDate', 'mode')),
+ ]);
+ }
+
+ /**
+ * P5-02: 일계표
+ */
+ public function dailySummary()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
+
+ $date = $this->request->getGet('date') ?? date('Y-m-d');
+ $db = \Config\Database::connect();
+
+ // 당일 판매
+ $daily = $db->query("
+ SELECT bs_bag_code, bs_bag_name,
+ SUM(CASE WHEN bs_type='sale' THEN ABS(bs_qty) ELSE 0 END) as sale_qty,
+ SUM(CASE WHEN bs_type='sale' THEN bs_amount ELSE 0 END) as sale_amount
+ FROM bag_sale
+ WHERE bs_lg_idx = ? AND bs_sale_date = ?
+ GROUP BY bs_bag_code, bs_bag_name
+ ORDER BY bs_bag_code
+ ", [$lgIdx, $date])->getResult();
+
+ // 당월 누계
+ $monthStart = date('Y-m-01', strtotime($date));
+ $monthly = $db->query("
+ SELECT bs_bag_code, bs_bag_name,
+ SUM(CASE WHEN bs_type='sale' THEN ABS(bs_qty) ELSE 0 END) as sale_qty,
+ SUM(CASE WHEN bs_type='sale' THEN bs_amount ELSE 0 END) as sale_amount
+ FROM bag_sale
+ WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ?
+ GROUP BY bs_bag_code, bs_bag_name
+ ORDER BY bs_bag_code
+ ", [$lgIdx, $monthStart, $date])->getResult();
+
+ return view('admin/layout', [
+ 'title' => '일계표',
+ 'content' => view('admin/sales_report/daily_summary', compact('daily', 'monthly', 'date')),
+ ]);
+ }
+
+ /**
+ * P5-03: 기간별 판매현황
+ */
+ public function periodSales()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
+
+ $startDate = $this->request->getGet('start_date') ?? date('Y-m-01');
+ $endDate = $this->request->getGet('end_date') ?? date('Y-m-d');
+ $db = \Config\Database::connect();
+
+ $result = $db->query("
+ SELECT bs_bag_code, bs_bag_name,
+ SUM(CASE WHEN bs_type='sale' THEN ABS(bs_qty) ELSE 0 END) as sale_qty,
+ SUM(CASE WHEN bs_type='sale' THEN bs_amount ELSE 0 END) as sale_amount,
+ SUM(CASE WHEN bs_type='return' THEN ABS(bs_qty) ELSE 0 END) as return_qty,
+ SUM(CASE WHEN bs_type='return' THEN bs_amount ELSE 0 END) as return_amount
+ FROM bag_sale
+ WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ?
+ GROUP BY bs_bag_code, bs_bag_name
+ ORDER BY bs_bag_code
+ ", [$lgIdx, $startDate, $endDate])->getResult();
+
+ return view('admin/layout', [
+ 'title' => '기간별 판매현황',
+ 'content' => view('admin/sales_report/period_sales', compact('result', 'startDate', 'endDate')),
+ ]);
+ }
+
+ /**
+ * P5-07: 봉투 수불 현황
+ */
+ public function supplyDemand()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
+
+ $startDate = $this->request->getGet('start_date') ?? date('Y-m-01');
+ $endDate = $this->request->getGet('end_date') ?? date('Y-m-d');
+ $db = \Config\Database::connect();
+
+ // 입고 합계
+ $receiving = $db->query("
+ SELECT br_bag_code, br_bag_name,
+ SUM(br_qty_sheet) as recv_qty
+ FROM bag_receiving
+ WHERE br_lg_idx = ? AND br_receive_date BETWEEN ? AND ?
+ GROUP BY br_bag_code, br_bag_name
+ ", [$lgIdx, $startDate, $endDate])->getResult();
+
+ // 판매 합계
+ $sales = $db->query("
+ SELECT bs_bag_code, bs_bag_name,
+ SUM(CASE WHEN bs_type='sale' THEN ABS(bs_qty) ELSE 0 END) as sale_qty,
+ SUM(CASE WHEN bs_type='return' THEN ABS(bs_qty) ELSE 0 END) as return_qty
+ FROM bag_sale
+ WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ?
+ GROUP BY bs_bag_code, bs_bag_name
+ ", [$lgIdx, $startDate, $endDate])->getResult();
+
+ // 불출 합계
+ $issues = $db->query("
+ SELECT bi2_bag_code, bi2_bag_name,
+ SUM(bi2_qty) as issue_qty
+ FROM bag_issue
+ WHERE bi2_lg_idx = ? AND bi2_issue_date BETWEEN ? AND ? AND bi2_status = 'normal'
+ GROUP BY bi2_bag_code, bi2_bag_name
+ ", [$lgIdx, $startDate, $endDate])->getResult();
+
+ // 현재 재고
+ $inventory = model(BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->findAll();
+
+ return view('admin/layout', [
+ 'title' => '봉투 수불 현황',
+ 'content' => view('admin/sales_report/supply_demand', compact('receiving', 'sales', 'issues', 'inventory', 'startDate', 'endDate')),
+ ]);
+ }
+}
diff --git a/app/Views/admin/sales_report/daily_summary.php b/app/Views/admin/sales_report/daily_summary.php
new file mode 100644
index 0000000..0539b58
--- /dev/null
+++ b/app/Views/admin/sales_report/daily_summary.php
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+ 당일 (= esc($date ?? '') ?>)
+
+
+
+
+ | 봉투코드 |
+ 봉투명 |
+ 판매수량 |
+ 판매금액 |
+
+
+
+
+
+ sale_qty;
+ $dailySaleAmountTotal += (int) $row->sale_amount;
+ ?>
+
+ | = esc($row->bs_bag_code) ?> |
+ = esc($row->bs_bag_name) ?> |
+ = number_format((int) $row->sale_qty) ?> |
+ = number_format((int) $row->sale_amount) ?> |
+
+
+
+ | 조회된 데이터가 없습니다. |
+
+
+
+
+ | 합계 |
+ = number_format($dailySaleQtyTotal) ?> |
+ = number_format($dailySaleAmountTotal) ?> |
+
+
+
+
+
+
+
+
+ 당월 누계 (= esc($monthStart ?? '') ?> ~ = esc($date ?? '') ?>)
+
+
+
+
+ | 봉투코드 |
+ 봉투명 |
+ 판매수량 |
+ 판매금액 |
+
+
+
+
+
+ sale_qty;
+ $monthlySaleAmountTotal += (int) $row->sale_amount;
+ ?>
+
+ | = esc($row->bs_bag_code) ?> |
+ = esc($row->bs_bag_name) ?> |
+ = number_format((int) $row->sale_qty) ?> |
+ = number_format((int) $row->sale_amount) ?> |
+
+
+
+ | 조회된 데이터가 없습니다. |
+
+
+
+
+ | 합계 |
+ = number_format($monthlySaleQtyTotal) ?> |
+ = number_format($monthlySaleAmountTotal) ?> |
+
+
+
+
+
diff --git a/app/Views/admin/sales_report/period_sales.php b/app/Views/admin/sales_report/period_sales.php
new file mode 100644
index 0000000..ebe307b
--- /dev/null
+++ b/app/Views/admin/sales_report/period_sales.php
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+ | 봉투코드 |
+ 봉투명 |
+ 판매수량 |
+ 판매금액 |
+ 반품수량 |
+ 반품금액 |
+ 합계수량 |
+ 합계금액 |
+
+
+
+
+
+ sale_qty;
+ $grandSaleAmount += (int) $row->sale_amount;
+ $grandReturnQty += (int) $row->return_qty;
+ $grandReturnAmount += (int) $row->return_amount;
+ ?>
+
+ | = esc($row->bs_bag_code) ?> |
+ = esc($row->bs_bag_name) ?> |
+ = number_format((int) $row->sale_qty) ?> |
+ = number_format((int) $row->sale_amount) ?> |
+ = number_format((int) $row->return_qty) ?> |
+ = number_format((int) $row->return_amount) ?> |
+ = number_format((int) $row->sale_qty - (int) $row->return_qty) ?> |
+ = number_format((int) $row->sale_amount - (int) $row->return_amount) ?> |
+
+
+
+ | 조회된 데이터가 없습니다. |
+
+
+
+
+ | 합계 |
+ = number_format($grandSaleQty) ?> |
+ = number_format($grandSaleAmount) ?> |
+ = number_format($grandReturnQty) ?> |
+ = number_format($grandReturnAmount) ?> |
+ = number_format($grandSaleQty - $grandReturnQty) ?> |
+ = number_format($grandSaleAmount - $grandReturnAmount) ?> |
+
+
+
+
diff --git a/app/Views/admin/sales_report/sales_ledger.php b/app/Views/admin/sales_report/sales_ledger.php
new file mode 100644
index 0000000..38d6ba1
--- /dev/null
+++ b/app/Views/admin/sales_report/sales_ledger.php
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+ | 판매일 |
+ 판매소 |
+ 봉투코드 |
+ 봉투명 |
+ 구분 |
+ 수량 |
+ 금액 |
+
+
+
+
+
+ | = esc($row->bs_sale_date) ?> |
+ = esc($row->bs_ds_name) ?> |
+ = esc($row->bs_bag_code) ?> |
+ = esc($row->bs_bag_name) ?> |
+
+ '판매', 'return' => '반품'];
+ echo esc($typeMap[$row->bs_type] ?? $row->bs_type);
+ ?>
+ |
+ = number_format((int) $row->total_qty) ?> |
+ = number_format((int) $row->total_amount) ?> |
+
+
+
+ | 조회된 데이터가 없습니다. |
+
+
+
+
+
+
+
+
+
+
+ | 판매소 |
+ 봉투코드 |
+ 봉투명 |
+ 판매수량 |
+ 판매금액 |
+ 반품수량 |
+ 반품금액 |
+ 계(수량) |
+ 계(금액) |
+
+
+
+
+
+ | = esc($row->bs_ds_name) ?> |
+ = esc($row->bs_bag_code) ?> |
+ = esc($row->bs_bag_name) ?> |
+ = number_format((int) $row->sale_qty) ?> |
+ = number_format((int) $row->sale_amount) ?> |
+ = number_format((int) $row->return_qty) ?> |
+ = number_format((int) $row->return_amount) ?> |
+ = number_format((int) $row->sale_qty - (int) $row->return_qty) ?> |
+ = number_format((int) $row->sale_amount - (int) $row->return_amount) ?> |
+
+
+
+ | 조회된 데이터가 없습니다. |
+
+
+
+
+
diff --git a/app/Views/admin/sales_report/supply_demand.php b/app/Views/admin/sales_report/supply_demand.php
new file mode 100644
index 0000000..f79e526
--- /dev/null
+++ b/app/Views/admin/sales_report/supply_demand.php
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+ 현재 재고
+
+
+
+
+ | 봉투코드 |
+ 봉투명 |
+ 재고수량 |
+
+
+
+
+
+ | = esc($row->bi_bag_code) ?> |
+ = esc($row->bi_bag_name) ?> |
+ = number_format((int) $row->bi_qty) ?> |
+
+
+
+ | 데이터가 없습니다. |
+
+
+
+
+
+
+
+
+ 기간 입고
+
+
+
+
+ | 봉투코드 |
+ 봉투명 |
+ 입고수량 |
+
+
+
+
+
+ | = esc($row->br_bag_code) ?> |
+ = esc($row->br_bag_name) ?> |
+ = number_format((int) $row->recv_qty) ?> |
+
+
+
+ | 데이터가 없습니다. |
+
+
+
+
+
+
+
+
+ 기간 판매
+
+
+
+
+ | 봉투코드 |
+ 봉투명 |
+ 판매수량 |
+ 반품수량 |
+ 순판매 |
+
+
+
+
+
+ | = esc($row->bs_bag_code) ?> |
+ = esc($row->bs_bag_name) ?> |
+ = number_format((int) $row->sale_qty) ?> |
+ = number_format((int) $row->return_qty) ?> |
+ = number_format((int) $row->sale_qty - (int) $row->return_qty) ?> |
+
+
+
+ | 데이터가 없습니다. |
+
+
+
+
+
+
+
+
+ 기간 불출
+
+
+
+
+ | 봉투코드 |
+ 봉투명 |
+ 불출수량 |
+
+
+
+
+
+ | = esc($row->bi2_bag_code) ?> |
+ = esc($row->bi2_bag_name) ?> |
+ = number_format((int) $row->issue_qty) ?> |
+
+
+
+ | 데이터가 없습니다. |
+
+
+
+
+
diff --git a/e2e/phase5-reports.spec.js b/e2e/phase5-reports.spec.js
new file mode 100644
index 0000000..80d848d
--- /dev/null
+++ b/e2e/phase5-reports.spec.js
@@ -0,0 +1,57 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+const { login } = require('./helpers/auth');
+
+async function loginAsAdmin(page) {
+ await login(page, 'admin');
+ await page.locator('input[name="lg_idx"]').first().check();
+ await page.click('button[type="submit"]');
+ await page.waitForURL(url => !url.pathname.includes('select-local-government'), { timeout: 15000 });
+}
+
+test.describe('P5: 판매 대장', () => {
+ test('일자별 판매 대장 접근', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/reports/sales-ledger');
+ await expect(page).toHaveURL(/\/admin\/reports\/sales-ledger/);
+ });
+ test('기간별 판매 대장', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/reports/sales-ledger?mode=period&start_date=2026-01-01&end_date=2026-12-31');
+ await expect(page).toHaveURL(/mode=period/);
+ });
+});
+
+test.describe('P5: 일계표', () => {
+ test('일계표 접근', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/reports/daily-summary');
+ await expect(page).toHaveURL(/\/admin\/reports\/daily-summary/);
+ });
+});
+
+test.describe('P5: 기간별 판매현황', () => {
+ test('기간별 판매현황 접근', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/reports/period-sales');
+ await expect(page).toHaveURL(/\/admin\/reports\/period-sales/);
+ });
+});
+
+test.describe('P5: 봉투 수불 현황', () => {
+ test('수불 현황 접근', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/reports/supply-demand');
+ await expect(page).toHaveURL(/\/admin\/reports\/supply-demand/);
+ });
+});
+
+test.describe('P5: 지자체관리자 접근', () => {
+ test('리포트 접근 가능', async ({ page }) => {
+ await login(page, 'local');
+ await page.goto('/admin/reports/sales-ledger');
+ await expect(page).toHaveURL(/\/admin\/reports\/sales-ledger/);
+ await page.goto('/admin/reports/daily-summary');
+ await expect(page).toHaveURL(/\/admin\/reports\/daily-summary/);
+ });
+});