diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index a82f856..eb04c25 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -163,6 +163,13 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary');
$routes->get('reports/period-sales', 'Admin\SalesReport::periodSales');
$routes->get('reports/supply-demand', 'Admin\SalesReport::supplyDemand');
+ $routes->get('reports/yearly-sales', 'Admin\SalesReport::yearlySales');
+ $routes->get('reports/shop-sales', 'Admin\SalesReport::shopSales');
+ $routes->get('reports/hometax-export', 'Admin\SalesReport::hometaxExport');
+ $routes->get('reports/returns', 'Admin\SalesReport::returns');
+ $routes->get('reports/lot-flow', 'Admin\SalesReport::lotFlow');
+ $routes->get('reports/misc-flow', 'Admin\SalesReport::miscFlow');
+ $routes->post('reports/misc-flow', 'Admin\SalesReport::miscFlowStore');
// 판매 대행소 관리 (P2-07/08)
$routes->get('sales-agencies', 'Admin\SalesAgency::index');
@@ -197,6 +204,8 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->post('free-recipients/delete/(:num)', 'Admin\FreeRecipient::delete/$1');
$routes->get('designated-shops/export', 'Admin\DesignatedShop::export');
+ $routes->get('designated-shops/map', 'Admin\DesignatedShop::map');
+ $routes->get('designated-shops/status', 'Admin\DesignatedShop::status');
$routes->get('designated-shops', 'Admin\DesignatedShop::index');
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store');
diff --git a/app/Controllers/Admin/BagIssue.php b/app/Controllers/Admin/BagIssue.php
index 833ec0f..72b68b5 100644
--- a/app/Controllers/Admin/BagIssue.php
+++ b/app/Controllers/Admin/BagIssue.php
@@ -78,7 +78,7 @@ class BagIssue extends BaseController
$db = \Config\Database::connect();
$db->transStart();
- $this->issueModel->insert([
+ $issueData = [
'bi2_lg_idx' => $lgIdx,
'bi2_year' => (int) $this->request->getPost('bi2_year'),
'bi2_quarter' => (int) $this->request->getPost('bi2_quarter'),
@@ -91,7 +91,13 @@ class BagIssue extends BaseController
'bi2_qty' => $qty,
'bi2_status' => 'normal',
'bi2_regdate' => date('Y-m-d H:i:s'),
- ]);
+ ];
+ $this->issueModel->insert($issueData);
+ $bi2Idx = (int) $this->issueModel->getInsertID();
+
+ // CT-05: 감사 로그
+ helper('audit');
+ audit_log('create', 'bag_issue', $bi2Idx, null, array_merge($issueData, ['bi2_idx' => $bi2Idx]));
// 재고 감산
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, -$qty);
@@ -112,7 +118,11 @@ class BagIssue extends BaseController
$db = \Config\Database::connect();
$db->transStart();
+ $before = (array) $item;
$this->issueModel->update($id, ['bi2_status' => 'cancelled']);
+ // CT-05: 감사 로그
+ helper('audit');
+ audit_log('update', 'bag_issue', $id, $before, ['bi2_status' => 'cancelled']);
// 재고 복원
model(BagInventoryModel::class)->adjustQty((int) $item->bi2_lg_idx, $item->bi2_bag_code, $item->bi2_bag_name, (int) $item->bi2_qty);
diff --git a/app/Controllers/Admin/BagOrder.php b/app/Controllers/Admin/BagOrder.php
index f280ad9..2e557cd 100644
--- a/app/Controllers/Admin/BagOrder.php
+++ b/app/Controllers/Admin/BagOrder.php
@@ -179,6 +179,10 @@ class BagOrder extends BaseController
$this->orderModel->insert($orderData);
$boIdx = (int) $this->orderModel->getInsertID();
+ // CT-05: 감사 로그
+ helper('audit');
+ audit_log('create', 'bag_order', $boIdx, null, array_merge($orderData, ['bo_idx' => $boIdx]));
+
// 품목 저장
$bagCodes = $this->request->getPost('item_bag_code') ?? [];
$qtyBoxes = $this->request->getPost('item_qty_box') ?? [];
@@ -250,7 +254,10 @@ class BagOrder extends BaseController
return redirect()->to(site_url('admin/bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
}
+ $before = (array) $order;
$this->orderModel->update($id, ['bo_status' => 'cancelled', 'bo_moddate' => date('Y-m-d H:i:s')]);
+ helper('audit');
+ audit_log('update', 'bag_order', $id, $before, ['bo_status' => 'cancelled']);
return redirect()->to(site_url('admin/bag-orders'))->with('success', '발주가 취소되었습니다.');
}
@@ -262,7 +269,10 @@ class BagOrder extends BaseController
return redirect()->to(site_url('admin/bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
}
+ $before = (array) $order;
$this->orderModel->update($id, ['bo_status' => 'deleted', 'bo_moddate' => date('Y-m-d H:i:s')]);
+ helper('audit');
+ audit_log('delete', 'bag_order', $id, $before, ['bo_status' => 'deleted']);
return redirect()->to(site_url('admin/bag-orders'))->with('success', '발주가 삭제 처리되었습니다.');
}
}
diff --git a/app/Controllers/Admin/BagSale.php b/app/Controllers/Admin/BagSale.php
index 082b6b5..5aa447c 100644
--- a/app/Controllers/Admin/BagSale.php
+++ b/app/Controllers/Admin/BagSale.php
@@ -131,7 +131,7 @@ class BagSale extends BaseController
$db = \Config\Database::connect();
$db->transStart();
- $this->saleModel->insert([
+ $saleData = [
'bs_lg_idx' => $lgIdx,
'bs_ds_idx' => $dsIdx,
'bs_ds_name' => $shop ? $shop->ds_name : '',
@@ -143,7 +143,13 @@ class BagSale extends BaseController
'bs_amount' => $unitPrice * abs($actualQty),
'bs_type' => $type,
'bs_regdate' => date('Y-m-d H:i:s'),
- ]);
+ ];
+ $this->saleModel->insert($saleData);
+ $bsIdx = (int) $this->saleModel->getInsertID();
+
+ // CT-05: 감사 로그
+ helper('audit');
+ audit_log('create', 'bag_sale', $bsIdx, null, array_merge($saleData, ['bs_idx' => $bsIdx]));
// 재고 감산(판매) / 가산(반품)
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $detail ? $detail->cd_name : '', -$actualQty);
diff --git a/app/Controllers/Admin/Dashboard.php b/app/Controllers/Admin/Dashboard.php
index f24c6f8..a1002bb 100644
--- a/app/Controllers/Admin/Dashboard.php
+++ b/app/Controllers/Admin/Dashboard.php
@@ -1,5 +1,7 @@
0,
+ 'order_amount' => 0,
+ 'sale_count' => 0,
+ 'sale_amount' => 0,
+ 'inventory_count' => 0,
+ 'issue_count_month'=> 0,
+ 'recent_orders' => [],
+ 'recent_sales' => [],
+ ];
+
+ if ($lgIdx) {
+ $db = \Config\Database::connect();
+
+ // 총 발주 건수/금액
+ $orderStats = $db->query("
+ SELECT COUNT(*) as cnt,
+ COALESCE(SUM(sub.total_amt), 0) as total_amount
+ FROM bag_order bo
+ LEFT JOIN (
+ SELECT boi_bo_idx, SUM(boi_amount) as total_amt
+ FROM bag_order_item GROUP BY boi_bo_idx
+ ) sub ON sub.boi_bo_idx = bo.bo_idx
+ WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
+ ", [$lgIdx])->getRow();
+ $stats['order_count'] = (int) ($orderStats->cnt ?? 0);
+ $stats['order_amount'] = (int) ($orderStats->total_amount ?? 0);
+
+ // 총 판매 건수/금액
+ $saleStats = $db->query("
+ SELECT COUNT(*) as cnt, COALESCE(SUM(bs_amount), 0) as total_amount
+ FROM bag_sale
+ WHERE bs_lg_idx = ? AND bs_type = 'sale'
+ ", [$lgIdx])->getRow();
+ $stats['sale_count'] = (int) ($saleStats->cnt ?? 0);
+ $stats['sale_amount'] = (int) ($saleStats->total_amount ?? 0);
+
+ // 현재 재고 품목 수
+ $invCount = $db->query("
+ SELECT COUNT(*) as cnt FROM bag_inventory WHERE bi_lg_idx = ? AND bi_qty > 0
+ ", [$lgIdx])->getRow();
+ $stats['inventory_count'] = (int) ($invCount->cnt ?? 0);
+
+ // 이번 달 불출 건수
+ $monthStart = date('Y-m-01');
+ $issueCount = $db->query("
+ SELECT COUNT(*) as cnt FROM bag_issue
+ WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_issue_date >= ?
+ ", [$lgIdx, $monthStart])->getRow();
+ $stats['issue_count_month'] = (int) ($issueCount->cnt ?? 0);
+
+ // 최근 발주 5건
+ $stats['recent_orders'] = $db->query("
+ SELECT bo_idx, bo_lot_no, bo_order_date, bo_status
+ FROM bag_order
+ WHERE bo_lg_idx = ?
+ ORDER BY bo_order_date DESC, bo_idx DESC
+ LIMIT 5
+ ", [$lgIdx])->getResult();
+
+ // 최근 판매 5건
+ $stats['recent_sales'] = $db->query("
+ SELECT bs_idx, bs_ds_name, bs_bag_name, bs_qty, bs_amount, bs_sale_date, bs_type
+ FROM bag_sale
+ WHERE bs_lg_idx = ?
+ ORDER BY bs_sale_date DESC, bs_idx DESC
+ LIMIT 5
+ ", [$lgIdx])->getResult();
+ }
+
return view('admin/layout', [
'title' => '대시보드',
- 'content' => view('admin/dashboard/index'),
+ 'content' => view('admin/dashboard/index', ['stats' => $stats, 'lgIdx' => $lgIdx]),
]);
}
}
diff --git a/app/Controllers/Admin/DesignatedShop.php b/app/Controllers/Admin/DesignatedShop.php
index d90edc4..32c3649 100644
--- a/app/Controllers/Admin/DesignatedShop.php
+++ b/app/Controllers/Admin/DesignatedShop.php
@@ -43,10 +43,24 @@ class DesignatedShop extends BaseController
->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.');
}
- $list = $this->shopModel
- ->where('ds_lg_idx', $lgIdx)
- ->orderBy('ds_idx', 'DESC')
- ->paginate(20);
+ $builder = $this->shopModel->where('ds_lg_idx', $lgIdx);
+
+ // 다조건 검색 (P2-15)
+ $dsName = $this->request->getGet('ds_name');
+ $dsGugunCode = $this->request->getGet('ds_gugun_code');
+ $dsState = $this->request->getGet('ds_state');
+
+ if ($dsName !== null && $dsName !== '') {
+ $builder->like('ds_name', $dsName);
+ }
+ if ($dsGugunCode !== null && $dsGugunCode !== '') {
+ $builder->where('ds_gugun_code', $dsGugunCode);
+ }
+ if ($dsState !== null && $dsState !== '') {
+ $builder->where('ds_state', (int) $dsState);
+ }
+
+ $list = $builder->orderBy('ds_idx', 'DESC')->paginate(20);
$pager = $this->shopModel->pager;
// 지자체 이름 매핑용
@@ -55,12 +69,20 @@ class DesignatedShop extends BaseController
$lgMap[$lg->lg_idx] = $lg->lg_name;
}
+ // 구군코드 목록 (검색 필터용)
+ $db = \Config\Database::connect();
+ $gugunCodes = $db->query("SELECT DISTINCT ds_gugun_code FROM designated_shop WHERE ds_lg_idx = ? AND ds_gugun_code != '' ORDER BY ds_gugun_code", [$lgIdx])->getResult();
+
return view('admin/layout', [
'title' => '지정판매소 관리',
'content' => view('admin/designated_shop/index', [
- 'list' => $list,
- 'lgMap' => $lgMap,
- 'pager' => $pager,
+ 'list' => $list,
+ 'lgMap' => $lgMap,
+ 'pager' => $pager,
+ 'dsName' => $dsName ?? '',
+ 'dsGugunCode' => $dsGugunCode ?? '',
+ 'dsState' => $dsState ?? '',
+ 'gugunCodes' => $gugunCodes,
]),
]);
}
@@ -317,6 +339,78 @@ class DesignatedShop extends BaseController
->with('success', '지정판매소가 삭제되었습니다.');
}
+ /**
+ * P2-17: 지정판매소 지도 표시
+ */
+ public function map()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if ($lgIdx === null || $lgIdx <= 0) {
+ return redirect()->to(site_url('admin'))
+ ->with('error', '작업할 지자체가 선택되지 않았습니다.');
+ }
+
+ $shops = $this->shopModel
+ ->where('ds_lg_idx', $lgIdx)
+ ->where('ds_state', 1)
+ ->findAll();
+
+ return view('admin/layout', [
+ 'title' => '지정판매소 지도',
+ 'content' => view('admin/designated_shop/map', [
+ 'shops' => $shops,
+ ]),
+ ]);
+ }
+
+ /**
+ * P2-18: 지정판매소 현황 (연도별 신규/취소)
+ */
+ public function status()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if ($lgIdx === null || $lgIdx <= 0) {
+ return redirect()->to(site_url('admin'))
+ ->with('error', '작업할 지자체가 선택되지 않았습니다.');
+ }
+
+ $db = \Config\Database::connect();
+
+ // 연도별 신규등록 건수 (ds_designated_at 기준)
+ $newByYear = $db->query("
+ SELECT YEAR(ds_designated_at) as yr, COUNT(*) as cnt
+ FROM designated_shop
+ WHERE ds_lg_idx = ? AND ds_designated_at IS NOT NULL
+ GROUP BY YEAR(ds_designated_at)
+ ORDER BY yr DESC
+ ", [$lgIdx])->getResult();
+
+ // 연도별 취소/비활성 건수 (ds_state != 1, ds_regdate 기준)
+ $cancelByYear = $db->query("
+ SELECT YEAR(ds_regdate) as yr, COUNT(*) as cnt
+ FROM designated_shop
+ WHERE ds_lg_idx = ? AND ds_state != 1
+ GROUP BY YEAR(ds_regdate)
+ ORDER BY yr DESC
+ ", [$lgIdx])->getResult();
+
+ // 전체 현황 합계
+ $totalActive = $this->shopModel->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->countAllResults(false);
+ $totalInactive = $this->shopModel->where('ds_lg_idx', $lgIdx)->where('ds_state !=', 1)->countAllResults(false);
+
+ return view('admin/layout', [
+ 'title' => '지정판매소 현황',
+ 'content' => view('admin/designated_shop/status', [
+ 'newByYear' => $newByYear,
+ 'cancelByYear' => $cancelByYear,
+ 'totalActive' => $totalActive,
+ 'totalInactive' => $totalInactive,
+ ]),
+ ]);
+ }
+
/**
* 지자체별 다음 판매소번호 생성 (lg_code + 3자리 일련번호)
* 문서: docs/기본 개발계획/22-판매소번호_일련번호_결정.md §3
diff --git a/app/Controllers/Admin/SalesReport.php b/app/Controllers/Admin/SalesReport.php
index 77a1389..c263ee3 100644
--- a/app/Controllers/Admin/SalesReport.php
+++ b/app/Controllers/Admin/SalesReport.php
@@ -128,6 +128,264 @@ class SalesReport extends BaseController
]);
}
+ /**
+ * P5-04: 년 판매 현황 (월별)
+ */
+ public function yearlySales()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
+
+ $year = $this->request->getGet('year') ?? date('Y');
+ $db = \Config\Database::connect();
+
+ $result = $db->query("
+ SELECT bs_bag_code, bs_bag_name,
+ SUM(CASE WHEN MONTH(bs_sale_date)=1 THEN ABS(bs_qty) ELSE 0 END) as m01,
+ SUM(CASE WHEN MONTH(bs_sale_date)=2 THEN ABS(bs_qty) ELSE 0 END) as m02,
+ SUM(CASE WHEN MONTH(bs_sale_date)=3 THEN ABS(bs_qty) ELSE 0 END) as m03,
+ SUM(CASE WHEN MONTH(bs_sale_date)=4 THEN ABS(bs_qty) ELSE 0 END) as m04,
+ SUM(CASE WHEN MONTH(bs_sale_date)=5 THEN ABS(bs_qty) ELSE 0 END) as m05,
+ SUM(CASE WHEN MONTH(bs_sale_date)=6 THEN ABS(bs_qty) ELSE 0 END) as m06,
+ SUM(CASE WHEN MONTH(bs_sale_date)=7 THEN ABS(bs_qty) ELSE 0 END) as m07,
+ SUM(CASE WHEN MONTH(bs_sale_date)=8 THEN ABS(bs_qty) ELSE 0 END) as m08,
+ SUM(CASE WHEN MONTH(bs_sale_date)=9 THEN ABS(bs_qty) ELSE 0 END) as m09,
+ SUM(CASE WHEN MONTH(bs_sale_date)=10 THEN ABS(bs_qty) ELSE 0 END) as m10,
+ SUM(CASE WHEN MONTH(bs_sale_date)=11 THEN ABS(bs_qty) ELSE 0 END) as m11,
+ SUM(CASE WHEN MONTH(bs_sale_date)=12 THEN ABS(bs_qty) ELSE 0 END) as m12,
+ SUM(ABS(bs_qty)) as total
+ FROM bag_sale
+ WHERE bs_lg_idx = ? AND YEAR(bs_sale_date) = ? AND bs_type = 'sale'
+ GROUP BY bs_bag_code, bs_bag_name
+ ORDER BY bs_bag_code
+ ", [$lgIdx, $year])->getResult();
+
+ return view('admin/layout', [
+ 'title' => '년 판매 현황',
+ 'content' => view('admin/sales_report/yearly_sales', compact('result', 'year')),
+ ]);
+ }
+
+ /**
+ * P5-05: 지정판매소별 판매현황
+ */
+ public function shopSales()
+ {
+ 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_ds_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 ABS(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
+ ORDER BY bs_ds_name
+ ", [$lgIdx, $startDate, $endDate])->getResult();
+
+ return view('admin/layout', [
+ 'title' => '지정판매소별 판매현황',
+ 'content' => view('admin/sales_report/shop_sales', compact('result', 'startDate', 'endDate')),
+ ]);
+ }
+
+ /**
+ * P5-06: 홈택스 세금계산서 엑셀 내보내기
+ */
+ public function hometaxExport()
+ {
+ helper(['admin', 'export']);
+ $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();
+ $rows = $db->query("
+ SELECT bs.bs_sale_date, ds.ds_biz_no as buyer_biz_no, ds.ds_name as buyer_name,
+ bs.bs_bag_name, ABS(bs.bs_qty) as qty, bs.bs_unit_price, bs.bs_amount
+ FROM bag_sale bs
+ LEFT JOIN designated_shop ds ON bs.bs_ds_idx = ds.ds_idx
+ WHERE bs.bs_lg_idx = ? AND bs.bs_sale_date BETWEEN ? AND ? AND bs.bs_type = 'sale'
+ ORDER BY bs.bs_sale_date, ds.ds_name
+ ", [$lgIdx, $startDate, $endDate])->getResult();
+
+ // 지자체 정보 (공급자)
+ $lg = model(\App\Models\LocalGovernmentModel::class)->find($lgIdx);
+ $supplierBizNo = $lg->lg_biz_no ?? '';
+ $supplierName = $lg->lg_name ?? '';
+
+ $csvRows = [];
+ foreach ($rows as $row) {
+ $amount = (int) $row->bs_amount;
+ $tax = (int) round($amount * 0.1);
+ $csvRows[] = [
+ str_replace('-', '', $row->bs_sale_date), // 작성일자 (YYYYMMDD)
+ $supplierBizNo, // 공급자사업자번호
+ $supplierName, // 공급자상호
+ $row->buyer_biz_no ?? '', // 공급받는자사업자번호
+ $row->buyer_name ?? '', // 공급받는자상호
+ $row->bs_bag_name, // 품목
+ (int) $row->qty, // 수량
+ (int) $row->bs_unit_price, // 단가
+ $amount, // 공급가액
+ $tax, // 세액
+ ];
+ }
+
+ export_csv(
+ '홈택스_세금계산서_' . date('Ymd') . '.csv',
+ ['작성일자', '공급자사업자번호', '공급자상호', '공급받는자사업자번호', '공급받는자상호', '품목', '수량', '단가', '공급가액', '세액'],
+ $csvRows
+ );
+ }
+
+ /**
+ * P5-08: 반품/파기 현황
+ */
+ public function returns()
+ {
+ 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_sale_date, bs_ds_name, bs_bag_code, bs_bag_name, bs_type,
+ ABS(bs_qty) as qty, ABS(bs_amount) as amount
+ FROM bag_sale
+ WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ? AND bs_type IN('return','cancel')
+ ORDER BY bs_sale_date DESC, bs_ds_name
+ ", [$lgIdx, $startDate, $endDate])->getResult();
+
+ return view('admin/layout', [
+ 'title' => '반품/파기 현황',
+ 'content' => view('admin/sales_report/returns', compact('result', 'startDate', 'endDate')),
+ ]);
+ }
+
+ /**
+ * P5-10: LOT 수불 조회
+ */
+ public function lotFlow()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
+
+ $lotNo = $this->request->getGet('lot_no') ?? '';
+ $order = null;
+ $items = [];
+ $receivings = [];
+
+ if ($lotNo !== '') {
+ $db = \Config\Database::connect();
+ $order = $db->query("SELECT * FROM bag_order WHERE bo_lg_idx = ? AND bo_lot_no = ?", [$lgIdx, $lotNo])->getRow();
+ if ($order) {
+ $items = $db->query("SELECT * FROM bag_order_item WHERE boi_bo_idx = ? ORDER BY boi_bag_code", [(int) $order->bo_idx])->getResult();
+ $receivings = $db->query("SELECT * FROM bag_receiving WHERE br_bo_idx = ? ORDER BY br_receive_date", [(int) $order->bo_idx])->getResult();
+ }
+ }
+
+ return view('admin/layout', [
+ 'title' => 'LOT 수불 조회',
+ 'content' => view('admin/sales_report/lot_flow', compact('lotNo', 'order', 'items', 'receivings')),
+ ]);
+ }
+
+ /**
+ * P5-11: 기타 입출고 목록
+ */
+ public function miscFlow()
+ {
+ 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();
+
+ // bag_misc_flow 테이블이 존재하는지 확인
+ $tableExists = $db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() > 0;
+ $result = [];
+ if ($tableExists) {
+ $result = $db->query("
+ SELECT * FROM bag_misc_flow
+ WHERE bmf_lg_idx = ? AND bmf_date BETWEEN ? AND ?
+ ORDER BY bmf_date DESC, bmf_idx DESC
+ ", [$lgIdx, $startDate, $endDate])->getResult();
+ }
+
+ // 봉투 코드 목록
+ $kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
+ $bagCodes = $kindO ? model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true) : [];
+
+ return view('admin/layout', [
+ 'title' => '기타 입출고',
+ 'content' => view('admin/sales_report/misc_flow', compact('result', 'startDate', 'endDate', 'bagCodes', 'tableExists')),
+ ]);
+ }
+
+ /**
+ * P5-11: 기타 입출고 등록 처리
+ */
+ public function miscFlowStore()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin/reports/misc-flow'))->with('error', '지자체를 선택해 주세요.');
+
+ $rules = [
+ 'bmf_type' => 'required|in_list[in,out]',
+ 'bmf_bag_code' => 'required|max_length[50]',
+ 'bmf_qty' => 'required|is_natural_no_zero',
+ 'bmf_date' => 'required|valid_date[Y-m-d]',
+ 'bmf_reason' => 'required|max_length[200]',
+ ];
+ if (! $this->validate($rules)) {
+ return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
+ }
+
+ $bagCode = $this->request->getPost('bmf_bag_code');
+ $qty = (int) $this->request->getPost('bmf_qty');
+ $type = $this->request->getPost('bmf_type');
+
+ // 봉투명 조회
+ $kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
+ $detail = $kindO ? model(\App\Models\CodeDetailModel::class)->where('cd_ck_idx', $kindO->ck_idx)->where('cd_code', $bagCode)->first() : null;
+ $bagName = $detail ? $detail->cd_name : '';
+
+ $db = \Config\Database::connect();
+ $db->transStart();
+
+ $db->query("
+ INSERT INTO bag_misc_flow (bmf_lg_idx, bmf_type, bmf_bag_code, bmf_bag_name, bmf_qty, bmf_date, bmf_reason, bmf_regdate)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ", [$lgIdx, $type, $bagCode, $bagName, $qty, $this->request->getPost('bmf_date'), $this->request->getPost('bmf_reason'), date('Y-m-d H:i:s')]);
+
+ // 재고 조정
+ $delta = ($type === 'in') ? $qty : -$qty;
+ model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, $delta);
+
+ $db->transComplete();
+
+ return redirect()->to(site_url('admin/reports/misc-flow'))->with('success', '기타 입출고가 등록되었습니다.');
+ }
+
/**
* P5-07: 봉투 수불 현황
*/
diff --git a/app/Helpers/audit_helper.php b/app/Helpers/audit_helper.php
new file mode 100644
index 0000000..aca9338
--- /dev/null
+++ b/app/Helpers/audit_helper.php
@@ -0,0 +1,43 @@
+query("SHOW TABLES LIKE 'activity_log'")->getNumRows() === 0) {
+ return;
+ }
+
+ $mbIdx = session()->get('mb_idx');
+ $ip = service('request')->getIPAddress();
+
+ model(\App\Models\ActivityLogModel::class)->insert([
+ 'al_mb_idx' => $mbIdx ? (int) $mbIdx : null,
+ 'al_action' => $action,
+ 'al_table' => $table,
+ 'al_record_id' => $recordId,
+ 'al_data_before' => $before !== null ? json_encode($before, JSON_UNESCAPED_UNICODE) : null,
+ 'al_data_after' => $after !== null ? json_encode($after, JSON_UNESCAPED_UNICODE) : null,
+ 'al_ip' => $ip,
+ 'al_regdate' => date('Y-m-d H:i:s'),
+ ]);
+ } catch (\Throwable $e) {
+ // 로깅 실패 시 본 로직 방해하지 않음
+ log_message('error', 'audit_log failed: ' . $e->getMessage());
+ }
+ }
+}
diff --git a/app/Models/ActivityLogModel.php b/app/Models/ActivityLogModel.php
new file mode 100644
index 0000000..5012c3e
--- /dev/null
+++ b/app/Models/ActivityLogModel.php
@@ -0,0 +1,25 @@
+
-
관리자 메인 화면입니다. 상단 메뉴에서 기능을 선택하세요.
+
+
+
+
+ 작업할 지자체가 선택되지 않았습니다. 상단에서 지자체를 선택해 주세요.
+
+
+
+
+
+
총 발주 건수
+
= number_format($s['order_count'] ?? 0) ?>
+
금액: = number_format($s['order_amount'] ?? 0) ?>원
+
+
+
총 판매 건수
+
= number_format($s['sale_count'] ?? 0) ?>
+
금액: = number_format($s['sale_amount'] ?? 0) ?>원
+
+
+
재고 품목 수
+
= number_format($s['inventory_count'] ?? 0) ?>
+
현재 재고가 있는 봉투 품목
+
+
+
이번 달 불출
+
= number_format($s['issue_count_month'] ?? 0) ?>
+
= date('Y년 n월') ?> 무료용 불출
+
+
+
+
+
+
+
+
+
+
+
+
+ | LOT번호 |
+ 발주일 |
+ 상태 |
+
+
+
+ '정상', 'cancelled' => '취소', 'deleted' => '삭제'];
+ foreach (($s['recent_orders'] ?? []) as $order):
+ ?>
+
+ |
+ = esc($order->bo_lot_no) ?>
+ |
+ = esc($order->bo_order_date) ?> |
+
+ bo_status) {
+ 'cancelled' => 'text-red-600',
+ 'deleted' => 'text-gray-400',
+ default => 'text-green-600',
+ };
+ ?>
+ = esc($statusMap[$order->bo_status] ?? $order->bo_status) ?>
+ |
+
+
+
+ | 발주 내역이 없습니다. |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 판매소 |
+ 봉투명 |
+ 수량 |
+ 금액 |
+ 구분 |
+
+
+
+ '판매', 'return' => '반품', 'cancel' => '취소'];
+ foreach (($s['recent_sales'] ?? []) as $sale):
+ ?>
+
+ | = esc($sale->bs_ds_name) ?> |
+ = esc($sale->bs_bag_name) ?> |
+ = number_format(abs((int) $sale->bs_qty)) ?> |
+ = number_format((int) $sale->bs_amount) ?> |
+ = esc($typeMap[$sale->bs_type] ?? $sale->bs_type) ?> |
+
+
+
+ | 판매 내역이 없습니다. |
+
+
+
+
+
+
+
diff --git a/app/Views/admin/designated_shop/index.php b/app/Views/admin/designated_shop/index.php
index 2adaf05..ca5e08f 100644
--- a/app/Views/admin/designated_shop/index.php
+++ b/app/Views/admin/designated_shop/index.php
@@ -9,6 +9,29 @@
+
+
diff --git a/app/Views/admin/designated_shop/map.php b/app/Views/admin/designated_shop/map.php
new file mode 100644
index 0000000..50906da
--- /dev/null
+++ b/app/Views/admin/designated_shop/map.php
@@ -0,0 +1,56 @@
+= view('components/print_header', ['printTitle' => '지정판매소 지도']) ?>
+
+
+총 = count($shops) ?>개 판매소 표시
+
+
+
diff --git a/app/Views/admin/designated_shop/status.php b/app/Views/admin/designated_shop/status.php
new file mode 100644
index 0000000..ef7308c
--- /dev/null
+++ b/app/Views/admin/designated_shop/status.php
@@ -0,0 +1,80 @@
+= view('components/print_header', ['printTitle' => '지정판매소 현황']) ?>
+
+
+
+
+
+
활성 판매소
+
= number_format($totalActive) ?>
+
+
+
비활성/취소 판매소
+
= number_format($totalInactive) ?>
+
+
+
전체
+
= number_format($totalActive + $totalInactive) ?>
+
+
+
+
+
+
+
연도별 신규등록 건수
+
+
+
+
+ | 연도 |
+ 신규등록 건수 |
+
+
+
+
+
+ | = esc($row->yr) ?>년 |
+ = number_format((int) $row->cnt) ?> |
+
+
+
+ | 데이터가 없습니다. |
+
+
+
+
+
+
+
+
+
연도별 취소/비활성 건수
+
+
+
+
+ | 연도 |
+ 취소/비활성 건수 |
+
+
+
+
+
+ | = esc($row->yr) ?>년 |
+ = number_format((int) $row->cnt) ?> |
+
+
+
+ | 데이터가 없습니다. |
+
+
+
+
+
+
diff --git a/app/Views/admin/sales_report/lot_flow.php b/app/Views/admin/sales_report/lot_flow.php
new file mode 100644
index 0000000..768fbb4
--- /dev/null
+++ b/app/Views/admin/sales_report/lot_flow.php
@@ -0,0 +1,99 @@
+= view('components/print_header', ['printTitle' => 'LOT 수불 조회']) ?>
+
+
+
+
+
+
+
발주 정보
+
+
LOT번호: = esc($order->bo_lot_no) ?>
+
발주일: = esc($order->bo_order_date) ?>
+
상태:
+ '정상', 'cancelled' => '취소', 'deleted' => '삭제']; ?>
+ = esc($statusMap[$order->bo_status] ?? $order->bo_status) ?>
+
+
등록일: = esc($order->bo_regdate) ?>
+
+
+
+
+발주 품목
+
+
+
+
+ | 봉투코드 |
+ 봉투명 |
+ 발주수량(박스) |
+ 발주수량(매) |
+ 단가 |
+ 금액 |
+
+
+
+
+
+ | = esc($item->boi_bag_code) ?> |
+ = esc($item->boi_bag_name) ?> |
+ = number_format((int) $item->boi_qty_box) ?> |
+ = number_format((int) $item->boi_qty_sheet) ?> |
+ = number_format((int) $item->boi_unit_price) ?> |
+ = number_format((int) $item->boi_amount) ?> |
+
+
+
+ | 품목이 없습니다. |
+
+
+
+
+
+
+입고 내역
+
+
+
+
+ | 입고일 |
+ 봉투코드 |
+ 봉투명 |
+ 입고수량(박스) |
+ 입고수량(매) |
+ 납품자 |
+
+
+
+
+
+ | = esc($recv->br_receive_date) ?> |
+ = esc($recv->br_bag_code) ?> |
+ = esc($recv->br_bag_name) ?> |
+ = number_format((int) $recv->br_qty_box) ?> |
+ = number_format((int) $recv->br_qty_sheet) ?> |
+ = esc($recv->br_sender_name ?? '') ?> |
+
+
+
+ | 입고 내역이 없습니다. |
+
+
+
+
+
+
+해당 LOT 번호의 발주를 찾을 수 없습니다.
+
+LOT 번호를 입력하고 조회해 주세요.
+
diff --git a/app/Views/admin/sales_report/misc_flow.php b/app/Views/admin/sales_report/misc_flow.php
new file mode 100644
index 0000000..1d99d97
--- /dev/null
+++ b/app/Views/admin/sales_report/misc_flow.php
@@ -0,0 +1,84 @@
+= view('components/print_header', ['printTitle' => '기타 입출고']) ?>
+
+
+
+
+ bag_misc_flow 테이블이 생성되지 않았습니다. writable/database/bag_misc_flow_tables.sql을 실행해 주세요.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 번호 |
+ 구분 |
+ 일자 |
+ 봉투코드 |
+ 봉투명 |
+ 수량 |
+ 사유 |
+ 등록일 |
+
+
+
+
+
+ | = (int) $row->bmf_idx ?> |
+ = $row->bmf_type === 'in' ? '입고' : '출고' ?> |
+ = esc($row->bmf_date) ?> |
+ = esc($row->bmf_bag_code) ?> |
+ = esc($row->bmf_bag_name) ?> |
+ = number_format((int) $row->bmf_qty) ?> |
+ = esc($row->bmf_reason) ?> |
+ = esc($row->bmf_regdate) ?> |
+
+
+
+ | 조회된 데이터가 없습니다. |
+
+
+
+
diff --git a/app/Views/admin/sales_report/returns.php b/app/Views/admin/sales_report/returns.php
new file mode 100644
index 0000000..7bc5512
--- /dev/null
+++ b/app/Views/admin/sales_report/returns.php
@@ -0,0 +1,59 @@
+= view('components/print_header', ['printTitle' => '반품/파기 현황']) ?>
+
+
+
+
+
+
+ | 일자 |
+ 판매소 |
+ 봉투코드 |
+ 봉투명 |
+ 구분 |
+ 수량 |
+ 금액 |
+
+
+
+ '반품', 'cancel' => '취소/파기'];
+ foreach ($result as $row):
+ $totalQty += (int) $row->qty;
+ $totalAmt += (int) $row->amount;
+ ?>
+
+ | = esc($row->bs_sale_date) ?> |
+ = esc($row->bs_ds_name) ?> |
+ = esc($row->bs_bag_code) ?> |
+ = esc($row->bs_bag_name) ?> |
+ = esc($typeMap[$row->bs_type] ?? $row->bs_type) ?> |
+ = number_format((int) $row->qty) ?> |
+ = number_format((int) $row->amount) ?> |
+
+
+
+ | 조회된 데이터가 없습니다. |
+
+
+ | 합계 |
+ = number_format($totalQty) ?> |
+ = number_format($totalAmt) ?> |
+
+
+
+
+
diff --git a/app/Views/admin/sales_report/shop_sales.php b/app/Views/admin/sales_report/shop_sales.php
new file mode 100644
index 0000000..9883a43
--- /dev/null
+++ b/app/Views/admin/sales_report/shop_sales.php
@@ -0,0 +1,64 @@
+= view('components/print_header', ['printTitle' => '지정판매소별 판매현황']) ?>
+
+
+
+
+
+
+ | 판매소명 |
+ 판매수량 |
+ 판매금액 |
+ 반품수량 |
+ 반품금액 |
+ 순판매수량 |
+ 순판매금액 |
+
+
+
+ sale_qty;
+ $totSaleAmt += (int) $row->sale_amount;
+ $totRetQty += (int) $row->return_qty;
+ $totRetAmt += (int) $row->return_amount;
+ ?>
+
+ | = esc($row->bs_ds_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($totSaleQty) ?> |
+ = number_format($totSaleAmt) ?> |
+ = number_format($totRetQty) ?> |
+ = number_format($totRetAmt) ?> |
+ = number_format($totSaleQty - $totRetQty) ?> |
+ = number_format($totSaleAmt - $totRetAmt) ?> |
+
+
+
+
+
diff --git a/app/Views/admin/sales_report/yearly_sales.php b/app/Views/admin/sales_report/yearly_sales.php
new file mode 100644
index 0000000..86fb527
--- /dev/null
+++ b/app/Views/admin/sales_report/yearly_sales.php
@@ -0,0 +1,62 @@
+= view('components/print_header', ['printTitle' => '년 판매 현황']) ?>
+
+
+ 년 판매 현황 (월별)
+
+
+
+
+
+
+
+
+ | 봉투코드 |
+ 봉투명 |
+ 1월 | 2월 | 3월 | 4월 | 5월 | 6월 |
+ 7월 | 8월 | 9월 | 10월 | 11월 | 12월 |
+ 합계 |
+
+
+
+
+
+ | = esc($row->bs_bag_code) ?> |
+ = esc($row->bs_bag_name) ?> |
+ $key;
+ $grandTotal[$m] += $val;
+ ?>
+ = $val > 0 ? number_format($val) : '-' ?> |
+
+ total; ?>
+ = number_format((int) $row->total) ?> |
+
+
+
+ | 조회된 데이터가 없습니다. |
+
+
+ | 합계 |
+
+ = $grandTotal[$m] > 0 ? number_format($grandTotal[$m]) : '-' ?> |
+
+ = number_format($grandTotal[13]) ?> |
+
+
+
+
+
diff --git a/writable/database/activity_log_tables.sql b/writable/database/activity_log_tables.sql
new file mode 100644
index 0000000..99eff04
--- /dev/null
+++ b/writable/database/activity_log_tables.sql
@@ -0,0 +1,16 @@
+-- CT-05: CRUD 활동 로그 테이블
+CREATE TABLE IF NOT EXISTS `activity_log` (
+ `al_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `al_mb_idx` INT UNSIGNED DEFAULT NULL COMMENT '회원 PK',
+ `al_action` VARCHAR(20) NOT NULL COMMENT 'create/update/delete',
+ `al_table` VARCHAR(100) NOT NULL COMMENT '대상 테이블명',
+ `al_record_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '대상 레코드 PK',
+ `al_data_before` JSON DEFAULT NULL COMMENT '변경 전 데이터',
+ `al_data_after` JSON DEFAULT NULL COMMENT '변경 후 데이터',
+ `al_ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT 'IP 주소',
+ `al_regdate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '기록일시',
+ PRIMARY KEY (`al_idx`),
+ KEY `idx_al_table_record` (`al_table`, `al_record_id`),
+ KEY `idx_al_mb` (`al_mb_idx`),
+ KEY `idx_al_regdate` (`al_regdate`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CRUD 활동 로그';
diff --git a/writable/database/bag_misc_flow_tables.sql b/writable/database/bag_misc_flow_tables.sql
new file mode 100644
index 0000000..3d9f706
--- /dev/null
+++ b/writable/database/bag_misc_flow_tables.sql
@@ -0,0 +1,14 @@
+-- P5-11: 기타 입출고 테이블
+CREATE TABLE IF NOT EXISTS `bag_misc_flow` (
+ `bmf_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `bmf_lg_idx` INT UNSIGNED NOT NULL COMMENT '지자체 PK',
+ `bmf_type` ENUM('in','out') NOT NULL COMMENT '입고/출고',
+ `bmf_bag_code` VARCHAR(50) NOT NULL COMMENT '봉투 코드',
+ `bmf_bag_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '봉투명',
+ `bmf_qty` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '수량',
+ `bmf_date` DATE NOT NULL COMMENT '입출고 일자',
+ `bmf_reason` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '사유',
+ `bmf_regdate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '등록일',
+ PRIMARY KEY (`bmf_idx`),
+ KEY `idx_bmf_lg_date` (`bmf_lg_idx`, `bmf_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='기타 입출고';