From 1e8bf1eeeb745df36d1c7ad0c5e2113a7aef9a76 Mon Sep 17 00:00:00 2001 From: javamon1174 Date: Thu, 26 Mar 2026 16:50:28 +0900 Subject: [PATCH] =?UTF-8?q?P2-15~18,=20P5-04~11,=20CT-05~06=20=EC=9B=B9=20?= =?UTF-8?q?=EB=AF=B8=EA=B5=AC=ED=98=84=20=EA=B8=B0=EB=8A=A5=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-15: 지정판매소 다조건 조회 (이름/구군/상태 필터) P2-17: 지정판매소 지도 표시 (Kakao Maps) P2-18: 지정판매소 현황 (연도별 신규/취소 통계) P5-04: 년 판매 현황 (월별 피벗 테이블) P5-05: 지정판매소별 판매현황 (판매소별 수량/금액) P5-06: 홈택스 세금계산서 엑셀 내보내기 P5-08: 반품/파기 현황 (기간별 조회) P5-10: LOT 수불 조회 (LOT 번호 검색) P5-11: 기타 입출고 (등록 + 재고 연동) CT-05: CRUD 로깅 (activity_log 테이블 + audit_helper) CT-06: 대시보드 실 데이터 (발주/판매/재고/불출 통계) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Config/Routes.php | 9 + app/Controllers/Admin/BagIssue.php | 14 +- app/Controllers/Admin/BagOrder.php | 10 + app/Controllers/Admin/BagSale.php | 10 +- app/Controllers/Admin/Dashboard.php | 77 +++++- app/Controllers/Admin/DesignatedShop.php | 108 +++++++- app/Controllers/Admin/SalesReport.php | 258 ++++++++++++++++++ app/Helpers/audit_helper.php | 43 +++ app/Models/ActivityLogModel.php | 25 ++ app/Views/admin/dashboard/index.php | 119 +++++++- app/Views/admin/designated_shop/index.php | 23 ++ app/Views/admin/designated_shop/map.php | 56 ++++ app/Views/admin/designated_shop/status.php | 80 ++++++ app/Views/admin/sales_report/lot_flow.php | 99 +++++++ app/Views/admin/sales_report/misc_flow.php | 84 ++++++ app/Views/admin/sales_report/returns.php | 59 ++++ app/Views/admin/sales_report/shop_sales.php | 64 +++++ app/Views/admin/sales_report/yearly_sales.php | 62 +++++ writable/database/activity_log_tables.sql | 16 ++ writable/database/bag_misc_flow_tables.sql | 14 + 20 files changed, 1216 insertions(+), 14 deletions(-) create mode 100644 app/Helpers/audit_helper.php create mode 100644 app/Models/ActivityLogModel.php create mode 100644 app/Views/admin/designated_shop/map.php create mode 100644 app/Views/admin/designated_shop/status.php create mode 100644 app/Views/admin/sales_report/lot_flow.php create mode 100644 app/Views/admin/sales_report/misc_flow.php create mode 100644 app/Views/admin/sales_report/returns.php create mode 100644 app/Views/admin/sales_report/shop_sales.php create mode 100644 app/Views/admin/sales_report/yearly_sales.php create mode 100644 writable/database/activity_log_tables.sql create mode 100644 writable/database/bag_misc_flow_tables.sql 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 @@ + -

관리자 메인 화면입니다. 상단 메뉴에서 기능을 선택하세요.

+ + + +
+ 작업할 지자체가 선택되지 않았습니다. 상단에서 지자체를 선택해 주세요.
+ + + +
+
+
총 발주 건수
+
+
금액:
+
+
+
총 판매 건수
+
+
금액:
+
+
+
재고 품목 수
+
+
현재 재고가 있는 봉투 품목
+
+
+
이번 달 불출
+
+
무료용 불출
+
+
+ + +
+ +
+
+

최근 발주 5건

+ 전체보기 +
+
+ + + + + + + + + + '정상', 'cancelled' => '취소', 'deleted' => '삭제']; + foreach (($s['recent_orders'] ?? []) as $order): + ?> + + + + + + + + + + +
LOT번호발주일상태
+ bo_lot_no) ?> + bo_order_date) ?> + bo_status) { + 'cancelled' => 'text-red-600', + 'deleted' => 'text-gray-400', + default => 'text-green-600', + }; + ?> + bo_status] ?? $order->bo_status) ?> +
발주 내역이 없습니다.
+
+
+ + +
+
+

최근 판매 5건

+ 전체보기 +
+
+ + + + + + + + + + + + '판매', 'return' => '반품', 'cancel' => '취소']; + foreach (($s['recent_sales'] ?? []) as $sale): + ?> + + + + + + + + + + + + +
판매소봉투명수량금액구분
bs_ds_name) ?>bs_bag_name) ?>bs_qty)) ?>bs_amount) ?>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 @@ + '지정판매소 지도']) ?> +
+
+ 지정판매소 지도 + 목록으로 +
+
+
+
개 판매소 표시
+ + + 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 @@ + '지정판매소 현황']) ?> +
+
+ 지정판매소 현황 (신규/취소) +
+ + 목록으로 +
+
+
+ + +
+
+
활성 판매소
+
+
+
+
비활성/취소 판매소
+
+
+
+
전체
+
+
+
+ +
+ +
+

연도별 신규등록 건수

+
+
+ + + + + + + + + + + + + + + + + +
연도신규등록 건수
yr) ?>년cnt) ?>
데이터가 없습니다.
+
+ + + +
+

연도별 취소/비활성 건수

+
+ + + + + + + + + + + + + + + + + + +
연도취소/비활성 건수
yr) ?>년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 @@ + 'LOT 수불 조회']) ?> +
+
+ LOT 수불 조회 + +
+
+
+
+ + + +
+
+ + + +
+

발주 정보

+
+
LOT번호: bo_lot_no) ?>
+
발주일: bo_order_date) ?>
+
상태: + '정상', 'cancelled' => '취소', 'deleted' => '삭제']; ?> + bo_status] ?? $order->bo_status) ?> +
+
등록일: bo_regdate) ?>
+
+
+ + +

발주 품목

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
봉투코드봉투명발주수량(박스)발주수량(매)단가금액
boi_bag_code) ?>boi_bag_name) ?>boi_qty_box) ?>boi_qty_sheet) ?>boi_unit_price) ?>boi_amount) ?>
품목이 없습니다.
+
+ + +

입고 내역

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
입고일봉투코드봉투명입고수량(박스)입고수량(매)납품자
br_receive_date) ?>br_bag_code) ?>br_bag_name) ?>br_qty_box) ?>br_qty_sheet) ?>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 @@ + '기타 입출고']) ?> +
+
+ 기타 입출고 + +
+
+ + +
+ bag_misc_flow 테이블이 생성되지 않았습니다. writable/database/bag_misc_flow_tables.sql을 실행해 주세요. +
+ + + +
+
+ + + + + + + + + + + + +
+
+ + +
+
+ + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
번호구분일자봉투코드봉투명수량사유등록일
bmf_idx ?>bmf_type === 'in' ? '입고' : '출고' ?>bmf_date) ?>bmf_bag_code) ?>bmf_bag_name) ?>bmf_qty) ?>bmf_reason) ?>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 @@ + '반품/파기 현황']) ?> +
+
+ 반품/파기 현황 + +
+
+
+
+ + + + + +
+
+
+ + + + + + + + + + + + + + '반품', 'cancel' => '취소/파기']; + foreach ($result as $row): + $totalQty += (int) $row->qty; + $totalAmt += (int) $row->amount; + ?> + + + + + + + + + + + + + + + + + + + + +
일자판매소봉투코드봉투명구분수량금액
bs_sale_date) ?>bs_ds_name) ?>bs_bag_code) ?>bs_bag_name) ?>bs_type] ?? $row->bs_type) ?>qty) ?>amount) ?>
조회된 데이터가 없습니다.
합계
+
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 @@ + '지정판매소별 판매현황']) ?> +
+
+ 지정판매소별 판매현황 + +
+
+
+
+ + + + + +
+
+
+ + + + + + + + + + + + + + sale_qty; + $totSaleAmt += (int) $row->sale_amount; + $totRetQty += (int) $row->return_qty; + $totRetAmt += (int) $row->return_amount; + ?> + + + + + + + + + + + + + + + + + + + + + + + + +
판매소명판매수량판매금액반품수량반품금액순판매수량순판매금액
bs_ds_name) ?>sale_qty) ?>sale_amount) ?>return_qty) ?>return_amount) ?>sale_qty - (int) $row->return_qty) ?>sale_amount - (int) $row->return_amount) ?>
조회된 데이터가 없습니다.
합계
+
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 @@ + '년 판매 현황']) ?> +
+
+ 년 판매 현황 (월별) + +
+
+
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + $key; + $grandTotal[$m] += $val; + ?> + + + total; ?> + + + + + + + + + + + + + + + +
봉투코드봉투명1월2월3월4월5월6월7월8월9월10월11월12월합계
bs_bag_code) ?>bs_bag_name) ?> 0 ? number_format($val) : '-' ?>total) ?>
조회된 데이터가 없습니다.
합계 0 ? number_format($grandTotal[$m]) : '-' ?>
+
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='기타 입출고';