From 8763876f193501d61119a2c8257ae6db4a7ac056 Mon Sep 17 00:00:00 2001 From: taekyoungc Date: Mon, 8 Jun 2026 00:46:51 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=A7=A4?= =?UTF-8?q?=EB=89=B4=EC=96=BC=C2=B7=EB=B2=88=ED=98=B8=EC=95=8C=EA=B8=B0?= =?UTF-8?q?=C2=B7gov-portal=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=EC=99=80=20=EB=A9=94=EB=89=B4=20=EB=8F=99=EC=84=A0=C2=B7?= =?UTF-8?q?=EC=88=98=EB=B6=88=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 매뉴얼: league/commonmark 기반 bag/manual(로그인 전용), ManualRenderer + Config\Manual manifest, 콘텐츠 8종, E2E - 번호알기(봉투번호확인): bag/number-lookup, BagNumberLookup, E2E - gov-portal 대시보드 시안(기본/strip)·기본코드관리 화면 - 메뉴 관리: 등록·수정 후 메뉴 화면 유지, 수정 버튼 클릭 시 상단 스크롤 - 수불/분석 리포트(LOT 수불·반품/파기·수급계획·추이) 표시 보강 - .gitignore: docs/ → /docs/ 앵커링(최상위 개발문서만 제외, app/Docs는 추적) Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 +- README.md | 2 + app/Config/Manual.php | 34 + app/Config/Routes.php | 13 + app/Controllers/Admin/Menu.php | 42 +- app/Controllers/Admin/SalesReport.php | 101 ++- app/Controllers/Bag.php | 65 ++ app/Controllers/Home.php | 102 +++ app/Docs/manual/00_overview.md | 49 ++ app/Docs/manual/10_workflow.md | 36 + app/Docs/manual/20_order_receiving.md | 41 ++ app/Docs/manual/30_inventory.md | 39 + app/Docs/manual/40_sales_issue.md | 46 ++ app/Docs/manual/50_reports.md | 42 ++ app/Docs/manual/90_code_system.md | 57 ++ app/Docs/manual/99_faq.md | 29 + app/Helpers/admin_helper.php | 342 +++++++++ app/Libraries/BagAnalyticsReportBuilder.php | 41 +- app/Libraries/BagLotFlowBuilder.php | 267 ++++++- app/Libraries/BagNumberLookup.php | 225 ++++++ app/Libraries/GovPortalCodeKindsPage.php | 101 +++ app/Libraries/ManualRenderer.php | 111 +++ app/Views/admin/menu/index.php | 5 + app/Views/admin/sales_report/lot_flow.php | 4 +- app/Views/admin/sales_report/returns.php | 54 +- .../admin/sales_report/supply_demand.php | 83 ++- app/Views/bag/_dev_all_sales_panel.php | 168 +++++ app/Views/bag/analytics_monthly_trend.php | 2 +- app/Views/bag/analytics_seasonal_trend.php | 2 +- app/Views/bag/analytics_yoy.php | 1 + app/Views/bag/flow.php | 53 +- app/Views/bag/help.php | 12 + app/Views/bag/lg_dashboard_simple.php | 9 + app/Views/bag/manual.php | 92 +++ app/Views/bag/number_lookup.php | 307 ++++++++ app/Views/components/field_tooltip.php | 10 + .../home/_dashboard_gov_portal_brand.php | 15 + .../home/_dashboard_gov_portal_brand_css.php | 13 + ..._dashboard_gov_portal_font_zoom_script.php | 15 + app/Views/home/_dashboard_gov_portal_head.php | 3 + .../_dashboard_gov_portal_header_utils.php | 24 + .../home/_dashboard_gov_portal_map_css.php | 71 ++ ...ashboard_gov_portal_map_leaflet_assets.php | 2 + .../home/_dashboard_gov_portal_map_panel.php | 117 +++ .../_dashboard_gov_portal_menu_search.php | 43 ++ .../_dashboard_gov_portal_menu_search_css.php | 48 ++ .../_dashboard_gov_portal_nav_script_base.php | 118 +++ .../home/_dashboard_gov_portal_shared.php | 10 + .../home/_dashboard_gov_portal_sidebar.php | 59 ++ ...ashboard_gov_portal_stock_alert_levels.php | 23 + .../_dashboard_gov_portal_stock_cards_css.php | 157 ++++ ..._dashboard_gov_portal_stock_cards_pair.php | 23 + ..._dashboard_gov_portal_strip_home_inner.php | 89 +++ .../_dashboard_gov_portal_strip_layout.php | 81 ++ .../_dashboard_gov_portal_strip_my_menu.php | 41 ++ .../_dashboard_gov_portal_strip_styles.php | 67 ++ .../_dashboard_gov_portal_topnav_click.php | 42 ++ .../home/_dashboard_gov_portal_topnav_css.php | 171 +++++ .../_dashboard_gov_portal_topnav_hover.php | 59 ++ .../_dashboard_gov_portal_variant_nav.php | 17 + .../_dashboard_gov_portal_workpage_css.php | 192 +++++ .../home/_gov_portal_code_kinds_body.php | 224 ++++++ app/Views/home/dashboard_gov_portal.php | 638 ++++++++++++++++ .../home/dashboard_gov_portal_code_kinds.php | 143 ++++ app/Views/home/dashboard_gov_portal_strip.php | 8 + .../dashboard_gov_portal_strip_code_kinds.php | 10 + composer.json | 1 + composer.lock | 692 ++++++++++++++++-- doc/봉투-LOT-바코드-코드체계.md | 294 ++++++++ e2e/manual.spec.js | 41 ++ e2e/new-features.spec.js | 15 +- e2e/number-lookup.spec.js | 37 + jobs.md | 7 + writable/database/bag_dispose_tables.sql | 107 +++ writable/database/menu_link_number_lookup.sql | 10 + ...enu_site_fill_empty_second_level_links.sql | 2 +- writable/database/menu_site_seed_from_csv.sql | 2 +- 77 files changed, 6139 insertions(+), 182 deletions(-) create mode 100644 app/Config/Manual.php create mode 100644 app/Docs/manual/00_overview.md create mode 100644 app/Docs/manual/10_workflow.md create mode 100644 app/Docs/manual/20_order_receiving.md create mode 100644 app/Docs/manual/30_inventory.md create mode 100644 app/Docs/manual/40_sales_issue.md create mode 100644 app/Docs/manual/50_reports.md create mode 100644 app/Docs/manual/90_code_system.md create mode 100644 app/Docs/manual/99_faq.md create mode 100644 app/Libraries/BagNumberLookup.php create mode 100644 app/Libraries/GovPortalCodeKindsPage.php create mode 100644 app/Libraries/ManualRenderer.php create mode 100644 app/Views/bag/_dev_all_sales_panel.php create mode 100644 app/Views/bag/manual.php create mode 100644 app/Views/bag/number_lookup.php create mode 100644 app/Views/components/field_tooltip.php create mode 100644 app/Views/home/_dashboard_gov_portal_brand.php create mode 100644 app/Views/home/_dashboard_gov_portal_brand_css.php create mode 100644 app/Views/home/_dashboard_gov_portal_font_zoom_script.php create mode 100644 app/Views/home/_dashboard_gov_portal_head.php create mode 100644 app/Views/home/_dashboard_gov_portal_header_utils.php create mode 100644 app/Views/home/_dashboard_gov_portal_map_css.php create mode 100644 app/Views/home/_dashboard_gov_portal_map_leaflet_assets.php create mode 100644 app/Views/home/_dashboard_gov_portal_map_panel.php create mode 100644 app/Views/home/_dashboard_gov_portal_menu_search.php create mode 100644 app/Views/home/_dashboard_gov_portal_menu_search_css.php create mode 100644 app/Views/home/_dashboard_gov_portal_nav_script_base.php create mode 100644 app/Views/home/_dashboard_gov_portal_shared.php create mode 100644 app/Views/home/_dashboard_gov_portal_sidebar.php create mode 100644 app/Views/home/_dashboard_gov_portal_stock_alert_levels.php create mode 100644 app/Views/home/_dashboard_gov_portal_stock_cards_css.php create mode 100644 app/Views/home/_dashboard_gov_portal_stock_cards_pair.php create mode 100644 app/Views/home/_dashboard_gov_portal_strip_home_inner.php create mode 100644 app/Views/home/_dashboard_gov_portal_strip_layout.php create mode 100644 app/Views/home/_dashboard_gov_portal_strip_my_menu.php create mode 100644 app/Views/home/_dashboard_gov_portal_strip_styles.php create mode 100644 app/Views/home/_dashboard_gov_portal_topnav_click.php create mode 100644 app/Views/home/_dashboard_gov_portal_topnav_css.php create mode 100644 app/Views/home/_dashboard_gov_portal_topnav_hover.php create mode 100644 app/Views/home/_dashboard_gov_portal_variant_nav.php create mode 100644 app/Views/home/_dashboard_gov_portal_workpage_css.php create mode 100644 app/Views/home/_gov_portal_code_kinds_body.php create mode 100644 app/Views/home/dashboard_gov_portal.php create mode 100644 app/Views/home/dashboard_gov_portal_code_kinds.php create mode 100644 app/Views/home/dashboard_gov_portal_strip.php create mode 100644 app/Views/home/dashboard_gov_portal_strip_code_kinds.php create mode 100644 doc/봉투-LOT-바코드-코드체계.md create mode 100644 e2e/manual.spec.js create mode 100644 e2e/number-lookup.spec.js create mode 100644 writable/database/bag_dispose_tables.sql create mode 100644 writable/database/menu_link_number_lookup.sql diff --git a/.gitignore b/.gitignore index 0231421..3ec8950 100644 --- a/.gitignore +++ b/.gitignore @@ -174,4 +174,5 @@ blob-report/ /results/ /phpunit*.xml -docs/ +# 최상위 개발 문서/스크린샷 폴더만 제외 (app/Docs 등 하위 docs 경로는 추적). +/docs/ diff --git a/README.md b/README.md index bf8baf1..ef7b256 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,8 @@ assets/ # 기획 문서 (엑셀) ## 바코드 생성/사용 시점 (현재 코드 기준) +상세 코드 체계(LOT·팩·낱장·품목코드·판매소번호): [`doc/봉투-LOT-바코드-코드체계.md`](doc/봉투-LOT-바코드-코드체계.md) + - **현재 코드 구현** - 박스/팩/낱장 바코드는 `bag_receiving_pack_code`에 저장됩니다. - 생성 트리거는 입고 처리(`receiving/scanner/store`, `receiving/batch/store`) 시점의 `createReceivingPackCodes()` 호출입니다. diff --git a/app/Config/Manual.php b/app/Config/Manual.php new file mode 100644 index 0000000..6656988 --- /dev/null +++ b/app/Config/Manual.php @@ -0,0 +1,34 @@ + + */ + public array $pages = [ + 'overview' => ['title' => '시작하기·시스템 개요', 'file' => '00_overview.md'], + 'flow' => ['title' => '핵심 업무 흐름', 'file' => '10_workflow.md'], + 'order' => ['title' => '발주·입고', 'file' => '20_order_receiving.md'], + 'inventory' => ['title' => '재고·실사', 'file' => '30_inventory.md'], + 'sales' => ['title' => '판매·불출', 'file' => '40_sales_issue.md'], + 'reports' => ['title' => '판매현황·수불·통계', 'file' => '50_reports.md'], + 'codes' => ['title' => '봉투·LOT·바코드 코드체계', 'file' => '90_code_system.md'], + 'faq' => ['title' => '자주 묻는 질문·문의', 'file' => '99_faq.md'], + ]; +} diff --git a/app/Config/Routes.php b/app/Config/Routes.php index b8df63f..f76c537 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -15,6 +15,10 @@ $routes->get('dashboard/dense', 'Home::dashboardDense'); $routes->get('dashboard/charts', 'Home::dashboardCharts'); $routes->get('dashboard/blend', 'Home::dashboardBlend'); $routes->get('dashboard/lite', 'Home::dashboardLite'); +$routes->get('dashboard/gov-portal', 'Home::dashboardGovPortal'); +$routes->get('dashboard/gov-portal/code-kinds', 'Home::dashboardGovPortalCodeKinds'); +$routes->get('dashboard/gov-portal-strip', 'Home::dashboardGovPortalStrip'); +$routes->get('dashboard/gov-portal-strip/code-kinds', 'Home::dashboardGovPortalStripCodeKinds'); $routes->get('bag/inventory-inquiry', 'Home::inventoryInquiry'); $routes->get('bag/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise'); @@ -52,6 +56,15 @@ $routes->get('bag/analytics/seasonal-trend', 'Bag::analyticsSeasonalTrend'); $routes->get('bag/window', 'Bag::window'); $routes->get('bag/help', 'Bag::help'); +// 사용자 매뉴얼(설명서) — 로그인 사용자 전용 +$routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void { + $routes->get('manual', 'Bag::manual'); + $routes->get('manual/(:segment)', 'Bag::manualPage/$1'); +}); + +$routes->get('bag/number-lookup', 'Bag::numberLookup'); +$routes->post('bag/number-lookup/resolve', 'Bag::numberLookupResolve'); + // 사이트 메뉴 CRUD (사이트 레이아웃) $routes->get('bag/issue/create', 'Bag::issueCreate'); $routes->post('bag/issue/store', 'Bag::issueStore'); diff --git a/app/Controllers/Admin/Menu.php b/app/Controllers/Admin/Menu.php index 42fd738..f657a39 100644 --- a/app/Controllers/Admin/Menu.php +++ b/app/Controllers/Admin/Menu.php @@ -18,6 +18,21 @@ class Menu extends BaseController $this->typeModel = model(MenuTypeModel::class); } + /** + * 메뉴 등록·수정·삭제·순서변경 후 항상 같은 메뉴 관리 화면(mt_idx 유지)으로 돌아간다. + * redirect()->back() 은 목록의 새 탭(target="_blank") 링크 클릭으로 세션 직전 URL(_ci_previous_url)이 + * 메뉴 대상 페이지로 덮어써지면 그 페이지로 이탈하므로, 명시적으로 메뉴 화면 URL 을 사용한다. + */ + private function menusRedirect(int $mtIdx): \CodeIgniter\HTTP\RedirectResponse + { + $url = base_url('admin/menus'); + if ($mtIdx > 0) { + $url .= '?mt_idx=' . $mtIdx; + } + + return redirect()->to($url); + } + /** * 메뉴 관리 화면 (목록 + 등록/수정 폼). 지자체별 메뉴만 조회·관리. */ @@ -140,10 +155,10 @@ class Menu extends BaseController $mmDep = (int) $this->request->getPost('mm_dep'); $mmName = trim((string) $this->request->getPost('mm_name')); if ($mtIdx <= 0) { - return redirect()->back()->with('error', '메뉴 종류를 선택하세요.'); + return $this->menusRedirect($mtIdx)->with('error', '메뉴 종류를 선택하세요.'); } if ($mmName === '') { - return redirect()->back()->with('error', '메뉴명을 입력하세요.'); + return $this->menusRedirect($mtIdx)->with('error', '메뉴명을 입력하세요.'); } $mmNum = $this->menuModel->getNextNum($mtIdx, $lgIdx, $mmPidx, $mmDep); $data = [ @@ -164,7 +179,7 @@ class Menu extends BaseController } $this->menuModel->pruneInventoryManagementMenus($mtIdx, $lgIdx); $this->menuModel->syncTypeToAllLgs($mtIdx, $lgIdx); - return redirect()->back()->with('success', '메뉴가 등록되었습니다.'); + return $this->menusRedirect($mtIdx)->with('success', '메뉴가 등록되었습니다.'); } /** @@ -182,10 +197,12 @@ class Menu extends BaseController } $row = $this->menuModel->find($id); if (! $row) { - return redirect()->back()->with('error', '메뉴를 찾을 수 없습니다.'); + return $this->menusRedirect((int) $this->request->getPost('mt_idx')) + ->with('error', '메뉴를 찾을 수 없습니다.'); } if ((int) $row->lg_idx !== $lgIdx) { - return redirect()->back()->with('error', '해당 지자체의 메뉴만 수정할 수 있습니다.'); + return $this->menusRedirect((int) $row->mt_idx) + ->with('error', '해당 지자체의 메뉴만 수정할 수 있습니다.'); } $data = [ 'mm_name' => (string) $this->request->getPost('mm_name'), @@ -196,7 +213,7 @@ class Menu extends BaseController $this->menuModel->update($id, $data); $this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx); $this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx); - return redirect()->back()->with('success', '메뉴가 수정되었습니다.'); + return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.'); } /** @@ -214,15 +231,16 @@ class Menu extends BaseController } $row = $this->menuModel->find($id); if (! $row || (int) $row->lg_idx !== $lgIdx) { - return redirect()->back()->with('error', '해당 지자체의 메뉴만 삭제할 수 있습니다.'); + return $this->menusRedirect((int) $this->request->getPost('mt_idx')) + ->with('error', '해당 지자체의 메뉴만 삭제할 수 있습니다.'); } $result = $this->menuModel->deleteSafe($id); if ($result['ok']) { $this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx); $this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx); - return redirect()->back()->with('success', '메뉴가 삭제되었습니다.'); + return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 삭제되었습니다.'); } - return redirect()->back()->with('error', $result['msg']); + return $this->menusRedirect((int) $row->mt_idx)->with('error', $result['msg']); } /** @@ -239,8 +257,9 @@ class Menu extends BaseController ->with('error', '지자체를 선택하세요.'); } $ids = $this->request->getPost('mm_idx'); + $postMtIdx = (int) $this->request->getPost('mt_idx'); if (! is_array($ids) || empty($ids)) { - return redirect()->back()->with('error', '순서를 적용할 메뉴가 없습니다.'); + return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.'); } $firstId = (int) ($ids[0] ?? 0); $firstRow = $firstId > 0 ? $this->menuModel->find($firstId) : null; @@ -249,7 +268,8 @@ class Menu extends BaseController $this->menuModel->pruneInventoryManagementMenus((int) $firstRow->mt_idx, $lgIdx); $this->menuModel->syncTypeToAllLgs((int) $firstRow->mt_idx, $lgIdx); } - return redirect()->back()->with('success', '순서가 적용되었습니다.'); + $mtIdx = $firstRow ? (int) $firstRow->mt_idx : $postMtIdx; + return $this->menusRedirect($mtIdx)->with('success', '순서가 적용되었습니다.'); } /** diff --git a/app/Controllers/Admin/SalesReport.php b/app/Controllers/Admin/SalesReport.php index 47a34f7..1f2bd83 100644 --- a/app/Controllers/Admin/SalesReport.php +++ b/app/Controllers/Admin/SalesReport.php @@ -2582,7 +2582,6 @@ class SalesReport extends BaseController 'endDate' => $endDate, 'ioType' => $ioType, 'queried' => $queried, - 'exportQuery' => $this->returnsExportQueryString(), ]); } @@ -2594,12 +2593,24 @@ class SalesReport extends BaseController return redirect()->to(mgmt_url('reports/returns'))->with('error', '지자체를 선택해 주세요.'); } - if ($this->request->getGet('search') !== '1') { + $startDate = trim((string) ($this->request->getGet('start_date') ?? '')); + $endDate = trim((string) ($this->request->getGet('end_date') ?? '')); + $hasQuery = $this->request->getGet('search') === '1' + || ($startDate !== '' && $endDate !== ''); + + if (! $hasQuery) { return redirect()->to(mgmt_url('reports/returns'))->with('error', '조회 후 엑셀 저장을 이용해 주세요.'); } - $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); - $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); + if ($startDate === '') { + $startDate = date('Y-m-01'); + } + if ($endDate === '') { + $endDate = date('Y-m-d'); + } + if ($startDate > $endDate) { + [$startDate, $endDate] = [$endDate, $startDate]; + } $ioType = (string) ($this->request->getGet('io_type') ?? 'out'); if (! in_array($ioType, ['in', 'out'], true)) { $ioType = 'out'; @@ -2628,22 +2639,77 @@ class SalesReport extends BaseController } /** + * 출고 = 지정판매소 반품(designated-return · bag_return_scan_code) + * 입고 = 물류 입고분 파기(bag_dispose) + * * @return list */ private function fetchReturnDisposeRows(int $lgIdx, string $startDate, string $endDate, string $ioType): array { - $bsTypes = $ioType === 'in' ? ['return'] : ['cancel']; - $typePlaceholders = implode(',', array_fill(0, count($bsTypes), '?')); $db = \Config\Database::connect(); + if ($ioType === 'out') { + return $this->fetchDesignatedReturnRows($db, $lgIdx, $startDate, $endDate); + } + + return $this->fetchBagDisposeRows($db, $lgIdx, $startDate, $endDate); + } + + /** + * @return list + */ + private function fetchDesignatedReturnRows($db, int $lgIdx, string $startDate, string $endDate): array + { + if ($db->tableExists('bag_return_scan_code')) { + return $db->query(" + SELECT r.brsc_return_date AS bs_sale_date, + COALESCE(ds.ds_name, '') AS bs_ds_name, + r.brsc_bag_code AS bs_bag_code, + r.brsc_bag_name AS bs_bag_name, + 'return' AS bs_type, + SUM(r.brsc_qty) AS qty + FROM bag_return_scan_code r + LEFT JOIN designated_shop ds + ON ds.ds_idx = r.brsc_ds_idx AND ds.ds_lg_idx = r.brsc_lg_idx + WHERE r.brsc_lg_idx = ? + AND r.brsc_return_date BETWEEN ? AND ? + AND r.brsc_state = 'returned' + GROUP BY r.brsc_return_date, r.brsc_ds_idx, ds.ds_name, + r.brsc_bag_code, r.brsc_bag_name + ORDER BY r.brsc_return_date ASC, bs_ds_name ASC, r.brsc_bag_code ASC + ", [$lgIdx, $startDate, $endDate])->getResult(); + } + return $db->query(" SELECT bs_sale_date, bs_ds_name, bs_bag_code, bs_bag_name, bs_type, ABS(bs_qty) AS qty FROM bag_sale WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ? - AND bs_type IN ({$typePlaceholders}) + AND bs_type = 'return' ORDER BY bs_sale_date ASC, bs_ds_name ASC, bs_bag_code ASC - ", array_merge([$lgIdx, $startDate, $endDate], $bsTypes))->getResult(); + ", [$lgIdx, $startDate, $endDate])->getResult(); + } + + /** + * @return list + */ + private function fetchBagDisposeRows($db, int $lgIdx, string $startDate, string $endDate): array + { + if (! $db->tableExists('bag_dispose')) { + return []; + } + + return $db->query(" + SELECT bd_dispose_date AS bs_sale_date, + bd_location AS bs_ds_name, + bd_bag_code AS bs_bag_code, + bd_bag_name AS bs_bag_name, + 'dispose' AS bs_type, + bd_qty AS qty + FROM bag_dispose + WHERE bd_lg_idx = ? AND bd_dispose_date BETWEEN ? AND ? + ORDER BY bd_dispose_date ASC, bd_location ASC, bd_bag_code ASC + ", [$lgIdx, $startDate, $endDate])->getResult(); } private function returnDisposeKindLabel(object $row): string @@ -2660,24 +2726,13 @@ class SalesReport extends BaseController private function returnDisposeTypeLabel(string $bsType): string { return match ($bsType) { - 'return' => '반품', - 'cancel' => '파기', - default => $bsType, + 'return' => '반품', + 'dispose' => '파기', + 'cancel' => '파기', + default => $bsType, }; } - private function returnsExportQueryString(): string - { - $params = array_filter([ - 'search' => '1', - 'start_date' => $this->request->getGet('start_date'), - 'end_date' => $this->request->getGet('end_date'), - 'io_type' => $this->request->getGet('io_type'), - ], static fn ($v) => $v !== null && $v !== ''); - - return http_build_query($params); - } - /** * P5-10: LOT 수불 조회 (레거시 w_gd033a — 바코드/봉투번호) */ diff --git a/app/Controllers/Bag.php b/app/Controllers/Bag.php index e209699..8a9babe 100644 --- a/app/Controllers/Bag.php +++ b/app/Controllers/Bag.php @@ -3571,6 +3571,71 @@ SQL); return $this->render('도움말', 'bag/help', []); } + /** + * 사용자 매뉴얼(설명서) — 목차 첫 페이지로 이동. + */ + public function manual(): \CodeIgniter\HTTP\RedirectResponse + { + $first = (new \App\Libraries\ManualRenderer())->firstSlug(); + + return redirect()->to(site_url('bag/manual/' . $first)); + } + + /** + * 사용자 매뉴얼 개별 페이지 (slug = 화이트리스트). 미등록 slug 는 404. + */ + public function manualPage(string $slug): string + { + $renderer = new \App\Libraries\ManualRenderer(); + $page = $renderer->find($slug); + $body = $page !== null ? $renderer->render($slug) : null; + if ($page === null || $body === null) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('매뉴얼 문서를 찾을 수 없습니다.'); + } + + return $this->render('사용자 매뉴얼 · ' . $page['title'], 'bag/manual', [ + 'pages' => $renderer->pages(), + 'current' => $slug, + 'title' => $page['title'], + 'body' => $body, + ]); + } + + /** + * 도움말 — 번호알기(봉투번호확인). 코드 → 바코드·인쇄숫자·인식번호. + */ + public function numberLookup(): string + { + $code = trim((string) ($this->request->getGet('code') ?? '')); + $result = null; + $error = ''; + + if ($code !== '') { + $resolved = (new \App\Libraries\BagNumberLookup())->resolve($code, $this->lgIdx()); + $result = $resolved; + if (! $resolved['ok']) { + $error = (string) $resolved['message']; + } + } + + return $this->render('번호알기', 'bag/number_lookup', [ + 'code' => $code, + 'result' => $result, + 'error' => $error, + ]); + } + + /** + * 번호알기 AJAX 조회. + */ + public function numberLookupResolve() + { + $code = trim((string) ($this->request->getPost('code') ?? $this->request->getGet('code') ?? '')); + $data = (new \App\Libraries\BagNumberLookup())->resolve($code, $this->lgIdx()); + + return $this->response->setJSON($data); + } + // ══════════════════════════════════════════════ // CRUD — 사이트 레이아웃으로 등록/처리 폼 제공 // ══════════════════════════════════════════════ diff --git a/app/Controllers/Home.php b/app/Controllers/Home.php index e01f1c7..7713cf8 100644 --- a/app/Controllers/Home.php +++ b/app/Controllers/Home.php @@ -2,6 +2,7 @@ namespace App\Controllers; +use App\Libraries\GovPortalCodeKindsPage; use App\Models\LocalGovernmentModel; class Home extends BaseController @@ -118,6 +119,107 @@ class Home extends BaseController ]); } + /** + * 공공 포털형(국가재난관리정보시스템 스타일) 메인 시안. + * URL: /dashboard/gov-portal — 기능은 요약 대시보드와 동일, UI만 별도 레이아웃. + */ + public function dashboardGovPortal() + { + if (! session()->get('logged_in')) { + return redirect()->to(base_url('login')); + } + + helper('admin'); + + return view('home/dashboard_gov_portal', gov_portal_dashboard_view_data($this->resolveLgLabel(), 'base')); + } + + /** + * 공공 포털형 변형 — 가로 MY MENU·와이드 맵·KPI 띠. URL: /dashboard/gov-portal-strip + */ + public function dashboardGovPortalStrip() + { + if (! session()->get('logged_in')) { + return redirect()->to(base_url('login')); + } + + helper('admin'); + + return view('home/_dashboard_gov_portal_strip_layout', array_merge( + gov_portal_dashboard_view_data($this->resolveLgLabel(), 'strip'), + ['stripInnerView' => 'home/_dashboard_gov_portal_strip_home_inner'] + )); + } + + /** + * 공공 포털형(기본) — 기본 코드관리 UI 시안. URL: /dashboard/gov-portal/code-kinds + */ + public function dashboardGovPortalCodeKinds() + { + return $this->renderGovPortalCodeKinds('base'); + } + + /** + * 공공 포털형(변형 strip) — 기본 코드관리 UI 시안. URL: /dashboard/gov-portal-strip/code-kinds + */ + public function dashboardGovPortalStripCodeKinds() + { + return $this->renderGovPortalCodeKinds('strip'); + } + + /** + * @return \CodeIgniter\HTTP\RedirectResponse|string + */ + private function renderGovPortalCodeKinds(string $variant) + { + if (! session()->get('logged_in')) { + return redirect()->to(base_url('login')); + } + + helper('admin'); + + $portalPath = gov_portal_code_kinds_portal_path($variant); + $lgIdx = admin_effective_lg_idx(); + $level = (int) session()->get('mb_level'); + $filters = [ + 'q_code' => $this->request->getGet('q_code'), + 'q_name' => $this->request->getGet('q_name'), + 'q_state' => $this->request->getGet('q_state'), + ]; + $builder = new GovPortalCodeKindsPage(); + $ckIdx = (int) ($this->request->getGet('ck_idx') ?? 0); + $pageData = $builder->buildPageData($lgIdx, $level, $lgIdx, $ckIdx, $filters); + + if ($ckIdx === 0 && $pageData['codeKinds'] !== []) { + $pageData = $builder->buildPageData( + $lgIdx, + $level, + $lgIdx, + (int) $pageData['codeKinds'][0]->ck_idx, + $filters + ); + } + + $viewData = array_merge( + gov_portal_dashboard_view_data($this->resolveLgLabel(), $variant), + $pageData, + [ + 'govActiveChildHref' => $portalPath, + 'pageBaseUrl' => site_url($portalPath), + ] + ); + + if ($variant === 'strip') { + return view('home/_dashboard_gov_portal_strip_layout', array_merge($viewData, [ + 'stripInnerView' => 'home/_gov_portal_code_kinds_body', + 'stripIncludeWorkCss' => true, + 'stripShowProfileLinks' => true, + ])); + } + + return view('home/dashboard_gov_portal_code_kinds', $viewData); + } + /** * 재고 조회(수불) 화면 (목업) */ diff --git a/app/Docs/manual/00_overview.md b/app/Docs/manual/00_overview.md new file mode 100644 index 0000000..7d0cc95 --- /dev/null +++ b/app/Docs/manual/00_overview.md @@ -0,0 +1,49 @@ +# 시작하기 · 시스템 개요 + +종량제 쓰레기봉투 물류 시스템 사용자 매뉴얼입니다. 이 문서는 실제 업무를 처리하는 담당자(지자체 관리자·지정판매소)를 위한 **빠른 시작 안내서**입니다. + +## 1. 시스템은 무엇을 하나요? + +지자체 종량제 쓰레기봉투의 **발주 → 입고 → 재고 → 판매/불출 → 정산·통계** 전 과정을 관리합니다. + +- 봉투를 제작업체에 **발주**하고, 들어온 물량을 **입고** 처리합니다. +- 현재 **재고**를 확인하고, 실사로 실제 수량과 맞춥니다. +- 지정판매소에 **판매**하거나 무료 대상자에게 **불출**합니다. +- 일계표·기간별·연간 등 **판매현황**과 **수불·통계**를 조회합니다. + +## 2. 로그인과 화면 구성 + +1. 발급받은 아이디·비밀번호로 로그인합니다. +2. 관리자(지자체/슈퍼)는 보안을 위해 **2차 인증(OTP)** 이 적용될 수 있습니다. +3. 로그인하면 상단에 **대메뉴**가 보이고, 각 대메뉴에 마우스를 올리면 **소메뉴**가 펼쳐집니다. +4. 화면 상단의 제목 바에 현재 보고 있는 화면 이름이 표시됩니다. + +> 슈퍼 관리자는 작업할 **지자체를 먼저 선택**해야 업무 화면이 열립니다. + +## 3. 사용자 역할(권한) + +시스템은 4단계 역할로 접근 권한을 구분합니다. + +| 레벨 | 역할 | 할 수 있는 일 | +|---|---|---| +| 1 | 일반 사용자 | 기본 조회 | +| 2 | 지정판매소 | 봉투 판매·반품, 자기 판매 현황 조회 | +| 3 | 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 | +| 4 | 슈퍼 관리자 | 전체 시스템 관리 + 기본코드 등 마스터 관리 (지자체 선택 후 작업) | + +### 역할별 접근 한눈에 보기 + +| 기능군 | 일반 | 판매소 | 지자체관리자 | 슈퍼 | +|---|:--:|:--:|:--:|:--:| +| 기본정보 조회(코드·단가·포장) | △ | ○ | ○ | ○ | +| 기본코드·마스터 편집 | ✕ | ✕ | △(지자체분) | ○ | +| 발주·입고·재고·불출 | ✕ | ✕ | ○ | ○ | +| 판매·반품 등록 | ✕ | ○ | ○ | ○ | +| 판매현황·수불·통계 | ✕ | △(자기분) | ○ | ○ | + +(○ 사용 가능 · △ 제한적 · ✕ 불가) + +## 4. 다음 단계 + +- 전체 업무가 어떻게 이어지는지 먼저 보려면 **[핵심 업무 흐름]** 으로 이동하세요. +- 특정 작업 방법은 좌측 목차에서 해당 항목(발주·입고 / 재고·실사 / 판매·불출 / 판매현황·수불·통계)을 선택하세요. diff --git a/app/Docs/manual/10_workflow.md b/app/Docs/manual/10_workflow.md new file mode 100644 index 0000000..48d340b --- /dev/null +++ b/app/Docs/manual/10_workflow.md @@ -0,0 +1,36 @@ +# 핵심 업무 흐름 + +봉투 한 묶음이 시스템에서 거치는 전체 흐름입니다. 처음 사용하신다면 이 순서대로 익히는 것을 권장합니다. + +## 전체 흐름 + +``` +발주 ─→ 입고 ─→ 재고(실사) ─→ 판매 / 불출 ─→ 판매현황 · 수불 · 통계 +``` + +| 단계 | 무엇을 하나 | 주요 메뉴 | +|---|---|---| +| ① 발주 | 봉투 종류·수량을 제작업체에 주문 | 발주 입고 관리 › 발주 등록 | +| ② 입고 | 도착한 물량을 시스템에 등록(스캐너/일괄) | 발주 입고 관리 › 입고 | +| ③ 재고 | 현재 보유 수량 확인, 실사로 실수량 보정 | 재고 관리 | +| ④ 판매 | 지정판매소에 판매·반품 처리 | 판매 관리 | +| ④ 불출 | 무료 대상자에게 무상 지급 | 불출 관리 | +| ⑤ 현황 | 일·기간·연간 판매 및 수불·통계 조회 | 판매 현황 / 봉투 수불 / 통계 분석 | + +## 각 단계 한 줄 요약 + +1. **발주** — 봉투 품목·수량·납기를 입력해 발주서를 만들면 추적용 **LOT 번호**가 부여됩니다. +2. **입고** — 발주분이 도착하면 입고로 등록합니다. 이때 박스·팩·낱장 단위의 **바코드**가 생성됩니다. +3. **재고** — 품목별 현재 재고를 조회하고, 정기적으로 **실사**(선별 → 등록 → 적용)로 실제 수량과 맞춥니다. +4. **판매/불출** — 지정판매소 판매·반품, 또는 무료 대상자 불출로 재고가 감소합니다. +5. **현황·통계** — 일계표·기간별·연간 판매와 봉투 수불, 전년대비/월별/계절 추이를 확인합니다. + +## 봉투 추적 단위 + +봉투는 다음 계층으로 추적됩니다. 자세한 코드 규칙은 **[봉투·LOT·바코드 코드체계]** 문서를 참고하세요. + +``` +LOT(발주 묶음) ─→ 박스(B) ─→ 팩(P) ─→ 낱장(S, 봉투 한 장) +``` + +> 코드 한 건이 어떤 바코드·인쇄숫자·인식번호인지 확인하려면 **도움말 › 번호알기** 화면을 사용하세요. diff --git a/app/Docs/manual/20_order_receiving.md b/app/Docs/manual/20_order_receiving.md new file mode 100644 index 0000000..ab749eb --- /dev/null +++ b/app/Docs/manual/20_order_receiving.md @@ -0,0 +1,41 @@ +# 발주 · 입고 + +제작업체에 봉투를 주문(발주)하고, 도착한 물량을 시스템에 등록(입고)하는 단계입니다. **지자체 관리자** 이상이 사용합니다. + +## 발주 + +### 발주 등록 + +**발주 입고 관리 › 발주 등록** + +1. 봉투 **품목**(종류·용량)과 **수량**, 납품 관련 정보를 입력합니다. +2. 박스/낱장 수량과 금액·총계가 자동으로 계산됩니다. +3. 저장하면 발주 건이 생성되고, 추적용 **LOT 번호**가 자동 부여됩니다. + +> 발주 내용은 무결성 보호를 위해 버전·해시로 관리됩니다. 수정(재발주) 시 기존 LOT는 유지됩니다. + +### 발주 변경 · 현황 + +| 작업 | 메뉴 | 설명 | +|---|---|---| +| 발주 변경 | 발주 변경 | 기존 발주 수정·재발주 | +| 발주 현황 | 발주 현황 | 발주 목록을 기간·상태로 조회, 엑셀 내보내기 | +| 발주 상세 | (현황에서 행 선택) | 개별 발주 상세 확인, 취소 처리 | + +## 입고 + +발주분이 실제 도착하면 입고로 등록합니다. 입고 시 **박스·팩·낱장 바코드**가 생성되어 재고에 반영됩니다. + +| 방식 | 메뉴 | 언제 사용 | +|---|---|---| +| 스캐너 입고 | 발주 입고[스캐너] | 바코드를 스캔하며 입고 | +| 일괄 입고 | 일괄입고 | 다량을 한 번에 입고 | +| 입고 현황 | 입고 현황 | 입고 기록 조회, 엑셀 내보내기 | + +### 입고 처리 순서 + +1. 입고할 발주 건(LOT)을 선택합니다. +2. 도착 수량(박스/낱장)을 확인·입력합니다. +3. 저장하면 재고가 증가하고, 단위별 바코드가 부여됩니다. + +> 입고가 끝나면 **재고 관리**에서 수량이 정상 반영됐는지 확인하세요. diff --git a/app/Docs/manual/30_inventory.md b/app/Docs/manual/30_inventory.md new file mode 100644 index 0000000..d07d7f8 --- /dev/null +++ b/app/Docs/manual/30_inventory.md @@ -0,0 +1,39 @@ +# 재고 · 실사 + +현재 보유 봉투 수량을 확인하고, 정기적으로 실사를 통해 실제 수량과 맞추는 단계입니다. + +## 재고 현황 + +**재고 관리 › 재고 현황** + +- 품목별·상태별 현재 재고를 조회합니다. +- 지자체·봉투 종류 등으로 필터링할 수 있습니다. +- **엑셀 내보내기**로 목록을 저장할 수 있습니다. + +| 항목 | 설명 | +|---|---| +| 품목 | 봉투 종류·용량 | +| 재고 수량 | 입고 − (판매 + 불출 + 파기) | +| 상태 | 재고/판매 등 단위별 상태 | + +## 실사 (재고 조사) + +장부상 재고와 실제 창고 수량을 맞추는 작업입니다. 다음 순서로 진행합니다. + +``` +실사 선별 ─→ 실사 등록(작업) ─→ 적용 +``` + +| 단계 | 메뉴 | 하는 일 | +|---|---|---| +| ① 선별 | 실사 선별 조회 | 실사 대상 범위를 골라 선별 | +| ② 작업 | 실사 선별 관리 | 실제 수량을 입력·기록 | +| ③ 상세·적용 | 실사 상세 | 실사 결과를 확인하고 재고에 **적용** | + +### 실사 진행 순서 + +1. **선별**: 실사할 품목·구간을 선택해 실사 건을 만듭니다. +2. **등록**: 실제 센 수량을 입력합니다. 장부 수량과의 차이가 표시됩니다. +3. **적용**: 검토 후 적용하면 차이가 재고에 반영됩니다. + +> 적용 전까지는 재고에 영향을 주지 않으므로, 입력 도중 중단해도 안전합니다. diff --git a/app/Docs/manual/40_sales_issue.md b/app/Docs/manual/40_sales_issue.md new file mode 100644 index 0000000..a1b48c7 --- /dev/null +++ b/app/Docs/manual/40_sales_issue.md @@ -0,0 +1,46 @@ +# 판매 · 불출 + +재고를 외부로 내보내는 두 가지 경로입니다. **판매**는 지정판매소 대상 유상 거래, **불출**은 무료 대상자에 대한 무상 지급입니다. + +## 판매 (지정판매소) + +**판매 관리** 메뉴에서 처리합니다. + +| 작업 | 메뉴 | 설명 | +|---|---|---| +| 판매 등록 | 지정 판매소 판매 | 판매소를 선택해 판매 거래 기록 | +| 스캔 판매 | 지정 판매소 판매[스캔] | 바코드를 스캔하며 판매(판매소 현장용) | +| 판매 취소 | 지정 판매소 판매 취소 | 기존 판매 거래 취소 | +| 반품 | 지정 판매소 반품 | 판매분 반품 등록 | +| 반품 취소 | 지정 판매소 반품 취소 | 반품 취소 처리 | + +### 판매 등록 순서 + +1. 판매할 **지정판매소**를 선택합니다. +2. 봉투 **품목·수량**을 입력(또는 바코드 스캔)합니다. +3. 저장하면 재고가 감소하고 판매 내역이 기록됩니다. + +### 전화 접수(주문) + +| 작업 | 메뉴 | +|---|---| +| 전화 접수(신규) | 전화 접수 | +| 전화 접수 관리 | 전화 접수 관리(수정·취소) | + +## 불출 (무료 대상자) + +**불출 관리** 메뉴에서 무료 대상자에게 봉투를 무상 지급합니다. + +| 작업 | 메뉴 | 설명 | +|---|---|---| +| 불출 처리 | 무료용 불출 처리 | 무료 배분 등록(재고 감소) | +| 불출 취소 | 무료용 불출 취소 | 불출 취소(재고 복원) | +| 불출 현황 | 무료 불출 현황 | 기간별 불출 기록 조회 | + +### 불출 처리 순서 + +1. 무료 **대상처**와 봉투 **품목·수량**을 선택합니다. +2. 저장하면 재고가 감소하고 불출 내역이 기록됩니다. +3. 잘못 처리한 경우 **무료용 불출 취소**로 되돌리면 재고가 복원됩니다. + +> 판매·불출 모두 재고를 감소시키므로, 처리 후 **재고 현황**에서 반영 결과를 확인하세요. diff --git a/app/Docs/manual/50_reports.md b/app/Docs/manual/50_reports.md new file mode 100644 index 0000000..96e6e81 --- /dev/null +++ b/app/Docs/manual/50_reports.md @@ -0,0 +1,42 @@ +# 판매현황 · 수불 · 통계 + +판매·재고 흐름을 집계해 보여주는 조회·리포트 화면 모음입니다. 대부분 **기간을 지정해 조회**하고 인쇄·엑셀로 내보낼 수 있습니다. + +## 판매 현황 + +| 메뉴 | 내용 | +|---|---| +| 지정 판매소 일/기간 판매대장 | 판매소별 일자·기간 거래 장부 | +| 일계표 | 하루 전체 판매 요약(일계 + 월 누계) | +| 기간별 판매현황 | 지정 기간의 판매 집계(일집계/기간집계) | +| 년 판매 현황 | 연간 판매 통계(월별/분기별) | +| 지정 판매소별 판매현황 | 판매소별 수량·금액 비교 | +| 홈텍스 처리 | 세금계산서용 데이터(엑셀) 생성 | + +## 봉투 수불 관리 + +입고·판매·불출·반품·파기를 한데 모아 **수불(수입·불출)** 흐름을 봅니다. + +| 메뉴 | 내용 | +|---|---| +| 기간별 봉투 수불 현황 | 기간 내 재고/입고/판매/불출 종합(엑셀 가능) | +| 기타 입출고 | 손상·기증·폐기 등 기타 입출고 등록·조회 | +| 반품/파기 현황 | 반품 및 파기 내역 | +| LOT 수불 조회 | 봉투번호(바코드)·LOT 단위 이력 추적 | +| 쓰레기 봉투 수급 계획 | 공급·수요 계획 | + +### LOT 수불 조회 사용법 + +1. **봉투번호(바코드)** 또는 LOT 번호를 입력합니다. +2. 조회하면 해당 단위의 입고·판매·반품 이력이 표시됩니다. +3. 입력할 코드 형식이 헷갈리면 **도움말 › 번호알기**로 먼저 확인하세요. + +## 통계 분석 + +| 메뉴 | 내용 | +|---|---| +| 전년 대비 판매 분석 | 작년 동기 대비 비교(차트) | +| 월별 판매 추이 분석 | 월별 추이 시각화 | +| 계절별 판매 추이 분석 | 계절 패턴 분석 | + +> 리포트 화면은 조회 조건(기간·품목·판매소)을 바꿔가며 반복 조회할 수 있습니다. 결과는 인쇄 버튼 또는 엑셀 내보내기로 저장하세요. diff --git a/app/Docs/manual/90_code_system.md b/app/Docs/manual/90_code_system.md new file mode 100644 index 0000000..cc3fbca --- /dev/null +++ b/app/Docs/manual/90_code_system.md @@ -0,0 +1,57 @@ +# 봉투 · LOT · 바코드 코드체계 + +봉투번호(바코드)·LOT 번호·품목코드가 무엇을 뜻하는지 정리한 안내입니다. LOT 수불 조회나 번호알기 화면에서 코드를 입력할 때 참고하세요. + +## 추적 단위 계층 + +``` +LOT(발주 묶음) ─→ 박스(B) ─→ 팩(P) ─→ 낱장(S, 봉투 한 장) +``` + +| 단위 | 예시 | 용도 | +|---|---|---| +| LOT | `OQXCKH` | 발주 단위, LOT 수불·시드 | +| 박스 | `OQXCKH-000008-B001` | 박스 단위 입출고 | +| 팩 | `OQXCKH-000008-P299` | 팩 단위(스캔 가능) | +| 낱장 | `OQXCKH-000008-P299-S00125` | 봉투 한 장 단위 판매·반품 | + +> `000008`은 "8번째 봉투"가 아니라 **입고 건 번호를 6자리로 채운 값**입니다. + +## 바코드 형식 읽는 법 + +`OQXCKH-000008-P299-S00125` 를 예로 들면: + +| 구간 | 값 | 의미 | +|---|---|---| +| 접두 | `OQXCKH` | 발주 LOT 번호 | +| 입고번호 | `000008` | 입고 건 번호(6자리) | +| 팩 | `P299` | 그 입고 건의 299번째 팩 | +| 낱장 | `S00125` | 그 팩의 125번째 낱장(봉투 한 장) | + +- 박스는 `B001`, 팩은 `P299` 처럼 접두 문자로 구분합니다. +- LOT 번호는 발주 시 자동 부여되는 영문·숫자 6자리입니다. + +## 품목코드 (봉투 종류) + +품목코드는 봉투의 **종류·용량**을 식별하는 마스터 코드로, 바코드와는 별개입니다. + +| 자리 | 의미 | 예 | +|---|---|---| +| 앞 2자리 | 봉투 구분(10 일반, 20 공공, 30 무료 …) | `10` | +| 다음 2자리 | 용량 | `15` | +| 마지막 1자리 | 재질 등 | `2` | + +**예:** `10152` = 일반용 20L. 같은 품목이라도 발주·입고 건마다 바코드(LOT) 접두는 달라집니다. + +## 번호알기로 확인하기 + +코드 한 건이 어떤 바코드·인쇄숫자·인식번호인지 확인하려면 **도움말 › 번호알기(봉투번호확인)** 화면에 코드를 입력하세요. + +입력 가능한 형식: + +| 단위 | 입력 형식 | 예시 | +|---|---|---| +| 낱장 | `{LOT}-{입고번호}-P{팩}-S{낱장}` | `OQXCKH-000008-P299-S00125` | +| 팩 | `{LOT}-{입고번호}-P{팩}` | `OQXCKH-000008-P299` | +| 박스 | `{LOT}-{입고번호}-B{박스}` | `OQXCKH-000008-B001` | +| LOT | 영문·숫자 4~8자리 | `OQXCKH` | diff --git a/app/Docs/manual/99_faq.md b/app/Docs/manual/99_faq.md new file mode 100644 index 0000000..f7c653a --- /dev/null +++ b/app/Docs/manual/99_faq.md @@ -0,0 +1,29 @@ +# 자주 묻는 질문 · 문의 + +## 자주 묻는 질문 + +### Q. 로그인 후 업무 화면이 안 열려요. +슈퍼 관리자는 **작업할 지자체를 먼저 선택**해야 합니다. 상단 안내에 따라 지자체를 선택하세요. 일반/판매소 계정은 권한 범위 내 메뉴만 보입니다. + +### Q. 메뉴가 안 보여요. +역할(권한)에 따라 노출 메뉴가 다릅니다. **시작하기 › 역할별 접근 한눈에 보기** 표를 확인하세요. 그래도 필요한 메뉴가 없으면 관리자에게 문의하세요. + +### Q. 입고했는데 재고에 안 보여요. +입고가 정상 저장됐는지 **발주 입고 관리 › 입고 현황**에서 확인하고, **재고 관리 › 재고 현황**에서 품목·지자체 필터를 점검하세요. + +### Q. 판매/불출을 잘못 처리했어요. +- 판매: **지정 판매소 판매 취소** 또는 **반품**으로 되돌립니다. +- 불출: **무료용 불출 취소**로 되돌리면 재고가 복원됩니다. + +### Q. 봉투 코드(바코드)가 무슨 뜻인지 모르겠어요. +**도움말 › 번호알기(봉투번호확인)** 에 코드를 입력하면 바코드·인쇄숫자·인식번호로 분해해 보여줍니다. 형식은 **봉투·LOT·바코드 코드체계** 문서를 참고하세요. + +### Q. 비밀번호를 바꾸고 싶어요. +**기본정보관리 › PASSWORD 변경** 에서 변경할 수 있습니다. + +### Q. 리포트를 엑셀/인쇄로 저장할 수 있나요? +대부분의 현황·리포트 화면에 **엑셀 내보내기**와 **인쇄** 기능이 있습니다. 이 매뉴얼 화면도 우측 상단 **인쇄** 버튼으로 출력할 수 있습니다. + +## 문의 + +시스템 사용 중 문제가 있으면 시스템 운영 담당자 또는 소속 지자체 관리자에게 문의하세요. diff --git a/app/Helpers/admin_helper.php b/app/Helpers/admin_helper.php index 71c95e1..e638db4 100644 --- a/app/Helpers/admin_helper.php +++ b/app/Helpers/admin_helper.php @@ -594,3 +594,345 @@ if (! function_exists('session_user_nav_display')) { ]; } } + +if (! function_exists('gov_portal_active_variant')) { + /** + * 공공 포털 시안 변형: base(좌측 MY MENU) | strip(호버 상단 메뉴). + */ + function gov_portal_active_variant(?string $fromPath = null): string + { + $path = strtolower(ltrim($fromPath ?? current_nav_request_path(), '/')); + + return str_starts_with($path, 'dashboard/gov-portal-strip') ? 'strip' : 'base'; + } +} + +if (! function_exists('gov_portal_code_kinds_portal_path')) { + /** + * 포털 UI 기본 코드관리 시안 경로 (변형별). + */ + function gov_portal_code_kinds_portal_path(?string $variant = null): string + { + return gov_portal_active_variant($variant) === 'strip' + ? 'dashboard/gov-portal-strip/code-kinds' + : 'dashboard/gov-portal/code-kinds'; + } +} + +if (! function_exists('gov_portal_menu_href_remap')) { + /** + * gov-portal 상·좌측·드롭다운 메뉴 전용: 기본 코드관리 → 변형별 포털 시안 URL. + */ + function gov_portal_menu_href_remap(string $href, ?string $variant = null): string + { + if (strtolower(ltrim($href, '/')) !== 'bag/code-kinds') { + return $href; + } + + return gov_portal_code_kinds_portal_path($variant); + } +} + +if (! function_exists('gov_portal_nav_match_path')) { + /** + * 포털 시안 URL 접속 시 사이트 메뉴(mm_link) 활성 판별용. + */ + function gov_portal_nav_match_path(string $currentPath): string + { + $key = strtolower(ltrim($currentPath, '/')); + + return match ($key) { + 'dashboard/gov-portal/code-kinds', + 'dashboard/gov-portal-strip/code-kinds' => 'bag/code-kinds', + default => $currentPath, + }; + } +} + +if (! function_exists('gov_portal_dashboard_aliases')) { + /** + * 포털 대시보드 현재 경로·메뉴 활성 판별용 별칭. + * + * @return list + */ + function gov_portal_dashboard_aliases(): array + { + return [ + 'dashboard', + 'dashboard/blend', + 'dashboard/simple', + 'dashboard/lite', + 'dashboard/compact', + 'dashboard/dense', + 'dashboard/charts', + 'dashboard/modern', + 'dashboard/gov-portal', + 'dashboard/gov-portal-strip', + 'dashboard/gov-portal/code-kinds', + 'dashboard/gov-portal-strip/code-kinds', + ]; + } +} + +if (! function_exists('gov_portal_nav_context')) { + /** + * 공공 포털형 대시보드(gov-portal)용 사이트 메뉴 트리·JSON·활성 대메뉴 인덱스. + * + * @return array{ + * siteNavTree: array, + * navItems: list,hasChildren:bool}>, + * navJson: string, + * activeParentIdx: int, + * currentPath: string, + * dashboardAliases: list + * } + */ + function gov_portal_nav_context(): array + { + helper('url'); + + $tree = get_site_nav_tree(); + $rawPath = current_nav_request_path(); + $variant = gov_portal_active_variant($rawPath); + $currentPath = gov_portal_nav_match_path($rawPath); + $aliases = gov_portal_dashboard_aliases(); + $items = []; + $activeParentIdx = 0; + + foreach ($tree as $pIdx => $parent) { + $children = []; + foreach ($parent->children ?? [] as $child) { + $href = gov_portal_menu_href_remap( + menu_link_preferred_href_path($child->mm_link ?? null, $currentPath), + $variant + ); + $children[] = [ + 'idx' => (int) ($child->mm_idx ?? 0), + 'name' => (string) ($child->mm_name ?? ''), + 'href' => $href, + 'url' => $href !== '' ? base_url($href) : '', + ]; + } + + $parentHref = menu_link_preferred_href_path($parent->mm_link ?? null, $currentPath); + $isParentActive = site_nav_link_matches_current($parent->mm_link ?? null, $currentPath, $aliases); + $activeChild = menu_active_child_for_parent($parent, $currentPath, $aliases); + if ($isParentActive || $activeChild !== null) { + $activeParentIdx = (int) $pIdx; + } + + $items[] = [ + 'idx' => (int) ($parent->mm_idx ?? 0), + 'name' => (string) ($parent->mm_name ?? ''), + 'href' => $parentHref, + 'url' => $parentHref !== '' ? base_url($parentHref) : '', + 'children' => $children, + 'hasChildren' => $children !== [], + ]; + } + + return [ + 'siteNavTree' => $tree, + 'navItems' => $items, + 'navJson' => json_encode($items, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS), + 'activeParentIdx' => $activeParentIdx, + 'currentPath' => $rawPath, + 'dashboardAliases'=> $aliases, + ]; + } +} + +if (! function_exists('gov_portal_menu_search_options')) { + /** + * 메뉴검색 입력 아래 표시할 바로가기(사이트 메뉴에서 추출, 최대 N개). + * + * @param list $navItems gov_portal_nav_context()['navItems'] + * @return list + */ + function gov_portal_menu_search_options(array $navItems, int $max = 8): array + { + $picked = []; + $seen = []; + $prefer = ['재고', '발주', '수불', '판매', '통계', '실사', '발급', '주문', '코드', '지정판매', '구매']; + + $push = static function (array $child) use (&$picked, &$seen, $max): bool { + $href = (string) ($child['href'] ?? ''); + if ($href === '' || isset($seen[$href])) { + return false; + } + $seen[$href] = true; + $picked[] = [ + 'label' => (string) ($child['name'] ?? ''), + 'url' => (string) ($child['url'] ?? ''), + 'keyword' => (string) ($child['name'] ?? ''), + ]; + + return count($picked) >= $max; + }; + + foreach ($prefer as $needle) { + if (count($picked) >= $max) { + break; + } + foreach ($navItems as $parent) { + foreach ($parent['children'] ?? [] as $child) { + $name = (string) ($child['name'] ?? ''); + if ($name === '' || ! str_contains($name, $needle)) { + continue; + } + if ($push($child)) { + break 3; + } + break; + } + if (count($picked) >= $max) { + break; + } + } + } + + foreach ($navItems as $parent) { + if (count($picked) >= $max) { + break; + } + foreach ($parent['children'] ?? [] as $child) { + if ($push($child)) { + break 2; + } + } + } + + return $picked; + } +} + +if (! function_exists('gov_portal_dashboard_view_data')) { + /** + * 공공 포털형 대시보드(gov-portal / gov-portal-strip) 뷰 데이터. + * 컨트롤러에서 view() 두 번째 인자로 전달한다. + * + * @return array + */ + function gov_portal_dashboard_view_data(string $lgLabel, string $activeVariant): array + { + helper('url'); + $govNav = gov_portal_nav_context(); + + return [ + 'lgLabel' => $lgLabel, + 'activeVariant' => $activeVariant, + 'mbName' => (string) (session()->get('mb_name') ?? '담당자'), + 'mbId' => (string) (session()->get('mb_id') ?? ''), + 'levelName' => config('Roles')->getLevelName((int) session()->get('mb_level')), + 'weeklyRequests' => [7, 12, 9, 14, 8, 11, 10], + 'stockMix' => [ + ['name' => '일반용', 'value' => 52, 'color' => '#3b82f6'], + ['name' => '음식물', 'value' => 28, 'color' => '#10b981'], + ['name' => '특수', 'value' => 20, 'color' => '#f59e0b'], + ], + 'lowStock' => [ + ['name' => '일반 20L', 'percent' => 34], + ['name' => '특수규격 A', 'percent' => 22], + ['name' => '재사용봉투', 'percent' => 58], + ], + 'stockAlerts' => [ + [ + 'count' => 18, + 'label' => '정상', + 'class' => 'al-blue', + 'bags' => ['일반 10L', '일반 20L', '음식물 2L', '음식물 5L'], + ], + [ + 'count' => 3, + 'label' => '주의', + 'class' => 'al-yellow', + 'bags' => ['일반 50L', '특수 소형', '음식물 10L'], + ], + [ + 'count' => 2, + 'label' => '경계', + 'class' => 'al-orange', + 'bags' => ['특수규격 A', '재사용봉투'], + ], + [ + 'count' => 1, + 'label' => '부족', + 'class' => 'al-red', + 'bags' => ['일반 20L'], + ], + ], + 'quickLinks' => [ + ['label' => '기본 코드관리', 'desc' => '포털 UI · 코드 종류·세부코드', 'url' => base_url(gov_portal_code_kinds_portal_path($activeVariant)), 'icon' => 'fa-code'], + ['label' => '창고 재고 조회', 'desc' => '품목별 현재 재고', 'url' => base_url('bag/inventory'), 'icon' => 'fa-boxes-stacked'], + ['label' => '발주(구매신청) 등록', 'desc' => '지정판매소 발주 입력', 'url' => base_url('bag/order/create'), 'icon' => 'fa-cart-shopping'], + ['label' => '수불 흐름 보기', 'desc' => '입고·출고 내역', 'url' => base_url('bag/flow'), 'icon' => 'fa-arrow-right-arrow-left'], + ['label' => '판매 내역 조회', 'desc' => '기간별 판매 현황', 'url' => base_url('bag/sales'), 'icon' => 'fa-receipt'], + ['label' => '전년 대비 통계', 'desc' => '통계분석 · YoY', 'url' => base_url('bag/analytics/year-over-year'), 'icon' => 'fa-chart-line'], + ['label' => '도움말 / 매뉴얼', 'desc' => '업무별 사용 안내', 'url' => base_url('bag/help'), 'icon' => 'fa-circle-question'], + ], + 'notices' => [ + ['title' => '봉투 단가 조정 예고 — 3/1 적용 예정', 'date' => '2026.05.12'], + ['title' => '실사·재고 점검 일정 안내', 'date' => '2026.05.08'], + ['title' => '지정판매소 바코드 연동 점검 완료', 'date' => '2026.04.29'], + ], + 'timeline' => [ + ['time' => '14:32', 'text' => 'GS25 검단점 — 일반 20L 판매 3건'], + ['time' => '13:10', 'text' => '북구 창고 — 입고 확인 완료'], + ['time' => '11:45', 'text' => '구매신청 #1042 승인 대기'], + ['time' => '09:20', 'text' => '회원 가입 승인 1건 처리'], + ], + 'govMapPanel' => [ + 'centerLat' => 35.8714, + 'centerLng' => 128.6014, + 'zoom' => 11, + 'markers' => [ + ['lat' => 35.8852, 'lng' => 128.5821, 'kind' => 'warehouse', 'title' => '북구 종량제 창고'], + ['lat' => 35.8684, 'lng' => 128.6243, 'kind' => 'shop', 'title' => 'GS25 검단점'], + ['lat' => 35.8621, 'lng' => 128.5948, 'kind' => 'shop', 'title' => 'CU 칠곡중앙점'], + ['lat' => 35.8776, 'lng' => 128.6479, 'kind' => 'flow', 'title' => '동구 입고·출고 거점'], + ['lat' => 35.8512, 'lng' => 128.6112, 'kind' => 'shop', 'title' => '이마트 월배점'], + ], + ], + 'portalVariants' => [ + ['label' => '기본', 'url' => base_url('dashboard/gov-portal')], + ['label' => '변형', 'url' => base_url('dashboard/gov-portal-strip')], + ], + 'siteNavTree' => $govNav['siteNavTree'], + 'govNavItems' => $govNav['navItems'], + 'menuSearchOptions' => gov_portal_menu_search_options($govNav['navItems']), + 'govNavJson' => $govNav['navJson'], + 'govActiveParentIdx' => $govNav['activeParentIdx'], + 'govCurrentPath' => gov_portal_nav_match_path($govNav['currentPath']), + 'govDashboardAliases' => $govNav['dashboardAliases'], + ]; + } +} + +if (! function_exists('gov_portal_nav_partial_vars')) { + /** + * CI4 view() partial에 넘길 사이트 메뉴·네비 변수만 추출. + * + * @param array $viewData + * @return array + */ + function gov_portal_nav_partial_vars(array $viewData): array + { + $keys = [ + 'siteNavTree', + 'govNavItems', + 'govNavJson', + 'govActiveParentIdx', + 'govCurrentPath', + 'govDashboardAliases', + 'govActiveChildHref', + ]; + $out = []; + foreach ($keys as $key) { + if (array_key_exists($key, $viewData)) { + $out[$key] = $viewData[$key]; + } + } + + return $out; + } +} diff --git a/app/Libraries/BagAnalyticsReportBuilder.php b/app/Libraries/BagAnalyticsReportBuilder.php index 41afcf7..71e6cc9 100644 --- a/app/Libraries/BagAnalyticsReportBuilder.php +++ b/app/Libraries/BagAnalyticsReportBuilder.php @@ -6,6 +6,8 @@ namespace App\Libraries; /** * 통계 분석 관리 (전년대비 / 월별·계절별 추이) + * + * 월별·계절별 추이·전년대비: bs_type = sale 판매량·판매금액만 집계 (반품·취소 제외) */ class BagAnalyticsReportBuilder { @@ -14,6 +16,12 @@ class BagAnalyticsReportBuilder /** @var array */ private array $bagNames = []; + /** 판매(bs_type=sale) 낱장 수량만 합산 */ + private function saleQtySql(): string + { + return "CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) ELSE 0 END"; + } + /** * @return array, cross_year: bool}> */ @@ -336,14 +344,11 @@ class BagAnalyticsReportBuilder string $gugunCode, int $dsIdx ): array { + $saleQty = $this->saleQtySql(); $sql = " SELECT bs.bs_bag_code AS bag_code, YEAR(bs.bs_sale_date) AS y, MONTH(bs.bs_sale_date) AS m, - SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) - WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty) - ELSE 0 END) AS net_qty, - SUM(CASE WHEN bs.bs_type = 'sale' THEN bs.bs_amount - WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_amount) - ELSE 0 END) AS net_amt + SUM({$saleQty}) AS sale_qty, + SUM(CASE WHEN bs.bs_type = 'sale' THEN bs.bs_amount ELSE 0 END) AS sale_amt FROM bag_sale bs INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx WHERE bs.bs_lg_idx = ? @@ -369,8 +374,8 @@ class BagAnalyticsReportBuilder continue; } $agg[$code][$y][$m] = [ - 'qty' => (float) ($row['net_qty'] ?? 0), - 'amt' => (float) ($row['net_amt'] ?? 0), + 'qty' => (float) ($row['sale_qty'] ?? 0), + 'amt' => (float) ($row['sale_amt'] ?? 0), ]; } @@ -515,11 +520,10 @@ class BagAnalyticsReportBuilder private function monthlyNetByShop(int $lgIdx, int $year, int $month, string $gugunCode, int $saIdx = 0): array { $hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop'); + $saleQty = $this->saleQtySql(); $sql = " SELECT bs.bs_ds_idx AS ds_idx, - SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) - WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty) - ELSE 0 END) AS net_qty + SUM({$saleQty}) AS sale_qty FROM bag_sale bs INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) = ? @@ -538,7 +542,7 @@ class BagAnalyticsReportBuilder $map = []; foreach ($this->db->query($sql, $params)->getResultArray() as $row) { - $map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['net_qty'] ?? 0); + $map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['sale_qty'] ?? 0); } return $map; @@ -560,11 +564,10 @@ class BagAnalyticsReportBuilder } $hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop'); + $saleQty = $this->saleQtySql(); $sql = " SELECT bs.bs_ds_idx AS ds_idx, - SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) - WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty) - ELSE 0 END) / 12 AS avg_qty + SUM({$saleQty}) / 12 AS avg_qty FROM bag_sale bs INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? @@ -606,15 +609,13 @@ class BagAnalyticsReportBuilder } $divisor = count($months); - $qtyExpr = "CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) - WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty) - ELSE 0 END"; + $saleQty = $this->saleQtySql(); $hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop'); if ($crossYearWinter) { $sql = " SELECT bs.bs_ds_idx AS ds_idx, - SUM({$qtyExpr}) / ? AS avg_qty + SUM({$saleQty}) / ? AS avg_qty FROM bag_sale bs INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx WHERE bs.bs_lg_idx = ? @@ -629,7 +630,7 @@ class BagAnalyticsReportBuilder $placeholders = implode(',', array_fill(0, count($months), '?')); $sql = " SELECT bs.bs_ds_idx AS ds_idx, - SUM({$qtyExpr}) / ? AS avg_qty + SUM({$saleQty}) / ? AS avg_qty FROM bag_sale bs INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? diff --git a/app/Libraries/BagLotFlowBuilder.php b/app/Libraries/BagLotFlowBuilder.php index 7b7fa7b..5ccf22b 100644 --- a/app/Libraries/BagLotFlowBuilder.php +++ b/app/Libraries/BagLotFlowBuilder.php @@ -202,11 +202,21 @@ class BagLotFlowBuilder return $this->resolvedFromPackRow($barcode, '박스', $first, 1, count($boxRows), $sheetQty); } + $exactSheet = $this->db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_sheet_start_code', $barcode) + ->limit(1) + ->get() + ->getRowArray(); + if (is_array($exactSheet) && $exactSheet !== []) { + return $this->resolvedFromPackRow($barcode, '낱장', $exactSheet, 0, 0, 1); + } + $sheetRows = $this->db->table('bag_receiving_pack_code') ->where('brpc_lg_idx', $lgIdx) - ->where('brpc_sheet_start_code <=', $barcode) - ->where('brpc_sheet_end_code >=', $barcode) - ->limit(50) + ->where('brpc_sheet_start_code !=', '') + ->where('brpc_sheet_end_code !=', '') + ->limit(200) ->get() ->getResultArray(); foreach ($sheetRows as $row) { @@ -217,9 +227,109 @@ class BagLotFlowBuilder } } + $fromScan = $this->resolveBarcodeFromScanTables($lgIdx, $barcode); + if ($fromScan !== null) { + return $fromScan; + } + return ['ok' => false, 'message' => '등록되지 않은 바코드입니다.']; } + /** + * 판매·반품 스캔에만 있는 낱장 코드(입고 팩 테이블 미등록) 조회 + * + * @return array{ok: true, barcode: string, unit: string, bag_code: string, bag_name: string, lot_no: string, box_code: string, pack_code: string, pack_ids: list, qty_box: int, qty_pack: int, qty_sheet: int}|null + */ + private function resolveBarcodeFromScanTables(int $lgIdx, string $barcode): ?array + { + $bagCode = ''; + $bagName = ''; + if ($this->db->tableExists('bag_sale_scan_code')) { + $sale = $this->db->table('bag_sale_scan_code') + ->select('bssc_bag_code, bssc_bag_name') + ->where('bssc_lg_idx', $lgIdx) + ->where('bssc_code', $barcode) + ->orderBy('bssc_regdate', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + if (is_array($sale) && $sale !== []) { + $bagCode = (string) ($sale['bssc_bag_code'] ?? ''); + $bagName = (string) ($sale['bssc_bag_name'] ?? ''); + } + } + if ($bagCode === '' && $this->db->tableExists('bag_return_scan_code')) { + $ret = $this->db->table('bag_return_scan_code') + ->select('brsc_bag_code, brsc_bag_name') + ->where('brsc_lg_idx', $lgIdx) + ->where('brsc_code', $barcode) + ->orderBy('brsc_regdate', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + if (is_array($ret) && $ret !== []) { + $bagCode = (string) ($ret['brsc_bag_code'] ?? ''); + $bagName = (string) ($ret['brsc_bag_name'] ?? ''); + } + } + if ($bagCode === '' && $bagName === '') { + return null; + } + + $packRow = $this->findPackRowContainingSheet($lgIdx, $barcode); + + return [ + 'ok' => true, + 'barcode' => $barcode, + 'unit' => '낱장', + 'bag_code' => $bagCode, + 'bag_name' => $bagName, + 'lot_no' => (string) ($packRow['brpc_lot_no'] ?? ''), + 'box_code' => (string) ($packRow['brpc_box_code'] ?? ''), + 'pack_code' => (string) ($packRow['brpc_pack_code'] ?? ''), + 'pack_ids' => isset($packRow['brpc_idx']) ? [(int) $packRow['brpc_idx']] : [], + 'qty_box' => 0, + 'qty_pack' => 0, + 'qty_sheet' => 1, + ]; + } + + /** + * @return array + */ + private function findPackRowContainingSheet(int $lgIdx, string $barcode): array + { + if (! $this->db->tableExists('bag_receiving_pack_code')) { + return []; + } + + $exact = $this->db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_sheet_start_code', $barcode) + ->limit(1) + ->get() + ->getRowArray(); + if (is_array($exact) && $exact !== []) { + return $exact; + } + + foreach ($this->db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_sheet_start_code !=', '') + ->where('brpc_sheet_end_code !=', '') + ->limit(200) + ->get() + ->getResultArray() as $row) { + $start = (string) ($row['brpc_sheet_start_code'] ?? ''); + $end = (string) ($row['brpc_sheet_end_code'] ?? ''); + if ($this->barcodeInRange($barcode, $start, $end)) { + return $row; + } + } + + return []; + } + /** * @param array $pack * @return array{ok: true, barcode: string, unit: string, bag_code: string, bag_name: string, lot_no: string, box_code: string, pack_code: string, pack_ids: list, qty_box: int, qty_pack: int, qty_sheet: int} @@ -247,30 +357,62 @@ class BagLotFlowBuilder * @return list */ private function collectFlowRows(int $lgIdx, array $resolved): array + { + $unit = (string) ($resolved['unit'] ?? ''); + + return match ($unit) { + '낱장' => $this->collectFlowRowsForSheet($lgIdx, $resolved), + '박스' => $this->collectFlowRowsForBox($lgIdx, $resolved), + default => $this->collectFlowRowsForPack($lgIdx, $resolved), + }; + } + + /** + * 낱장: 해당 바코드 판매·반품만 + 소속 팩 입고 1건(발주·동일 LOT 전체 이력 제외) + * + * @param array $resolved + * @return list + */ + private function collectFlowRowsForSheet(int $lgIdx, array $resolved): array { $rows = []; - $codes = [$resolved['barcode']]; - if (($resolved['pack_code'] ?? '') !== '' && ! in_array($resolved['pack_code'], $codes, true)) { - $codes[] = $resolved['pack_code']; - } - if (($resolved['box_code'] ?? '') !== '' && ! in_array($resolved['box_code'], $codes, true)) { - $codes[] = $resolved['box_code']; + $barcode = trim((string) ($resolved['barcode'] ?? '')); + if ($barcode === '') { + return []; } - $brIdx = 0; - if ($this->db->tableExists('bag_receiving_pack_code')) { - $packCode = (string) ($resolved['pack_code'] ?? ''); - if ($packCode !== '') { - $p = $this->db->table('bag_receiving_pack_code') - ->select('brpc_br_idx') - ->where('brpc_lg_idx', $lgIdx) - ->where('brpc_pack_code', $packCode) - ->get() - ->getRowArray(); - $brIdx = (int) ($p['brpc_br_idx'] ?? 0); + $brIdx = $this->receivingBrIdxForPackCode($lgIdx, (string) ($resolved['pack_code'] ?? '')); + if ($brIdx > 0) { + foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) { + $rows[] = $ev; } } + foreach ($this->loadScanEventsForCodes($lgIdx, [$barcode]) as $ev) { + $rows[] = $ev; + } + foreach ($this->loadReturnEventsForCodes($lgIdx, [$barcode]) as $ev) { + $rows[] = $ev; + } + + return $rows; + } + + /** + * 팩: 팩·낱장 바코드 스캔 + 입고 + LOT 발주 + * + * @param array $resolved + * @return list + */ + private function collectFlowRowsForPack(int $lgIdx, array $resolved): array + { + $rows = []; + $codes = array_values(array_unique(array_filter([ + (string) ($resolved['barcode'] ?? ''), + (string) ($resolved['pack_code'] ?? ''), + ], static fn (string $c): bool => $c !== ''))); + + $brIdx = $this->receivingBrIdxForPackCode($lgIdx, (string) ($resolved['pack_code'] ?? '')); if ($brIdx > 0) { foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) { $rows[] = $ev; @@ -294,6 +436,91 @@ class BagLotFlowBuilder return $rows; } + /** + * 박스: 박스·소속 팩 코드 스캔 + 입고(박스 내 팩) + LOT 발주 + * + * @param array $resolved + * @return list + */ + private function collectFlowRowsForBox(int $lgIdx, array $resolved): array + { + $rows = []; + $boxCode = (string) ($resolved['box_code'] ?? ''); + $codes = array_values(array_unique(array_filter([ + (string) ($resolved['barcode'] ?? ''), + $boxCode, + (string) ($resolved['pack_code'] ?? ''), + ], static fn (string $c): bool => $c !== ''))); + + if ($boxCode !== '' && $this->db->tableExists('bag_receiving_pack_code')) { + $packCodes = $this->db->table('bag_receiving_pack_code') + ->select('brpc_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_box_code', $boxCode) + ->get() + ->getResultArray(); + foreach ($packCodes as $p) { + $pc = (string) ($p['brpc_pack_code'] ?? ''); + if ($pc !== '' && ! in_array($pc, $codes, true)) { + $codes[] = $pc; + } + } + } + + $seenBr = []; + if ($boxCode !== '' && $this->db->tableExists('bag_receiving_pack_code')) { + $brRows = $this->db->table('bag_receiving_pack_code') + ->select('brpc_br_idx') + ->distinct() + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_box_code', $boxCode) + ->get() + ->getResultArray(); + foreach ($brRows as $brRow) { + $brIdx = (int) ($brRow['brpc_br_idx'] ?? 0); + if ($brIdx <= 0 || isset($seenBr[$brIdx])) { + continue; + } + $seenBr[$brIdx] = true; + foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) { + $rows[] = $ev; + } + } + } + + foreach ($this->loadScanEventsForCodes($lgIdx, $codes) as $ev) { + $rows[] = $ev; + } + foreach ($this->loadReturnEventsForCodes($lgIdx, $codes) as $ev) { + $rows[] = $ev; + } + + $lotNo = (string) ($resolved['lot_no'] ?? ''); + if ($lotNo !== '') { + foreach ($this->loadOrderEventsForLot($lgIdx, $lotNo) as $ev) { + $rows[] = $ev; + } + } + + return $rows; + } + + private function receivingBrIdxForPackCode(int $lgIdx, string $packCode): int + { + if ($packCode === '' || ! $this->db->tableExists('bag_receiving_pack_code')) { + return 0; + } + + $p = $this->db->table('bag_receiving_pack_code') + ->select('brpc_br_idx') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_pack_code', $packCode) + ->get() + ->getRowArray(); + + return (int) ($p['brpc_br_idx'] ?? 0); + } + /** * @return list */ diff --git a/app/Libraries/BagNumberLookup.php b/app/Libraries/BagNumberLookup.php new file mode 100644 index 0000000..f26baef --- /dev/null +++ b/app/Libraries/BagNumberLookup.php @@ -0,0 +1,225 @@ +fail('코드를 입력해 주세요.', $input); + } + + $normalized = strtoupper(preg_replace('/\s+/', '', $input) ?? $input); + $parsed = $this->parseStructuredCode($normalized); + + if ($parsed === null && $lgIdx !== null) { + $parsed = $this->resolveFromDatabase($lgIdx, $normalized); + } + + if ($parsed === null) { + return $this->fail('인식할 수 없는 코드입니다. 봉투 바코드(LOT-입고번호-팩/박스-낱장) 형식을 확인해 주세요.', $input); + } + + return [ + 'ok' => true, + 'message' => '', + 'input' => $input, + 'barcode_text' => $this->formatRow($parsed['barcode'], 4), + 'print_text' => $this->formatRow($parsed['print'], 3), + 'recognition_text' => $this->formatRow($parsed['recognition'], 2), + 'unit' => (string) ($parsed['unit'] ?? ''), + 'bag_code' => (string) ($parsed['bag_code'] ?? ''), + 'bag_name' => (string) ($parsed['bag_name'] ?? ''), + ]; + } + + /** + * @param list $parts + */ + private function formatRow(array $parts, int $slots): string + { + $cells = array_pad($parts, $slots, '-'); + foreach ($cells as $i => $cell) { + if ($cell === '' || $cell === null) { + $cells[$i] = '-'; + } + } + + return implode(' ', $cells); + } + + /** + * @return array{barcode:list,print:list,recognition:list,unit:string,bag_code?:string,bag_name?:string}|null + */ + private function parseStructuredCode(string $code): ?array + { + if (preg_match('/^([A-Z0-9]+)-(\d{6})-P(\d{3})-S(\d+)$/i', $code, $m) === 1) { + $sheet = str_pad((string) $m[4], 5, '0', STR_PAD_LEFT); + + return [ + 'barcode' => [(string) $m[1], (string) $m[2], 'P' . $m[3], 'S' . $sheet], + 'print' => [(string) (int) $m[2], (string) (int) $m[3], (string) (int) $sheet], + 'recognition' => [(string) $m[2], 'P' . $m[3]], + 'unit' => '낱장', + ]; + } + + if (preg_match('/^([A-Z0-9]+)-(\d{6})-P(\d{3})$/i', $code, $m) === 1) { + return [ + 'barcode' => [(string) $m[1], (string) $m[2], 'P' . $m[3], '-'], + 'print' => [(string) (int) $m[2], (string) (int) $m[3], '-'], + 'recognition' => [(string) $m[2], 'P' . $m[3]], + 'unit' => '팩', + ]; + } + + if (preg_match('/^([A-Z0-9]+)-(\d{6})-B(\d{3})$/i', $code, $m) === 1) { + return [ + 'barcode' => [(string) $m[1], (string) $m[2], 'B' . $m[3], '-'], + 'print' => [(string) (int) $m[2], (string) (int) $m[3], '-'], + 'recognition' => [(string) $m[2], 'B' . $m[3]], + 'unit' => '박스', + ]; + } + + if (preg_match('/^([A-Z0-9]{4,8})$/i', $code) === 1) { + return [ + 'barcode' => [$code, '-', '-', '-'], + 'print' => ['-', '-', '-'], + 'recognition' => [$code, '-'], + 'unit' => 'LOT', + ]; + } + + return null; + } + + /** + * @return array{barcode:list,print:list,recognition:list,unit:string,bag_code?:string,bag_name?:string}|null + */ + private function resolveFromDatabase(int $lgIdx, string $code): ?array + { + $db = \Config\Database::connect(); + if (! $db->tableExists('bag_receiving_pack_code')) { + return null; + } + + $row = $db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_pack_code', $code) + ->get() + ->getRowArray(); + if (is_array($row) && $row !== []) { + return $this->parsedFromPackRow($code, $row, '팩'); + } + + $boxRows = $db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_box_code', $code) + ->limit(1) + ->get() + ->getRowArray(); + if (is_array($boxRows) && $boxRows !== []) { + return $this->parsedFromPackRow($code, $boxRows, '박스'); + } + + $sheetRow = $db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_sheet_start_code <=', $code) + ->where('brpc_sheet_end_code >=', $code) + ->limit(1) + ->get() + ->getRowArray(); + if (is_array($sheetRow) && $sheetRow !== []) { + $parsed = $this->parseStructuredCode($code); + if ($parsed !== null) { + $parsed['bag_code'] = (string) ($sheetRow['brpc_bag_code'] ?? ''); + $parsed['bag_name'] = (string) ($sheetRow['brpc_bag_name'] ?? ''); + + return $parsed; + } + } + + $exactSheet = $db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->groupStart() + ->where('brpc_sheet_start_code', $code) + ->orWhere('brpc_sheet_end_code', $code) + ->groupEnd() + ->limit(1) + ->get() + ->getRowArray(); + if (is_array($exactSheet) && $exactSheet !== []) { + $parsed = $this->parseStructuredCode($code); + if ($parsed !== null) { + $parsed['bag_code'] = (string) ($exactSheet['brpc_bag_code'] ?? ''); + $parsed['bag_name'] = (string) ($exactSheet['brpc_bag_name'] ?? ''); + + return $parsed; + } + } + + return null; + } + + /** + * @param array $row + * @return array{barcode:list,print:list,recognition:list,unit:string,bag_code:string,bag_name:string} + */ + private function parsedFromPackRow(string $code, array $row, string $unit): array + { + $parsed = $this->parseStructuredCode($code); + if ($parsed === null) { + $parsed = [ + 'barcode' => [$code, '-', '-', '-'], + 'print' => ['-', '-', '-'], + 'recognition' => ['-', '-'], + 'unit' => $unit, + ]; + } + $parsed['unit'] = $unit; + $parsed['bag_code'] = (string) ($row['brpc_bag_code'] ?? ''); + $parsed['bag_name'] = (string) ($row['brpc_bag_name'] ?? ''); + + return $parsed; + } + + /** + * @return array{ok:bool,message:string,input:string,barcode_text:string,print_text:string,recognition_text:string,unit:string,bag_code:string,bag_name:string} + */ + private function fail(string $message, string $input): array + { + return [ + 'ok' => false, + 'message' => $message, + 'input' => $input, + 'barcode_text' => $this->formatRow([], 4), + 'print_text' => $this->formatRow([], 3), + 'recognition_text' => $this->formatRow([], 2), + 'unit' => '', + 'bag_code' => '', + 'bag_name' => '', + ]; + } +} diff --git a/app/Libraries/GovPortalCodeKindsPage.php b/app/Libraries/GovPortalCodeKindsPage.php new file mode 100644 index 0000000..6a499f2 --- /dev/null +++ b/app/Libraries/GovPortalCodeKindsPage.php @@ -0,0 +1,101 @@ + + */ + public function buildPageData(?int $lgIdx, int $level, ?int $adminLgIdx, int $selectedCkIdx, array $filters): array + { + $kindModel = model(CodeKindModel::class); + $detailModel = model(CodeDetailModel::class); + $kinds = []; + $countMap = []; + $selectedKind = null; + $detailList = []; + $rowCanEdit = []; + + $qCode = trim((string) ($filters['q_code'] ?? '')); + $qName = trim((string) ($filters['q_name'] ?? '')); + $qState = (string) ($filters['q_state'] ?? ''); + + try { + $builder = $kindModel->orderBy('ck_code', 'ASC'); + if ($qCode !== '') { + $builder->like('ck_code', $qCode); + } + if ($qName !== '') { + $builder->like('ck_name', $qName); + } + if ($qState === '1' || $qState === '0') { + $builder->where('ck_state', (int) $qState); + } + $kinds = $builder->findAll(); + foreach ($kinds as $row) { + $countMap[$row->ck_idx] = (int) $detailModel->where('cd_ck_idx', $row->ck_idx) + ->filterByTenantScope($lgIdx) + ->countAllResults(); + } + } catch (\Throwable $e) { + log_message('error', '[GovPortalCodeKinds] {type} {message}', [ + 'type' => $e::class, + 'message' => $e->getMessage(), + ]); + } + + $canManageKinds = Roles::canManageCodeKindMaster($level); + $canManageDetails = Roles::canManageCodeMaster($level); + + if ($kinds !== []) { + foreach ($kinds as $row) { + if ((int) $row->ck_idx === $selectedCkIdx) { + $selectedKind = $row; + break; + } + } + if ($selectedKind === null) { + $selectedKind = $kinds[0]; + } + } + + if ($selectedKind !== null) { + $detailList = $detailModel->where('cd_ck_idx', (int) $selectedKind->ck_idx) + ->filterByTenantScope($lgIdx) + ->orderBy('cd_sort', 'ASC') + ->orderBy('cd_code', 'ASC') + ->orderBy('cd_idx', 'ASC') + ->findAll(); + + foreach ($detailList as $row) { + $rowCanEdit[$row->cd_idx] = Roles::canEditCodeDetailRow($level, $row, $adminLgIdx); + } + } + + return [ + 'codeKinds' => $kinds, + 'countMap' => $countMap, + 'canManageKinds' => $canManageKinds, + 'canManageDetails' => $canManageDetails, + 'selectedKind' => $selectedKind, + 'detailList' => $detailList, + 'rowCanEdit' => $rowCanEdit, + 'totalCount' => count($kinds), + 'filters' => [ + 'q_code' => $qCode, + 'q_name' => $qName, + 'q_state' => $qState, + ], + ]; + } +} diff --git a/app/Libraries/ManualRenderer.php b/app/Libraries/ManualRenderer.php new file mode 100644 index 0000000..665d9a2 --- /dev/null +++ b/app/Libraries/ManualRenderer.php @@ -0,0 +1,111 @@ +config = $config ?? config(ManualConfig::class); + $this->converter = new GithubFlavoredMarkdownConverter([ + // 콘텐츠 저자는 신뢰되지만, 사고 방지를 위해 정책을 명시 고정한다. + 'html_input' => 'escape', + 'allow_unsafe_links' => false, + 'max_nesting_level' => 50, + ]); + } + + /** + * 목차(slug → title/file). 배열 순서가 노출 순서. + * + * @return array + */ + public function pages(): array + { + return $this->config->pages; + } + + /** 목차의 첫 번째 slug (기본 진입 페이지). */ + public function firstSlug(): string + { + return (string) (array_key_first($this->config->pages) ?? ''); + } + + /** + * slug 메타 조회. 화이트리스트에 없으면 null. + * + * @return array{title: string, file: string}|null + */ + public function find(string $slug): ?array + { + return $this->config->pages[$slug] ?? null; + } + + /** + * slug 페이지를 HTML 로 변환해 반환. 미등록 slug·파일 없음·변환 실패 시 null. + */ + public function render(string $slug): ?string + { + $page = $this->find($slug); + if ($page === null) { + return null; + } + + $path = $this->resolvePath($page['file']); + if ($path === null) { + return null; + } + + $markdown = @file_get_contents($path); + if ($markdown === false) { + return null; + } + + try { + return (string) $this->converter->convert($markdown); + } catch (CommonMarkException) { + return null; + } + } + + /** + * 파일명을 디렉터리 경계 안으로 안전하게 해석한다. + * manifest 의 고정 파일명만 받으며, realpath 가 $dir 하위인지 재검증한다. + */ + private function resolvePath(string $file): ?string + { + $baseReal = realpath(rtrim($this->config->dir, '/\\')); + if ($baseReal === false) { + return null; + } + + $candidate = realpath($baseReal . DIRECTORY_SEPARATOR . $file); + if ($candidate === false || ! is_file($candidate)) { + return null; + } + + $prefix = $baseReal . DIRECTORY_SEPARATOR; + if (! str_starts_with($candidate, $prefix)) { + return null; + } + + return $candidate; + } +} diff --git a/app/Views/admin/menu/index.php b/app/Views/admin/menu/index.php index 0110e22..4560bb5 100644 --- a/app/Views/admin/menu/index.php +++ b/app/Views/admin/menu/index.php @@ -157,6 +157,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
+
@@ -317,6 +318,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso formTitle.textContent = '메뉴 수정'; btnSubmit.textContent = '수정'; btnCancel.style.display = 'inline-block'; + // 수정 폼이 보이도록 스크롤 최상단으로 이동 + window.scrollTo({ top: 0, behavior: 'smooth' }); + const sc = document.querySelector('.main-content-area'); + if (sc) sc.scrollTo({ top: 0, behavior: 'smooth' }); }); }); diff --git a/app/Views/admin/sales_report/lot_flow.php b/app/Views/admin/sales_report/lot_flow.php index 398b1a3..3a1d520 100644 --- a/app/Views/admin/sales_report/lot_flow.php +++ b/app/Views/admin/sales_report/lot_flow.php @@ -52,7 +52,7 @@ if ($bagName !== '' || $bagCode !== '') {

- 팩·박스·낱장 바코드 또는 LOT 번호(보조: lot_no 파라미터)로 조회합니다. + 낱장 번호 조회 시 해당 장(바코드)의 판매·반품만 표시합니다. 팩·박스·LOT 조회는 해당 단위 이력입니다.

@@ -128,6 +128,8 @@ if ($bagName !== '' || $bagCode !== '') {
LOT
+
봉투번호
+
조회단위
diff --git a/app/Views/admin/sales_report/returns.php b/app/Views/admin/sales_report/returns.php index 27b91d2..c590f5f 100644 --- a/app/Views/admin/sales_report/returns.php +++ b/app/Views/admin/sales_report/returns.php @@ -4,7 +4,15 @@ $endDate = (string) ($endDate ?? date('Y-m-d')); $ioType = (string) ($ioType ?? 'out'); $result = is_array($result ?? null) ? $result : (array) ($result ?? []); $queried = (bool) ($queried ?? false); -$exportQuery = (string) ($exportQuery ?? 'search=1'); +$exportParams = $queried ? array_filter([ + 'search' => '1', + 'start_date' => $startDate, + 'end_date' => $endDate, + 'io_type' => $ioType, +], static fn ($v) => $v !== null && $v !== '') : []; +$excelUrl = $exportParams !== [] + ? mgmt_url('reports/returns/export') . '?' . http_build_query($exportParams) + : ''; $fmtKrDate = static function (string $ymd): string { $ts = strtotime($ymd); @@ -22,9 +30,10 @@ $printExtraLines = [ $typeLabel = static function (string $bsType): string { return match ($bsType) { - 'return' => '반품', - 'cancel' => '파기', - default => $bsType, + 'return' => '반품', + 'dispose' => '파기', + 'cancel' => '파기', + default => $bsType, }; }; @@ -42,6 +51,11 @@ $totalQty = 0; foreach ($result as $row) { $totalQty += (int) ($row->qty ?? 0); } + +$tipPage = "지정판매소 반품·물류 입고분 파기 내역을 기간·입출고 구분으로 조회합니다.\n" + . "· 출고: 지정판매소 반품 등록 화면에서 처리된 반품\n" + . "· 입고: 물류 창고 입고분 파기 처리 내역\n" + . "조회 후 표·엑셀·인쇄에 반영됩니다."; ?> '반품 / 파기 현황', @@ -50,10 +64,13 @@ foreach ($result as $row) {
- 반품/파기 현황 + + 반품/파기 현황 + $tipPage, 'placement' => 'below']) ?> +
- - + 엑셀저장 엑셀저장 @@ -89,9 +106,6 @@ foreach ($result as $row) { -

- 입고 = 지정판매소 반품(재고 복귀), 출고 = 판매 취소·파기 처리. 조회 후 표·엑셀·인쇄에 반영됩니다. -

@@ -138,6 +152,26 @@ foreach ($result as $row) { + +
+ + + + +
+
+ +
+ +
+ + +
+ + + + + + + + +
+
+
diff --git a/app/Views/bag/number_lookup.php b/app/Views/bag/number_lookup.php new file mode 100644 index 0000000..2350942 --- /dev/null +++ b/app/Views/bag/number_lookup.php @@ -0,0 +1,307 @@ + + + + + +

+ 봉투 바코드·LOT·팩·낱장 코드를 입력하면 바코드(4칸), 인쇄숫자(3칸), 인식번호(2칸)로 나누어 표시합니다. + 등록된 입고 바코드는 DB에서 품목명을 함께 조회합니다. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
단위입력 예시설명
LOTOQXCKH + 발주 LOT 번호만 입력합니다.
+ 바코드: OQXCKH - - -
+ 인쇄숫자/인식번호는 LOT 기준으로만 표시됩니다. +
박스OQXCKH-000008-B001 + 박스 바코드(LOT-입고번호-B박스번호)를 그대로 입력합니다.
+ 바코드: LOT 입고번호 B박스 -
+ 인쇄숫자: 입고번호 박스번호 -
+ 인식번호: 입고번호 B박스 +
OQXCKH-000008-P299 + 팩 바코드(LOT-입고번호-P팩번호)를 그대로 입력합니다.
+ 바코드: LOT 입고번호 P팩 -
+ 인쇄숫자: 입고번호 팩번호 -
+ 인식번호: 입고번호 P팩 +
낱장OQXCKH-000008-P299-S00125 + 낱장 바코드(LOT-입고번호-P팩-S장번호)를 그대로 입력합니다.
+ 바코드: LOT 입고번호 P팩 S장번호
+ 인쇄숫자: 입고번호 팩번호 장번호
+ 인식번호: 입고번호 P팩 +
+
+ + diff --git a/app/Views/components/field_tooltip.php b/app/Views/components/field_tooltip.php new file mode 100644 index 0000000..1f0fa89 --- /dev/null +++ b/app/Views/components/field_tooltip.php @@ -0,0 +1,10 @@ + + + + + diff --git a/app/Views/home/_dashboard_gov_portal_brand.php b/app/Views/home/_dashboard_gov_portal_brand.php new file mode 100644 index 0000000..5240363 --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_brand.php @@ -0,0 +1,15 @@ + + + + 종량제 시스템 + diff --git a/app/Views/home/_dashboard_gov_portal_brand_css.php b/app/Views/home/_dashboard_gov_portal_brand_css.php new file mode 100644 index 0000000..c600962 --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_brand_css.php @@ -0,0 +1,13 @@ + .gov-portal-brand { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.9375rem; + font-weight: 700; + letter-spacing: -0.03em; + color: #fff; + text-decoration: none; + white-space: nowrap; + } + .gov-portal-brand:hover { color: #fff; opacity: 0.92; } + .gov-portal-brand svg { width: 24px; height: 24px; flex-shrink: 0; opacity: 0.95; } diff --git a/app/Views/home/_dashboard_gov_portal_font_zoom_script.php b/app/Views/home/_dashboard_gov_portal_font_zoom_script.php new file mode 100644 index 0000000..63f9ee8 --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_font_zoom_script.php @@ -0,0 +1,15 @@ + diff --git a/app/Views/home/_dashboard_gov_portal_head.php b/app/Views/home/_dashboard_gov_portal_head.php new file mode 100644 index 0000000..2c3b171 --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_head.php @@ -0,0 +1,3 @@ + + + 종량제 시스템 diff --git a/app/Views/home/_dashboard_gov_portal_header_utils.php b/app/Views/home/_dashboard_gov_portal_header_utils.php new file mode 100644 index 0000000..3313db6 --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_header_utils.php @@ -0,0 +1,24 @@ + +
+ $portalVariants, 'activeVariant' => $activeVariant]) ?> + · · + + +
+ + 100% + +
+ + + +
diff --git a/app/Views/home/_dashboard_gov_portal_map_css.php b/app/Views/home/_dashboard_gov_portal_map_css.php new file mode 100644 index 0000000..bf01e8b --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_map_css.php @@ -0,0 +1,71 @@ + .portal-map-wrap { + position: relative; + width: 100%; + min-height: 200px; + background: #e8eef4; + } + .portal-map-wrap.is-fill { height: 100%; min-height: 220px; } + .portal-map-leaflet { + width: 100%; + height: 100%; + min-height: inherit; + z-index: 1; + } + .portal-map-wrap .leaflet-control-attribution { + font-size: 0.5625rem; + line-height: 1.2; + background: rgba(255, 255, 255, 0.85); + } + .portal-map-legend { + position: absolute; + left: 0.5rem; + bottom: 0.5rem; + z-index: 500; + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + padding: 0.3rem 0.45rem; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 6px; + font-size: 0.5625rem; + font-weight: 600; + color: #444; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + pointer-events: none; + } + .portal-map-legend span::before { + content: ''; + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + margin-right: 0.2rem; + vertical-align: middle; + } + .portal-map-legend .lg-warehouse::before { background: #2563eb; } + .portal-map-legend .lg-shop::before { background: #10b981; } + .portal-map-legend .lg-flow::before { background: #f59e0b; } + .portal-map-lg-badge { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 500; + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 6px; + padding: 0.35rem 0.55rem; + font-size: 0.6875rem; + font-weight: 700; + color: #1a2b4b; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.1); + pointer-events: none; + max-width: calc(100% - 1rem); + } + .portal-map-lg-badge small { + display: block; + font-size: 0.5625rem; + font-weight: 500; + color: #666; + margin-top: 0.1rem; + } diff --git a/app/Views/home/_dashboard_gov_portal_map_leaflet_assets.php b/app/Views/home/_dashboard_gov_portal_map_leaflet_assets.php new file mode 100644 index 0000000..7eb8ed6 --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_map_leaflet_assets.php @@ -0,0 +1,2 @@ + + diff --git a/app/Views/home/_dashboard_gov_portal_map_panel.php b/app/Views/home/_dashboard_gov_portal_map_panel.php new file mode 100644 index 0000000..825d051 --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_map_panel.php @@ -0,0 +1,117 @@ +} $govMapPanel + */ +$mapId = $mapId ?? 'govPortalMap'; +$mapHeight = $mapHeight ?? '200px'; +$mapFill = $mapFill ?? false; +$govMapPanel = $govMapPanel ?? [ + 'centerLat' => 35.8714, + 'centerLng' => 128.6014, + 'zoom' => 11, + 'markers' => [], +]; +$wrapClass = 'portal-map-wrap' . ($mapFill ? ' is-fill' : ''); +$wrapStyle = $mapFill ? '' : 'height:' . esc($mapHeight, 'attr') . ';'; +?> +
> +
+ + 지정판매소·창고 (목업) +
+
+ +
+ diff --git a/app/Views/home/_dashboard_gov_portal_menu_search.php b/app/Views/home/_dashboard_gov_portal_menu_search.php new file mode 100644 index 0000000..66b8609 --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_menu_search.php @@ -0,0 +1,43 @@ + $menuSearchOptions + */ +$variant = $variant ?? 'teal'; +$inputId = $inputId ?? 'menuSearch'; +$menuSearchOptions = $menuSearchOptions ?? []; +?> + +
+
+ + +
+ +
+ + + +
+ +
+ +
+ 메뉴검색 +
+ + +
+ +
+ + + +
+ +
+ diff --git a/app/Views/home/_dashboard_gov_portal_menu_search_css.php b/app/Views/home/_dashboard_gov_portal_menu_search_css.php new file mode 100644 index 0000000..38e6b25 --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_menu_search_css.php @@ -0,0 +1,48 @@ + .portal-menu-search-options { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.5rem; + } + .menu-search-chip { + display: inline-flex; + align-items: center; + max-width: 100%; + padding: 0.22rem 0.5rem; + border-radius: 999px; + font-size: 0.625rem; + font-weight: 600; + line-height: 1.25; + letter-spacing: -0.02em; + text-decoration: none; + cursor: pointer; + border: 1px solid transparent; + transition: background 0.15s, color 0.15s, border-color 0.15s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .search-teal .menu-search-chip, + .search-inline .menu-search-chip { + background: rgba(255, 255, 255, 0.22); + color: #fff; + border-color: rgba(255, 255, 255, 0.35); + } + .search-teal .menu-search-chip:hover, + .search-inline .menu-search-chip:hover { + background: #fff; + color: #00796b; + border-color: #fff; + } + .search-inline.portal-menu-search-block { + flex-direction: column; + align-items: stretch; + } + .search-inline.portal-menu-search-block .search-inline-row { + display: flex; + gap: 0.5rem; + align-items: center; + } + .search-inline.portal-menu-search-block input { + margin-top: 0; + } diff --git a/app/Views/home/_dashboard_gov_portal_nav_script_base.php b/app/Views/home/_dashboard_gov_portal_nav_script_base.php new file mode 100644 index 0000000..260472c --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_nav_script_base.php @@ -0,0 +1,118 @@ + diff --git a/app/Views/home/_dashboard_gov_portal_shared.php b/app/Views/home/_dashboard_gov_portal_shared.php new file mode 100644 index 0000000..103520d --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_shared.php @@ -0,0 +1,10 @@ + $v) { + $$k = $v; +} diff --git a/app/Views/home/_dashboard_gov_portal_sidebar.php b/app/Views/home/_dashboard_gov_portal_sidebar.php new file mode 100644 index 0000000..a26e42f --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_sidebar.php @@ -0,0 +1,59 @@ + $govNavItems */ +/** @var int $govActiveParentIdx */ +/** @var string $govActiveChildHref */ +$activeParent = $govNavItems[$govActiveParentIdx] ?? $govNavItems[0] ?? null; +$sidebarTitle = $activeParent['name'] ?? 'MY MENU'; +$activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/')); +?> + diff --git a/app/Views/home/_dashboard_gov_portal_stock_alert_levels.php b/app/Views/home/_dashboard_gov_portal_stock_alert_levels.php new file mode 100644 index 0000000..2c714fd --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_stock_alert_levels.php @@ -0,0 +1,23 @@ +}> $stockAlerts + */ +?> +
+ +
+
+
+ +
    + +
  • + +
+ +
+ +
diff --git a/app/Views/home/_dashboard_gov_portal_stock_cards_css.php b/app/Views/home/_dashboard_gov_portal_stock_cards_css.php new file mode 100644 index 0000000..fc712ec --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_stock_cards_css.php @@ -0,0 +1,157 @@ + .alert-levels { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; + } + .alert-box { + text-align: center; + padding: 0.5rem 0.2rem 0.4375rem; + border-radius: 4px; + color: #fff; + font-weight: 700; + } + .alert-box .n { font-size: 1.25rem; line-height: 1.1; letter-spacing: -0.03em; } + .alert-box .t { + font-size: 0.6875rem; + font-weight: 500; + margin-top: 0.125rem; + letter-spacing: -0.02em; + } + .alert-box .alert-bags { + list-style: none; + margin: 0.35rem 0 0; + padding: 0; + width: 100%; + font-size: 0.5625rem; + font-weight: 500; + line-height: 1.3; + letter-spacing: -0.02em; + opacity: 0.92; + text-align: center; + } + .alert-box .alert-bags li { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0.05rem 0; + } + .al-yellow .alert-bags { color: #333; opacity: 1; } + .al-blue { background: #3498db; } + .al-yellow { background: #f1c40f; color: #333; } + .al-yellow .n, .al-yellow .t { color: #333; } + .al-orange { background: #f39c12; } + .al-red { background: #e74c3c; } + .bar-row { margin-bottom: 0.4375rem; } + .bar-row .meta { + display: flex; + justify-content: space-between; + font-size: 0.6875rem; + color: var(--muted); + margin-bottom: 0.15rem; + letter-spacing: -0.02em; + } + .bar-track { + height: 7px; + background: #f1f5f9; + border-radius: 4px; + overflow: hidden; + } + .bar-fill { height: 100%; background: #f59e0b; border-radius: 4px; } + .card-low-stock { + display: flex; + flex-direction: column; + min-height: 100%; + } + .card-low-stock .card-bd { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + .card-low-stock .low-stock-grid { + flex: 1; + display: flex; + flex-direction: column; + justify-content: space-evenly; + gap: 0.75rem; + min-height: 9.5rem; + } + .grid .card-low-stock.stock-tall .low-stock-grid { + min-height: 100%; + gap: 1rem; + } + .card-low-stock .bar-row { + flex: 1 1 0; + display: flex; + flex-direction: column; + justify-content: center; + margin-bottom: 0; + min-height: 2.75rem; + } + .grid .card-low-stock.stock-tall .bar-row { + min-height: 3.25rem; + flex: 1 1 auto; + } + .card-low-stock .bar-row .meta { margin-bottom: 0.3rem; } + .grid .card-low-stock.stock-tall .bar-row .meta { + margin-bottom: 0.45rem; + font-size: 0.75rem; + } + .card-low-stock .bar-track { + flex: 1 1 auto; + height: auto; + min-height: 12px; + max-height: 2.25rem; + border-radius: 6px; + } + .grid .card-low-stock.stock-tall .bar-track { + min-height: 16px; + max-height: 3.25rem; + } + .card-low-stock .bar-fill { border-radius: 6px; } + .stock-pair.grid2 { + align-items: stretch; + } + .stock-pair.grid2 .card { + display: flex; + flex-direction: column; + min-height: 100%; + } + .stock-pair.grid2 .card-stock-alert .card-bd { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + .stock-pair.grid2 .card-stock-alert .alert-levels { + flex: 1; + align-items: stretch; + min-height: 9.5rem; + } + .stock-pair.grid2 .card-stock-alert .alert-box { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100%; + min-height: 100%; + padding: 0.625rem 0.3rem; + box-sizing: border-box; + border-radius: 6px; + } + .stock-pair.grid2 .card-stock-alert .alert-box .n { + font-size: 1.35rem; + } + .stock-pair.grid2 .card-stock-alert .alert-box .alert-bags { + font-size: 0.625rem; + margin-top: 0.45rem; + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 0.12rem; + } + .stock-pair.grid2 .card-stock-alert .alert-box .alert-bags li { + white-space: normal; + line-height: 1.25; + } diff --git a/app/Views/home/_dashboard_gov_portal_stock_cards_pair.php b/app/Views/home/_dashboard_gov_portal_stock_cards_pair.php new file mode 100644 index 0000000..cc4a732 --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_stock_cards_pair.php @@ -0,0 +1,23 @@ + $lowStock */ +?> +
+
재고 경보
+
+ $stockAlerts]) ?> +
+
+
+
부족 재고
+
+
+ +
+
%
+
+
+ +
+
+
diff --git a/app/Views/home/_dashboard_gov_portal_strip_home_inner.php b/app/Views/home/_dashboard_gov_portal_strip_home_inner.php new file mode 100644 index 0000000..1e02daa --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_strip_home_inner.php @@ -0,0 +1,89 @@ + +
+
+
+
양호
봉투 재고 상태
+
+
+
+
12
미처리 구매신청
+
+
+
+
4
승인 대기
+
+
+
+
담당 지자체
+
+
+ +
+
+ GIS 통합 현황판 + 수불 조회 > +
+
+
+ 'govPortalStripMap', + 'mapFill' => true, + 'lgLabel' => $lgLabel, + 'govMapPanel' => $govMapPanel, + ]) ?> +
+
+ +
+ + +
+ +
+
+
+ +
+ $lowStock, 'stockAlerts' => $stockAlerts]) ?> +
+ +
+
+
메시지
+
+ +
+ + +
+ + 'inline', + 'inputId' => 'menuSearch', + 'menuSearchOptions' => $menuSearchOptions, + ]) ?> +
+
+
+
최근 7일 신청 · 바로가기
+
+
+ $v): $h = (int) round(($v / $maxReq) * 100); ?> +
D
+ +
+
+ + + + + + + +
+
+
+
diff --git a/app/Views/home/_dashboard_gov_portal_strip_layout.php b/app/Views/home/_dashboard_gov_portal_strip_layout.php new file mode 100644 index 0000000..18e1fce --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_strip_layout.php @@ -0,0 +1,81 @@ + + + + + + + + + + + + + +
+
+ base_url('dashboard/gov-portal-strip')]) ?> + + $activeVariant, + 'portalVariants' => $portalVariants, + 'mbName' => $mbName, + 'levelName' => $levelName, + 'lgLabel' => $lgLabel, + ]) ?> +
+
+ +
+
+
+
님, 환영합니다
+
· · 최근접속
+
+ + + +
+ + + + +
+ +
+ +
+ + + + + diff --git a/app/Views/home/_dashboard_gov_portal_strip_my_menu.php b/app/Views/home/_dashboard_gov_portal_strip_my_menu.php new file mode 100644 index 0000000..38fa06c --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_strip_my_menu.php @@ -0,0 +1,41 @@ + $govNavItems + * @var int $govActiveParentIdx + * @var string $govActiveChildHref + */ +$activeParent = $govNavItems[$govActiveParentIdx] ?? $govNavItems[0] ?? null; +if ($activeParent === null) { + return; +} +$children = $activeParent['children'] ?? []; +if ($children === []) { + return; +} +$activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/')); +$sectionTitle = (string) ($activeParent['name'] ?? 'MY MENU'); +?> + diff --git a/app/Views/home/_dashboard_gov_portal_strip_styles.php b/app/Views/home/_dashboard_gov_portal_strip_styles.php new file mode 100644 index 0000000..551b435 --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_strip_styles.php @@ -0,0 +1,67 @@ + :root { --navy:#1a2b4b; --navy-deep:#002b4e; --blue:#007bff; --blue-menu:#4a69bd; --teal:#009688; --bg:#f0f4f8; --border:#dde4ec; --text:#444; --text-dark:#222; --muted:#888; --font-scale:1; } + * { box-sizing:border-box; margin:0; padding:0; } + html { font-size:calc(14px * var(--font-scale)); -webkit-text-size-adjust:100%; } + body { font-family:'Pretendard','Malgun Gothic','Noto Sans KR',sans-serif; font-size:.8125rem; font-weight:400; line-height:1.45; letter-spacing:-.02em; color:var(--text); background:var(--bg); min-height:100vh; -webkit-font-smoothing:antialiased; } + .page { padding:.875rem 1rem 1.25rem; max-width:1400px; margin:0 auto; } + .profile-inline { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; background:#4a5568; color:#fff; padding:1rem; border-radius:12px; margin-bottom:.875rem; } + .profile-inline .name { font-size:1rem; font-weight:700; } + .profile-inline .sub { font-size:.6875rem; opacity:.8; margin-top:.15rem; } + .profile-inline a { color:#fff; font-size:.75rem; font-weight:600; text-decoration:none; border:1px solid rgba(255,255,255,.4); padding:.3rem .55rem; border-radius:4px; } + .strip-my-menu { + display:flex; flex-wrap:wrap; align-items:center; gap:.35rem; + padding:.5rem .75rem; margin-bottom:.875rem; + background:#fff; border:1px solid var(--border); border-radius:10px; + box-shadow:0 1px 3px rgba(26,43,75,.05); + } + .strip-my-menu .strip-my-menu-title { + font-size:.6875rem; font-weight:700; color:var(--navy); + margin-right:.35rem; letter-spacing:.04em; white-space:nowrap; + } + .strip-my-menu .strip-my-menu-chips { display:flex; flex-wrap:wrap; gap:.35rem; flex:1; } + .strip-my-menu a { + display:inline-flex; align-items:center; gap:.25rem; + padding:.4rem .65rem; border-radius:10px; + background:var(--blue-menu); color:#fff; text-decoration:none; + font-size:.8125rem; font-weight:600; + border:1px solid rgba(255,255,255,.22); + } + .strip-my-menu a:hover { filter:brightness(1.06); } + .strip-my-menu a.active { background:#3d5a9e; border-color:rgba(255,255,255,.4); } + .strip-my-menu a .menu-ico { font-size:.625rem; width:.75rem; text-align:center; } + .kpi-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:.75rem; margin-bottom:.875rem; } + @media(max-width:900px){.kpi-strip{grid-template-columns:repeat(2,1fr)}} + .kpi-card { background:#fff; border-radius:12px; border:1px solid var(--border); padding:.75rem 1rem; display:flex; align-items:center; gap:.75rem; box-shadow:0 1px 3px rgba(26,43,75,.05); } + .kpi-card .ico { width:40px; height:40px; border-radius:10px; background:#eef6ff; color:var(--blue); display:flex; align-items:center; justify-content:center; font-size:1rem; } + .kpi-card .n { font-size:1.35rem; font-weight:700; color:#2563eb; line-height:1.1; } + .kpi-card .n.ok { font-size:1.1rem; color:#10b981; } + .kpi-card .l { font-size:.6875rem; color:var(--muted); font-weight:600; margin-top:.1rem; } + .hero { background:#fff; border-radius:12px; border:1px solid var(--border); overflow:hidden; margin-bottom:.875rem; box-shadow:0 1px 3px rgba(26,43,75,.06); } + .hero-hd { display:flex; justify-content:space-between; align-items:center; padding:.6rem .875rem; border-bottom:1px solid var(--border); font-size:1rem; font-weight:700; color:var(--text-dark); } + .hero-hd a { font-size:.6875rem; font-weight:600; color:#fff; background:var(--blue); padding:.25rem .5rem; border-radius:3px; text-decoration:none; } + .hero-body { display:grid; grid-template-columns:1fr 200px; min-height:200px; } + @media(max-width:800px){.hero-body{grid-template-columns:1fr}} + .hero-map { position:relative; min-height:220px; overflow:hidden; } + .hero-tl { background:var(--navy-deep); color:#fff; padding:.4rem; overflow-y:auto; max-height:220px; } + .hero-tl .item { padding:.4rem .35rem; border-bottom:1px solid rgba(255,255,255,.1); font-size:.75rem; } + .hero-tl .time { font-weight:700; font-size:.8125rem; display:block; } + .hero-tl .txt { color:#4fc3f7; font-weight:600; } + .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:.875rem; } + @media(max-width:900px){.grid2{grid-template-columns:1fr}} + .card { background:#fff; border-radius:12px; border:1px solid var(--border); box-shadow:0 1px 3px rgba(26,43,75,.05); overflow:hidden; } + .card-hd { padding:.6rem .875rem; border-bottom:1px solid var(--border); font-weight:700; font-size:1rem; color:var(--text-dark); } + .card-hd i { color:var(--blue); margin-right:.3rem; } + .card-bd { padding:.875rem 1rem; } + .notice-t { font-size:.8125rem; font-weight:600; color:var(--text-dark); } + .notice-d { font-size:.6875rem; color:var(--blue); background:#eef6ff; padding:.1rem .35rem; border-radius:2px; font-weight:600; margin-left:.35rem; } + .notice-row { padding:.4rem 0; border-bottom:1px dashed #e8edf2; } + .bars { display:flex; align-items:flex-end; gap:4px; height:56px; } + .bars .col { flex:1; text-align:center; font-size:.625rem; color:var(--muted); font-weight:500; display:flex; flex-direction:column; justify-content:flex-end; gap:2px; } + .bars .bar { background:linear-gradient(180deg,#2563eb,#60a5fa); border-radius:3px 3px 0 0; min-height:4px; width:100%; } + .quick-list a { display:flex; align-items:center; gap:.5rem; padding:.4rem 0; text-decoration:none; color:var(--text); border-bottom:1px solid #f1f5f9; font-size:.8125rem; font-weight:600; } + .quick-list a:last-child { border-bottom:none; } + .quick-list a:hover { color:var(--blue); } + .quick-list span { font-size:.6875rem; font-weight:400; color:var(--muted); margin-left:auto; } + .search-inline { display:flex; gap:.5rem; background:var(--teal); padding:.65rem .75rem; border-radius:10px; color:#fff; align-items:center; margin-top:.5rem; } + .search-inline input { flex:1; border:none; border-radius:4px; padding:.4rem .5rem; font-size:.8125rem; font-family:inherit; } + .search-inline label { font-weight:700; font-size:.8125rem; white-space:nowrap; } + .stock-pair.grid2 { margin-bottom:.875rem; } diff --git a/app/Views/home/_dashboard_gov_portal_topnav_click.php b/app/Views/home/_dashboard_gov_portal_topnav_click.php new file mode 100644 index 0000000..a32868c --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_topnav_click.php @@ -0,0 +1,42 @@ + $govNavItems + * @var int $govActiveParentIdx + * @var string $govCurrentPath + * @var list $govDashboardAliases + */ +helper('admin'); +?> + diff --git a/app/Views/home/_dashboard_gov_portal_topnav_css.php b/app/Views/home/_dashboard_gov_portal_topnav_css.php new file mode 100644 index 0000000..1775689 --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_topnav_css.php @@ -0,0 +1,171 @@ + /* 상단 메뉴바 — 기본·변형 동일 높이 */ + .portal-header { + background: var(--navy); + color: #fff; + flex-shrink: 0; + } + .portal-header-inner { + display: flex; + align-items: stretch; + justify-content: space-between; + padding: 0 1rem; + min-height: 48px; + gap: 0.5rem; + flex-wrap: wrap; + } + .portal-top-nav { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: 0 0.25rem; + min-height: 48px; + font-size: 0.875rem; + font-weight: 600; + } + .portal-nav-item { + display: flex; + align-items: stretch; + position: relative; + } + .portal-nav-link, + .portal-nav-trigger { + display: inline-flex; + align-items: center; + padding: 0 0.625rem; + min-height: 48px; + box-sizing: border-box; + color: rgba(255,255,255,.9); + text-decoration: none; + border: none; + border-bottom: 4px solid transparent; + background: transparent; + font: inherit; + letter-spacing: -0.02em; + cursor: pointer; + white-space: nowrap; + } + .portal-nav-link:hover, + .portal-nav-trigger:hover { + color: #fff; + font-weight: 700; + } + .portal-nav-link.is-active, + .portal-nav-trigger.is-active { + color: #fff; + font-weight: 700; + border-bottom-color: #fff; + } + .portal-nav-dropdown { + position: absolute; + left: 0; + top: 100%; + z-index: 300; + margin-top: -2px; + padding-top: 4px; + min-width: 12rem; + display: none; + } + .portal-nav-item:hover .portal-nav-dropdown, + .portal-nav-item:focus-within .portal-nav-dropdown { + display: block; + } + .portal-nav-dropdown-panel { + background: #fff; + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 6px 16px rgba(26,43,75,.12); + padding: 0.25rem 0; + } + .portal-nav-dropdown-panel a { + display: block; + padding: 0.45rem 0.75rem; + font-size: 0.8125rem; + font-weight: 600; + color: #334155; + text-decoration: none; + white-space: nowrap; + } + .portal-nav-dropdown-panel a:hover { + background: #eff6ff; + color: var(--blue-ui); + } + .portal-nav-dropdown-panel a.is-active { + background: #eff6ff; + color: var(--blue-ui); + font-weight: 700; + } + .portal-nav-dropdown-panel .no-link { + display: block; + padding: 0.45rem 0.75rem; + font-size: 0.8125rem; + color: #94a3b8; + cursor: default; + } + .portal-header-utils { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: rgba(255,255,255,.92); + flex-wrap: wrap; + min-height: 48px; + } + .portal-header-utils .user-line { + max-width: 11rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .portal-header-utils .extend-btn { + background: var(--blue-ui); + border: none; + color: #fff; + padding: 0.25rem 0.5rem; + border-radius: 3px; + font-size: 0.6875rem; + font-weight: 600; + cursor: pointer; + } + .portal-header-utils .font-zoom { + display: inline-flex; + align-items: center; + gap: 0.2rem; + font-size: 0.6875rem; + border: 1px solid rgba(255,255,255,.25); + border-radius: 3px; + padding: 0.125rem 0.25rem; + background: rgba(0,0,0,.12); + } + .portal-header-utils .font-zoom button { + background: transparent; + border: none; + color: #fff; + width: 1.125rem; + height: 1.125rem; + cursor: pointer; + padding: 0; + } + .portal-header-utils .util-ico { + color: inherit; + text-decoration: none; + opacity: 0.88; + font-size: 0.875rem; + padding: 0.2rem; + } + .portal-header-utils .util-ico:hover { opacity: 1; color: #fff; } + .variant-nav { + display: inline-flex; + gap: 0.125rem; + padding: 0.125rem; + background: rgba(0,0,0,.15); + border-radius: 4px; + } + .variant-nav a { + font-size: 0.6875rem; + font-weight: 600; + padding: 0.2rem 0.45rem; + border-radius: 3px; + color: rgba(255,255,255,.78); + text-decoration: none; + } + .variant-nav a.on, .variant-nav a:hover { background: rgba(255,255,255,.18); color: #fff; } diff --git a/app/Views/home/_dashboard_gov_portal_topnav_hover.php b/app/Views/home/_dashboard_gov_portal_topnav_hover.php new file mode 100644 index 0000000..823620c --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_topnav_hover.php @@ -0,0 +1,59 @@ + $govNavItems + * @var int $govActiveParentIdx + * @var string $govCurrentPath + * @var list $govDashboardAliases + */ +helper('admin'); +?> + diff --git a/app/Views/home/_dashboard_gov_portal_variant_nav.php b/app/Views/home/_dashboard_gov_portal_variant_nav.php new file mode 100644 index 0000000..aa5f9ee --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_variant_nav.php @@ -0,0 +1,17 @@ + $portalVariants */ +/** @var string $activeVariant base|alt|strip */ +?> + diff --git a/app/Views/home/_dashboard_gov_portal_workpage_css.php b/app/Views/home/_dashboard_gov_portal_workpage_css.php new file mode 100644 index 0000000..fc944bb --- /dev/null +++ b/app/Views/home/_dashboard_gov_portal_workpage_css.php @@ -0,0 +1,192 @@ + .main.work-main { + flex: 1; + min-width: 0; + overflow: auto; + background: #f5f7fa; + padding: 0.75rem 1rem 1.5rem; + } + .work-breadcrumb { + font-size: 0.6875rem; + color: #888; + margin-bottom: 0.5rem; + letter-spacing: -0.02em; + } + .work-breadcrumb a { color: #666; text-decoration: none; } + .work-breadcrumb a:hover { text-decoration: underline; } + .work-breadcrumb .sep { margin: 0 0.35rem; color: #bbb; } + .work-page-hd { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; + } + .work-page-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.125rem; + font-weight: 700; + color: #222; + letter-spacing: -0.03em; + } + .work-page-title .fav { + color: #f59e0b; + font-size: 0.875rem; + cursor: pointer; + opacity: 0.85; + } + .work-actions { display: flex; flex-wrap: wrap; gap: 0.375rem; } + .btn-portal { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.85rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 3px; + border: 1px solid transparent; + text-decoration: none; + cursor: pointer; + letter-spacing: -0.02em; + line-height: 1.3; + } + .btn-portal-primary { + background: #00205b; + color: #fff; + border-color: #00205b; + } + .btn-portal-primary:hover { background: #003080; } + .btn-portal-secondary { + background: #fff; + color: #333; + border-color: #c5cdd8; + } + .btn-portal-secondary:hover { background: #f8fafc; } + .btn-portal-search { + background: #3366ff; + color: #fff; + border-color: #3366ff; + } + .btn-portal-search:hover { background: #2952cc; } + .btn-portal-reset { + background: #fff; + color: #555; + border-color: #c5cdd8; + } + .search-panel { + background: #fff; + border: 1px solid #d1d5db; + margin-bottom: 0.75rem; + } + .search-panel table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; + } + .search-panel th { + width: 10%; + min-width: 5.5rem; + background: #f8fafc; + border: 1px solid #e5e7eb; + padding: 0.45rem 0.65rem; + font-weight: 600; + color: #444; + text-align: left; + vertical-align: middle; + } + .search-panel td { + border: 1px solid #e5e7eb; + padding: 0.35rem 0.5rem; + background: #fff; + } + .search-panel input, + .search-panel select { + width: 100%; + max-width: 14rem; + border: 1px solid #d1d5db; + padding: 0.3rem 0.45rem; + font-size: 0.75rem; + font-family: inherit; + border-radius: 2px; + } + .search-panel-foot { + display: flex; + justify-content: flex-end; + gap: 0.35rem; + padding: 0.5rem 0.65rem; + border-top: 1px solid #e5e7eb; + background: #fafbfc; + } + .table-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.4rem; + font-size: 0.75rem; + } + .table-toolbar .total strong { color: #3366ff; font-weight: 700; } + .table-toolbar .tools { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; } + .table-toolbar select { + border: 1px solid #d1d5db; + padding: 0.25rem 0.4rem; + font-size: 0.75rem; + } + .portal-data-table-wrap { + background: #fff; + border: 1px solid #d1d5db; + overflow: auto; + } + .portal-data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; + } + .portal-data-table thead th { + background: #e8eef5; + color: #00205b; + font-weight: 700; + text-align: center; + padding: 0.5rem 0.4rem; + border: 1px solid #d1d5db; + white-space: nowrap; + } + .portal-data-table tbody td { + border: 1px solid #e5e7eb; + padding: 0.4rem 0.5rem; + text-align: center; + color: #333; + } + .portal-data-table tbody tr:nth-child(even) { background: #f9fafb; } + .portal-data-table tbody tr:hover { background: #eef6ff; } + .portal-data-table tbody tr.is-selected { background: #dbeafe; } + .portal-data-table tbody td.text-left { text-align: left; } + .portal-data-table tbody td a { color: #3366ff; text-decoration: none; } + .portal-data-table tbody td a:hover { text-decoration: underline; } + .portal-data-table .empty-row td { + padding: 2rem; + color: #888; + text-align: center; + } + .detail-section { + margin-top: 1rem; + } + .detail-section-hd { + font-size: 0.8125rem; + font-weight: 700; + color: #222; + margin-bottom: 0.4rem; + padding-bottom: 0.35rem; + border-bottom: 2px solid #00205b; + } + .flash-banner { + margin-bottom: 0.65rem; + padding: 0.5rem 0.75rem; + border-radius: 4px; + font-size: 0.75rem; + } + .flash-banner.ok { background: #ecfdf5; border: 1px solid #a7f3d0; color: #065f46; } + .flash-banner.err { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; } diff --git a/app/Views/home/_gov_portal_code_kinds_body.php b/app/Views/home/_gov_portal_code_kinds_body.php new file mode 100644 index 0000000..690e3e5 --- /dev/null +++ b/app/Views/home/_gov_portal_code_kinds_body.php @@ -0,0 +1,224 @@ + $codeKinds */ +/** @var array $countMap */ +/** @var bool $canManageKinds */ +/** @var bool $canManageDetails */ +/** @var object|null $selectedKind */ +/** @var list $detailList */ +/** @var array $rowCanEdit */ +/** @var int $totalCount */ +/** @var array $filters */ +/** @var string $pageBaseUrl */ + +$canManageKinds = ! empty($canManageKinds); +$canManageDetails = ! empty($canManageDetails); +$selectedKindId = (int) ($selectedKind->ck_idx ?? 0); +$filters = is_array($filters ?? null) ? $filters : []; +$activeVariant = (string) ($activeVariant ?? 'base'); +$pageBaseUrl = (string) ($pageBaseUrl ?? site_url(gov_portal_code_kinds_portal_path($activeVariant))); + +$buildUrl = static function (array $extra = []) use ($filters, $selectedKindId, $pageBaseUrl): string { + $q = array_merge([ + 'search' => '1', + 'q_code' => $filters['q_code'] ?? '', + 'q_name' => $filters['q_name'] ?? '', + 'q_state' => $filters['q_state'] ?? '', + 'ck_idx' => $selectedKindId > 0 ? (string) $selectedKindId : '', + ], $extra); + foreach ($q as $k => $v) { + if ($v === '' || $v === null) { + unset($q[$k]); + } + } + + return $pageBaseUrl . '?' . http_build_query($q); +}; +?> + + +
+

+ 기본 코드관리 + 포털 UI 시안 + +

+ +
+ +getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ +getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
+ + 0): ?> + + + + + + + + + + + + + +
코드코드명사용여부 + +
+
+ + 초기화 + + +
+
+ +
+
+
전체
+
+ 코드 종류 +
+
+
+ + + + + + + + + + + + + + + + + + ck_idx === $selectedKindId; ?> + + + + + + + + + + + + + + + + + + +
번호코드코드명세부코드사용여부등록일작업
ck_code) ?>ck_name) ?>ck_idx] ?? 0) ?>개ck_state ?? 0) === 1 ? '사용' : '미사용' ?>ck_regdate ?? '') ?> + 수정 +
등록된 코드 종류가 없습니다.
+
+
+ + +
+

+ 세부코드 — ck_name) ?> (ck_code) ?>) +

+
+
세부
+ +
+
+ + + + + + + + + + + + + + + + + + + cd_source ?? 'platform') === 'platform' && (int) ($row->cd_lg_idx ?? 0) === 0); + $scopeLabel = $isPlatform ? '공통' : '지자체'; + ?> + + + + + + + + + + + + + + + + + + + +
번호코드코드명범위정렬사용여부등록일작업
cd_code) ?>cd_name) ?>cd_sort ?? 0) ?>cd_state ?? 0) === 1 ? '사용' : '미사용' ?>cd_regdate ?? '') ?> + cd_idx])): ?> + 수정 + + + +
등록된 세부코드가 없습니다.
+
+
+ +

위 표에서 코드 종류를 선택하면 세부코드가 표시됩니다.

+ diff --git a/app/Views/home/dashboard_gov_portal.php b/app/Views/home/dashboard_gov_portal.php new file mode 100644 index 0000000..761455f --- /dev/null +++ b/app/Views/home/dashboard_gov_portal.php @@ -0,0 +1,638 @@ + + + + + + + + + + + +
+
+ base_url('dashboard/gov-portal')]) ?> + + $activeVariant, 'portalVariants' => $portalVariants, 'mbName' => $mbName, 'levelName' => $levelName, 'lgLabel' => $lgLabel]) ?> +
+
+ +
+ + +
+
+ getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + + +
+
+

안녕하세요.

+

+

아이디
최근접속

+ +
+
+ + +
+
메시지
+
+ +
+ + +
+ +
+
+ + +
+
업무 현황 요약
+
+
+
+
양호
+
봉투 재고
+
+
+
12
+
미처리 구매신청
+
+
+
4
+
승인 대기
+
+
+
+ · 기준일 +
+
+
+ + +
+
+ 판매·수불 최근 동향 + 수불 통합 조회 > +
+
+ 'govPortalMainMap', + 'mapHeight' => '200px', + 'lgLabel' => $lgLabel, + 'govMapPanel' => $govMapPanel, + ]) ?> +
+ +
+ + +
+ +
+
+
+ + +
+ 'teal', + 'inputId' => 'menuSearch', + 'menuSearchOptions' => $menuSearchOptions, + ]) ?> +
+ + +
+
+ 서비스 데스크 + 담당: 시스템 운영팀
+ 문의: help@wxn.local (목업)
+ 평일 09:00 ~ 18:00 +
+
+ + +
+
재고 경보
+
+ $stockAlerts]) ?> +
+
+ + +
+
부족 재고
+
+
+ +
+
%
+
+
+ +
+
+
+ + +
+
최근 7일 신청
+
+ +
+ $v): ?> + +
+ +
+ D +
+ +
+
+
+ + +
+
재고 구성
+
+
+ +
    + +
  • + + % +
  • + +
+
+
+
+ +
+
+
+ + + + + diff --git a/app/Views/home/dashboard_gov_portal_code_kinds.php b/app/Views/home/dashboard_gov_portal_code_kinds.php new file mode 100644 index 0000000..87017b8 --- /dev/null +++ b/app/Views/home/dashboard_gov_portal_code_kinds.php @@ -0,0 +1,143 @@ + + + + + + + + + + +
+
+ base_url('dashboard/gov-portal')]) ?> + + $activeVariant, + 'portalVariants' => $portalVariants, + 'mbName' => $mbName, + 'levelName' => $levelName, + 'lgLabel' => $lgLabel, + ]) ?> +
+
+ +
+ +
+ +
+
+ + + + + diff --git a/app/Views/home/dashboard_gov_portal_strip.php b/app/Views/home/dashboard_gov_portal_strip.php new file mode 100644 index 0000000..4ecdbee --- /dev/null +++ b/app/Views/home/dashboard_gov_portal_strip.php @@ -0,0 +1,8 @@ + 'home/_dashboard_gov_portal_strip_home_inner', +])); diff --git a/app/Views/home/dashboard_gov_portal_strip_code_kinds.php b/app/Views/home/dashboard_gov_portal_strip_code_kinds.php new file mode 100644 index 0000000..486d52e --- /dev/null +++ b/app/Views/home/dashboard_gov_portal_strip_code_kinds.php @@ -0,0 +1,10 @@ + 'home/_gov_portal_code_kinds_body', + 'stripIncludeWorkCss' => true, + 'stripShowProfileLinks' => true, +])); diff --git a/composer.json b/composer.json index 049e320..681627a 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "require": { "php": "^8.2", "codeigniter4/framework": "^4.7", + "league/commonmark": "^2.4", "phpoffice/phpspreadsheet": "^2.2", "robthree/twofactorauth": "^3.0" }, diff --git a/composer.lock b/composer.lock index e443607..5853695 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "75c3dc434e0c074b48fe108135f527ad", + "content-hash": "453c36cb480c6356bc20f71f8a3d1603", "packages": [ { "name": "codeigniter4/framework", @@ -162,6 +162,81 @@ ], "time": "2024-11-12T16:29:46+00:00" }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, { "name": "laminas/laminas-escaper", "version": "2.18.0", @@ -223,6 +298,195 @@ ], "time": "2025-10-14T18:31:13+00:00" }, + { + "name": "league/commonmark", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-03-19T13:16:38+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, { "name": "maennchen/zipstream-php", "version": "3.2.2", @@ -408,6 +672,164 @@ }, "time": "2022-12-02T22:17:43+00:00" }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.4", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.4" + }, + "time": "2026-05-11T20:49:54+00:00" + }, { "name": "phpoffice/phpspreadsheet", "version": "2.4.4", @@ -514,6 +936,56 @@ }, "time": "2026-04-10T03:20:38+00:00" }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -695,6 +1167,157 @@ } ], "time": "2026-01-05T13:17:41+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" } ], "packages-dev": [ @@ -2485,73 +3108,6 @@ ], "time": "2023-02-07T11:34:05+00:00" }, - { - "name": "symfony/deprecation-contracts", - "version": "v3.6.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.6-dev" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-25T14:21:43+00:00" - }, { "name": "theseer/tokenizer", "version": "1.3.1", diff --git a/doc/봉투-LOT-바코드-코드체계.md b/doc/봉투-LOT-바코드-코드체계.md new file mode 100644 index 0000000..10b6540 --- /dev/null +++ b/doc/봉투-LOT-바코드-코드체계.md @@ -0,0 +1,294 @@ +# 봉투·LOT·바코드 코드 체계 + +종량제 물류 시스템에서 사용하는 **봉투번호(바코드)**, **LOT 번호**, **품목코드** 등이 어디서 만들어지고 무엇을 의미하는지 정리한 문서입니다. +구현 기준: `app/Controllers/Bag.php`, `app/Controllers/Admin/BagOrder.php`, `app/Libraries/BagLotFlowBuilder.php`, `writable/database/*.sql`. + +--- + +## 1. 계층 구조 (한눈에 보기) + +``` +[지자체] lg_code (예: 110204 남구) + └── [발주] bag_order + ├── bo_lot_no … LOT 번호 (제작·추적 단위) + ├── bo_uuid … 발주 식별자 (버전 이력) + └── bo_hash … 발주 내용 무결성 (SHA-256) + └── [입고] bag_receiving (br_idx) + └── bag_receiving_pack_code + ├── brpc_box_code … 박스 바코드 + ├── brpc_pack_code … 팩 바코드 + ├── brpc_sheet_start_code ~ end … 낱장 구간 + └── brpc_lot_no … 발주 LOT 복사 + └── [판매/반품 스캔] + ├── bag_sale_scan_code.bssc_code + └── bag_return_scan_code.brsc_code +``` + +| 단위 | 예시 | 조회·스캔 용도 | +|------|------|----------------| +| **LOT** | `OQXCKH` | 발주 단위, LOT 수불·디스켓 시드 | +| **박스** | `OQXCKH-000008-B001` | 박스 단위 입출고·LOT 수불 | +| **팩** | `OQXCKH-000008-P299` | 팩 단위 (스캔 가능) | +| **낱장** | `OQXCKH-000008-P299-S00125` | 장(매) 단위 판매·반품·LOT 수불 | + +> **참고:** `000008`은 “8번째 봉투”가 아니라 **입고 건 PK(`br_idx`)를 6자리로 채운 값**입니다. + +--- + +## 2. 발주 LOT 번호 (`bo_lot_no`) + +### 생성 시점·위치 + +- **화면:** 관리자 발주 등록·수정 (`Admin\BagOrder::store`) +- **테이블:** `bag_order.bo_lot_no` + +### 생성 규칙 + +1. **신규 발주:** `generateLotNo6()` — 영문 대문자+숫자 **6자리** 난수 (`0-9`, `A-Z`), DB 중복 시 최대 20회 재시도. +2. **실패 시:** 타임스탬프 기반 6자리 fallback (`base_convert(time, 10, 36)`). +3. **발주 수정(재발주):** 기존 건의 `bo_lot_no`를 **유지** (새 LOT를 붙이지 않음). +4. **입고 시 LOT 미지정:** 주문에 LOT가 없으면 `bag_code`(품목코드)를 LOT 자리에 대체 사용 (`createReceivingPackCodes`). + +### 의미 + +- 제작업체·지자체가 **한 번의 발주 묶음**을 식별하는 번호. +- 입고 후 `bag_receiving_pack_code.brpc_lot_no`에 복사되어 박스/팩/낱장 코드의 **접두어**가 됩니다. +- **LOT-No 디스켓 불출** (`/bag/order/lot-seed`)·바코드 시드 파일명에 사용. + +### 예시 + +| 값 | 설명 | +|----|------| +| `OQXCKH` | 발주 시 부여된 6자리 LOT (시드 예: `writable/barcode-seeds/OQXCKH_v1.seed.json`) | +| `3K9F2A` | 다른 발주 건의 LOT | + +--- + +## 3. 발주 UUID·버전·해시 + +| 필드 | 형식 | 의미 | +|------|------|------| +| `bo_uuid` | UUID v4 (`xxxxxxxx-xxxx-4xxx-...`) | 동일 발주의 **버전 묶음** 식별자 | +| `bo_version` | 정수 (1부터 증가) | 발주 **개정 이력** (복합 PK: `uuid` + `version`) | +| `bo_hash` | SHA-256 64자 hex | 발주 헤더+품목 JSON의 **무결성 검증**용 | + +- 수정·재발주 시 새 `bo_version` 행이 생기고, `bo_hash`가 갱신됩니다. +- 블록체인 연동 시 `SqlLedger`에 `ORDER_CREATE` / `ORDER_UPDATE`와 함께 기록됩니다. + +--- + +## 4. 바코드 시드 파일 (제작업체 연동) + +### 생성 시점 + +- 발주 저장 시: `Admin\BagOrder::generateBarcodeSeedFile()` +- LOT 디스켓 불출 시: `Bag::orderLotSeedGenerate()` (동일 포맷) + +### 저장 위치·파일명 + +- 디렉터리: `writable/barcode-seeds/` +- 파일명: `{bo_lot_no}_v{bo_version}.seed.json` + 예: `OQXCKH_v1.seed.json` + +### 내용 + +- 발주·품목 메타를 JSON으로 묶고 **AES-256-CBC**로 암호화. +- AES 키는 **RSA-2048** 공개키로 래핑 (`writable/keys/barcode_seed_*.pem`). +- 평문에는 `lot_no`, `uuid`, `version`, `order`, `items`, `order_hash` 등이 포함됩니다. + +### 의미 (운영) + +- 레거시/요구사항상 **제작업체 인쇄용 바코드 원시데이터**를 지자체가 발주와 함께 넘기는 용도. +- **현재 웹 입고 바코드**(`bag_receiving_pack_code`)는 아래 5절처럼 **입고 처리 시 서버가 별도 생성**합니다. + (README 「바코드 생성/사용 시점」 참고 — 발주 시점 생성 vs 입고 시점 생성 정책 검토 중) + +--- + +## 5. 입고 시 박스·팩·낱장 바코드 (`bag_receiving_pack_code`) + +### 생성 시점·함수 + +- **함수:** `Bag::createReceivingPackCodes()` +- **호출:** 입고 스캐너·일괄 입고 저장 시 (`receiving/scanner/store`, `receiving/batch/store` 등) +- **조건:** 해당 `br_idx`에 팩 코드가 아직 없을 때만 1회 생성 + +### 입력·포장 단위 + +- `packaging_unit` (`pu_pack_per_sheet`, `pu_total_per_box`): 팩당 낱장 수, 박스당 총 낱장 수. +- 입고 낱장 수 `qtySheet`로 **필요 팩 개수** = `ceil(qtySheet / pack_per_sheet)`. +- **박스당 팩 수** = `total_per_box / pack_per_sheet`. + +### 코드 포맷 (구현) + +`$lotNo` = 발주 `bo_lot_no` (없으면 품목코드), `$brIdx` = `bag_receiving.br_idx`. + +| 구분 | sprintf 패턴 | 예 (`lot=OQXCKH`, `br_idx=8`) | +|------|----------------|-------------------------------| +| 박스 | `{lotNo}-%06d-B%03d` | `OQXCKH-000008-B001` | +| 팩 | `{lotNo}-%06d-P%03d` | `OQXCKH-000008-P001` … `P299` | +| 낱장 시작 | `{packCode}-S%05d` | `OQXCKH-000008-P299-S00001` | +| 낱장 끝 | `{packCode}-S%05d` | `OQXCKH-000008-P299-S00300` (팩당 300장 예) | + +- `%06d` → `br_idx` 6자리 (000008 = 8번 입고 건). +- `%03d` → 박스·팩 **순번** (001~999). +- `%05d` → 팩 **내부 낱장 순번** (00001~). + +### DB 컬럼 의미 + +| 컬럼 | 설명 | +|------|------| +| `brpc_lot_no` | 발주 LOT | +| `brpc_box_code` | 박스 바코드 | +| `brpc_pack_code` | 팩 바코드 (UNIQUE) | +| `brpc_sheet_start_code` / `brpc_sheet_end_code` | 해당 팩에 속한 낱장 코드 구간 | +| `brpc_sheet_qty` | 그 팩의 낱장 매수 | +| `brpc_state` | `in_stock` / `sold` 등 재고 상태 | + +### LOT 수불 조회에서의 해석 + +- **낱장 번호 입력** → 해당 장의 판매·반품만 + 소속 팩 **입고 1건**. +- **팩 번호** (`…-P299`) → 팩·박스 코드 기준 이력. +- **LOT만** (`lot_no` 파라미터) → 동일 LOT 전체 팩·입고·발주 요약. + +--- + +## 6. 봉투 품목코드 (`code_detail`, 종류 `O`) + +### 등록 + +- **종류:** `code_kind.ck_code = 'O'` (봉투명/품목) +- **테이블:** `code_detail.cd_code`, `cd_name` +- 지자체별 확장: `cd_lg_idx` (0이면 공통) + +### 코드 구성 (5자리 숫자 관례) + +대구 시드(`code_master_init_daegu.sql`) 기준: + +| 자리 | 연계 종류 | 예 | +|------|-----------|-----| +| 앞 2자리 | `E` 봉투구분 (10=일반, 20=공공, 30=무료, 60=음식물…) | `10` | +| 다음 2자리 | `G` 용량 (15=20L, 13=10L…) | `15` | +| 마지막 1자리 | `F` 재질 등 | `2` | + +**예:** `10152` = 일반용(`10`) + 20L(`15`) + … → **일반용 20L** + +- 발주·입고·재고·판매 집계는 이 코드(`bs_bag_code`, `br_bag_code`, `bi_bag_code` 등)로 묶입니다. +- **바코드(LOT·팩·낱장)와는 별개** — 품목 종류 식별용 마스터 코드입니다. + +--- + +## 7. 지자체·행정 코드 + +| 코드 | 필드 | 예 | 의미 | +|------|------|-----|------| +| 지자체 | `local_government.lg_code` | `110204` | 대구 남구 (6자리 관례) | +| 발주 구·군 | `bag_order.bo_gugun_code` | `110204` | 발주 시 지자체 `lg_code` 복사 | +| 발주 동 | `bag_order.bo_dong_code` | (동 코드) | 세부 행정 단위 | + +--- + +## 8. 지정판매소 번호 (`ds_shop_no`) + +### 자동 부여 (주소 기반) + +`Admin\DesignatedShop::resolveDesignatedShopNumberFromAddress()`: + +``` +판매소번호 = B코드 + C코드 + D코드 + 3자리 일련번호 +``` + +- **B:** 시·도 (`code_kind` = `B`, 주소 매칭) +- **C:** 구·군 (`C`, B 접두 포함) +- **D:** 동 (`D`, C 접두 포함) +- **일련:** 동일 지자체 내 기존 번호 끝 3자리 숫자 최댓값 + 1 + +### 시드·레거시 + +- 초기 데이터에 `DS-2024-001` 형태가 있을 수 있음 (수동/이관 데이터). +- **가상계좌:** `ds_va_number` / `ds_va_bank` + `ds_va_account` + +### 판매 스캔과의 관계 + +- 판매·반품 시 `bssc_ds_idx` / `brsc_ds_idx`로 판매소 연결. +- LOT 수불·판매 대장의 **입출고처**에 `ds_name` 표시. + +--- + +## 9. 판매·반품 스캔 코드 + +### 저장 테이블 + +| 테이블 | 코드 컬럼 | 생성 시점 | +|--------|-----------|-----------| +| `bag_sale_scan_code` | `bssc_code` | 지정판매소 판매 (`/bag/sale/designated`) | +| `bag_return_scan_code` | `brsc_code` | 지정판매소 반품 (`/bag/sale/designated-return`) | + +### 규칙 + +- 스캔·입력 값 = **입고 시 생성된 바코드** (보통 **낱장** 또는 팩). +- `bssc_state`: `in_stock` → 판매 시 `sold`, 반품 시 다시 `in_stock`. +- **낱장 판매** 시 동일 팩의 다른 낱장을 위해 `bag_receiving_pack_code` 팩 상태는 유지할 수 있음 (코드 주석 참고). + +### 주문·판매 집계 코드 + +- `bag_sale.bs_type`: `sale` / `return` / `cancel` +- `shop_order` / `shop_order_item`: 주문 접수용 (전화·웹 등), 판매소·품목·수량 — **LOT 바코드와 별도 흐름**. + +--- + +## 10. 기타 코드 + +| 구분 | 예 | 설명 | +|------|-----|------| +| 회원 | `mb_id` | 로그인 ID (시스템 부여·가입) | +| 제작업체 | `company.cp_*` | 발주 `bo_company_idx` | +| 판매 대행소 | `sales_agency.sa_*` | 발주·입고 `bo_agency_idx` | +| 무료 불출 | `bag_issue` | 불출처·수량 (바코드 LOT 체계와 별도) | +| 반품/파기 시드 | `RET-20260508-DS1-001` | 테스트용 반품 스캔 코드 (`bag_dispose_tables.sql`) | +| 파기 | `bag_dispose` | 입고분 파기 이력 (반품/파기 현황 **입고** 탭) | + +--- + +## 11. 화면별 “어떤 코드를 넣나” + +| 화면 | 입력·표시 코드 | 데이터 소스 | +|------|----------------|-------------| +| LOT 수불 조회 | 봉투번호(바코드)·`lot_no` | `BagLotFlowBuilder` | +| 지정판매소 판매/반품 | 스캔 바코드 | `bag_sale_scan_code` / `bag_return_scan_code` | +| 반품/파기 현황 출고 | (조회만) | `bag_return_scan_code` | +| 반품/파기 현황 입고 | (조회만) | `bag_dispose` | +| 기간별 봉투 수불 | 품목코드 `O` | 집계 (`BagFlowReportBuilder`) | +| 발주·입고 | LOT, 품목코드 | `bag_order`, `bag_receiving` | + +--- + +## 12. 구현·스키마 참고 파일 + +| 주제 | 파일 | +|------|------| +| 입고 바코드 생성 | `app/Controllers/Bag.php` → `createReceivingPackCodes()` | +| LOT 6자리 생성 | `app/Controllers/Admin/BagOrder.php` → `generateLotNo6()` | +| LOT 수불 조회 | `app/Libraries/BagLotFlowBuilder.php` | +| 팩 코드 테이블 | `writable/database/receiving_pack_code_tables.sql` | +| 발주 테이블 | `writable/database/order_tables.sql` | +| 품목 마스터 | `writable/database/code_master_init_daegu.sql` (종류 `O`) | +| 바코드 시드 샘플 | `writable/barcode-seeds/*.seed.json` | + +--- + +## 13. 자주 묻는 혼동 + +1. **`OQXCKH-000008-P299`와 `P300`이 비슷해 보이는데 수불이 같다?** + - 이전에는 팩·LOT 전체 이력을 함께 가져왔습니다. + - 수정 후 **낱장 단위**는 `…-P299-Sxxxxx` 형태로 조회하거나, 팩 단위는 P299/P300 각각 조회해야 판매 이력이 갈립니다. + +2. **LOT 6자리와 접두 `OQXCKH`가 다른 길이?** + - 발주 LOT는 6자리 규칙이지만, 시드·테스트 데이터에 6자 영문(`OQXCKH`)을 쓴 경우가 있습니다. DB `VARCHAR(50)`에 저장되며, 입고 코드 접두로 그대로 사용됩니다. + +3. **품목코드 `10152`와 바코드 `OQXCKH-…`의 관계?** + - `10152`는 **종류·용량 마스터**. + - `OQXCKH-…`는 **물리 단위(팩/장) 추적용** 바코드입니다. 같은 품목이라도 LOT·입고 건마다 바코드 접두가 달라집니다. + +--- + +*문서 갱신: 코드 변경 시 `createReceivingPackCodes`, `generateLotNo6`, `BagLotFlowBuilder`를 우선 확인하세요.* diff --git a/e2e/manual.spec.js b/e2e/manual.spec.js new file mode 100644 index 0000000..472ead1 --- /dev/null +++ b/e2e/manual.spec.js @@ -0,0 +1,41 @@ +const { test, expect } = require('@playwright/test'); +const { login } = require('./helpers/auth'); + +/** + * 사용자 매뉴얼(bag/manual) E2E + * - 비로그인 차단(loginAuth) + * - 로그인 후 목차/본문 렌더 + * - 목차 이동(표 렌더) + * - 미등록 slug 404 + */ +test.describe('사용자 매뉴얼', () => { + test('비로그인 시 로그인으로 이동', async ({ page }) => { + await page.goto('/bag/manual'); + await expect(page).toHaveURL(/\/login/); + }); + + test('로그인 후 매뉴얼 첫 페이지(개요) 렌더 + 목차 노출', async ({ page }) => { + await login(page, 'user'); + await page.goto('/bag/manual'); + // manual 첫 slug(overview)로 이동 + await expect(page).toHaveURL(/\/bag\/manual\/overview/); + // 좌측 목차 + await expect(page.locator('.manual-toc')).toBeVisible(); + await expect(page.locator('.manual-toc a', { hasText: '핵심 업무 흐름' })).toBeVisible(); + // 본문 + await expect(page.locator('.manual-prose h1')).toContainText('시스템 개요'); + }); + + test('목차에서 코드체계 페이지로 이동 → 표 렌더', async ({ page }) => { + await login(page, 'user'); + await page.goto('/bag/manual/codes'); + await expect(page.locator('.manual-prose table').first()).toBeVisible(); + await expect(page.locator('.manual-prose')).toContainText('바코드'); + }); + + test('미등록 slug 는 404', async ({ page }) => { + await login(page, 'user'); + const res = await page.goto('/bag/manual/does-not-exist'); + expect(res.status()).toBe(404); + }); +}); diff --git a/e2e/new-features.spec.js b/e2e/new-features.spec.js index 9abd998..4a70296 100644 --- a/e2e/new-features.spec.js +++ b/e2e/new-features.spec.js @@ -240,16 +240,27 @@ test.describe('P5-08: 반품/파기 현황', () => { test('기간·입출고 조회 및 엑셀', async ({ page }) => { await loginAsLocal(page); - await page.goto('/bag/reports/returns?search=1&start_date=2026-01-01&end_date=2026-12-31&io_type=out'); + await page.goto('/bag/reports/returns?search=1&start_date=2026-05-01&end_date=2026-05-31&io_type=out'); await expect(page.locator('table.data-table')).toBeVisible(); + await expect(page.locator('table.data-table tbody tr').first()).not.toContainText('해당 자료가 없습니다'); + await expect(page.getByText('반품').first()).toBeVisible(); await expect(page.getByRole('link', { name: '엑셀저장' })).toBeVisible(); const res = await page.request.get( - '/bag/reports/returns/export?search=1&start_date=2026-01-01&end_date=2026-12-31&io_type=out' + '/bag/reports/returns/export?search=1&start_date=2026-05-01&end_date=2026-05-31&io_type=out' ); expect(res.status()).toBe(200); const ct = (res.headers()['content-type'] || '').toLowerCase(); expect(ct).toContain('application/vnd.ms-excel'); }); + + test('5월 입고(파기) 조회', async ({ page }) => { + await loginAsLocal(page); + await page.goto('/bag/reports/returns?search=1&start_date=2026-05-01&end_date=2026-05-31&io_type=in'); + await expect(page.locator('table.data-table')).toBeVisible(); + await expect(page.locator('table.data-table tbody tr').first()).not.toContainText('해당 자료가 없습니다'); + await expect(page.getByText('물류창고')).toBeVisible(); + await expect(page.getByText('파기').first()).toBeVisible(); + }); }); // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/e2e/number-lookup.spec.js b/e2e/number-lookup.spec.js new file mode 100644 index 0000000..2b6d28d --- /dev/null +++ b/e2e/number-lookup.spec.js @@ -0,0 +1,37 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { login } = require('./helpers/auth'); + +test.describe('번호알기 (봉투번호확인)', () => { + test.beforeEach(async ({ page }) => { + await login(page, 'local'); + }); + + test('페이지 로드 및 기본 UI', async ({ page }) => { + await page.goto('/bag/number-lookup'); + await expect(page).toHaveURL(/\/bag\/number-lookup/); + await expect(page.locator('#numLookupTitle')).toHaveText(/봉투번호확인/); + await expect(page.locator('#codeInput')).toBeVisible(); + await expect(page.locator('#barcodeOut')).toHaveText(/- - - -/); + await expect(page.locator('#printOut')).toHaveText(/- - -/); + await expect(page.locator('#recognitionOut')).toHaveText(/- -/); + }); + + test('LOT-팩-낱장 코드 조회', async ({ page }) => { + await page.goto('/bag/number-lookup'); + await page.fill('#codeInput', 'OQXCKH-000008-P299-S00125'); + await page.click('button.num-lookup-btn-primary'); + + await expect(page).toHaveURL(/code=OQXCKH-000008-P299-S00125/); + await expect(page.locator('#barcodeOut')).toContainText('OQXCKH'); + await expect(page.locator('#barcodeOut')).toContainText('P299'); + await expect(page.locator('#printOut')).toContainText('8'); + await expect(page.locator('#recognitionOut')).toContainText('P299'); + }); + + test('도움말에서 번호알기 링크', async ({ page }) => { + await page.goto('/bag/help'); + await page.click('a[href*="bag/number-lookup"]'); + await expect(page).toHaveURL(/\/bag\/number-lookup/); + }); +}); diff --git a/jobs.md b/jobs.md index 9a776aa..4bd2067 100644 --- a/jobs.md +++ b/jobs.md @@ -163,6 +163,13 @@ > 최신 항목이 위에 옵니다. +### 2026-06-05 + +- **DOC-03** 사용자 매뉴얼(설명서) 마크다운 기반 in-app 문서 시스템 추가 + - `league/commonmark` 도입, `bag/manual` 라우트(loginAuth 보호), `ManualRenderer` 라이브러리 + `Config\Manual` manifest + - 콘텐츠 8종(`app/Docs/manual/*.md`): 개요/업무흐름/발주입고/재고실사/판매불출/판매현황·수불·통계/코드체계/FAQ + - `bag/help`에 매뉴얼 링크 추가, E2E 4건(`e2e/manual.spec.js`) 통과 + ### 2026-03-25 - **P5** Phase 5 판매대장/일계표/기간별현황/수불현황 리포트 (`f451f0f`) diff --git a/writable/database/bag_dispose_tables.sql b/writable/database/bag_dispose_tables.sql new file mode 100644 index 0000000..ebef155 --- /dev/null +++ b/writable/database/bag_dispose_tables.sql @@ -0,0 +1,107 @@ +-- ============================================ +-- 봉투 파기(입고분 폐기) + 5월 테스트 시드 +-- 반품/파기 현황: 입출고=입고 시 bag_dispose 조회 +-- ============================================ + +CREATE TABLE IF NOT EXISTS `bag_dispose` ( + `bd_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `bd_lg_idx` INT UNSIGNED NOT NULL, + `bd_dispose_date` DATE NOT NULL COMMENT '파기일', + `bd_location` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '파기처(창고·처리장 등)', + `bd_bag_code` VARCHAR(50) NOT NULL, + `bd_bag_name` VARCHAR(100) NOT NULL DEFAULT '', + `bd_qty` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '파기수량(매)', + `bd_reason` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '사유', + `bd_regdate` DATETIME NOT NULL, + PRIMARY KEY (`bd_idx`), + KEY `idx_bd_lg_date` (`bd_lg_idx`, `bd_dispose_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='봉투 파기(입고분)'; + +-- 지정판매소 반품 스캔(없으면 생성) — designated-return 과 동일 구조 +CREATE TABLE IF NOT EXISTS `bag_return_scan_code` ( + `brsc_idx` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `brsc_lg_idx` INT UNSIGNED NOT NULL, + `brsc_so_idx` INT UNSIGNED NOT NULL, + `brsc_ds_idx` INT UNSIGNED NOT NULL, + `brsc_bag_code` VARCHAR(50) NOT NULL DEFAULT '', + `brsc_bag_name` VARCHAR(100) NOT NULL DEFAULT '', + `brsc_code` VARCHAR(120) NOT NULL, + `brsc_unit` VARCHAR(10) NOT NULL DEFAULT '', + `brsc_qty` INT UNSIGNED NOT NULL DEFAULT 0, + `brsc_unit_price` DECIMAL(12,2) NOT NULL DEFAULT 0.00, + `brsc_amount` DECIMAL(14,2) NOT NULL DEFAULT 0.00, + `brsc_return_date` DATE NOT NULL, + `brsc_state` VARCHAR(20) NOT NULL DEFAULT 'returned', + `brsc_regdate` DATETIME NOT NULL, + PRIMARY KEY (`brsc_idx`), + KEY `idx_brsc_lg_return_date` (`brsc_lg_idx`, `brsc_return_date`), + KEY `idx_brsc_ds_idx` (`brsc_ds_idx`), + KEY `idx_brsc_state` (`brsc_state`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='지정판매소 반품 스캔 코드'; + +SET @LG_IDX = (SELECT lg_idx FROM local_government WHERE lg_code = '110204' LIMIT 1); +SET @DS1 = COALESCE( + (SELECT ds_idx FROM designated_shop WHERE ds_lg_idx = @LG_IDX AND ds_name = 'CU 대명점' LIMIT 1), + (SELECT MIN(ds_idx) FROM designated_shop WHERE ds_lg_idx = @LG_IDX) +); +SET @DS2 = COALESCE( + (SELECT ds_idx FROM designated_shop WHERE ds_lg_idx = @LG_IDX AND ds_name = 'GS25 앞산점' LIMIT 1), + (SELECT MIN(ds_idx) + 1 FROM designated_shop WHERE ds_lg_idx = @LG_IDX) +); +SET @DS4 = COALESCE( + (SELECT ds_idx FROM designated_shop WHERE ds_lg_idx = @LG_IDX AND ds_name = '봉덕슈퍼' LIMIT 1), + (SELECT MAX(ds_idx) FROM designated_shop WHERE ds_lg_idx = @LG_IDX) +); +SET @SO1 = (SELECT so_idx FROM shop_order WHERE so_lg_idx = @LG_IDX ORDER BY so_idx ASC LIMIT 1); + +DELETE FROM `bag_dispose` + WHERE `bd_lg_idx` = @LG_IDX AND `bd_dispose_date` BETWEEN '2026-05-01' AND '2026-05-31'; +DELETE FROM `bag_return_scan_code` + WHERE `brsc_lg_idx` = @LG_IDX AND `brsc_code` LIKE 'RET-202605%'; + +-- 2026년 5월 파기(입고) — 반품/파기 현황 입고 조회용 +INSERT INTO `bag_dispose` (`bd_lg_idx`, `bd_dispose_date`, `bd_location`, `bd_bag_code`, `bd_bag_name`, `bd_qty`, `bd_reason`, `bd_regdate`) VALUES +(@LG_IDX, '2026-05-05', '남구 종량제 물류창고', '10152', '일반용 20L', 120, '유통기한 경과', '2026-05-05 10:00:00'), +(@LG_IDX, '2026-05-12', '남구 종량제 물류창고', '60102', '음식물 2L', 80, '오염·훼손', '2026-05-12 11:30:00'), +(@LG_IDX, '2026-05-19', '남구 종량제 물류창고', '10172', '일반용 50L', 45, '재고 정리', '2026-05-19 09:15:00'); + +-- 2026년 5월 지정판매소 반품(designated-return) — 출고 조회용 +INSERT INTO `bag_return_scan_code` + (`brsc_lg_idx`, `brsc_so_idx`, `brsc_ds_idx`, `brsc_bag_code`, `brsc_bag_name`, `brsc_code`, `brsc_unit`, `brsc_qty`, `brsc_unit_price`, `brsc_amount`, `brsc_return_date`, `brsc_state`, `brsc_regdate`) +SELECT @LG_IDX, IFNULL(@SO1, 0), @DS1, '10152', '일반용 20L', 'RET-20260508-DS1-001', '매', 25, 670.00, 16750.00, '2026-05-08', 'returned', '2026-05-08 14:20:00' + WHERE @DS1 IS NOT NULL +UNION ALL SELECT @LG_IDX, IFNULL(@SO1, 0), @DS1, '10152', '일반용 20L', 'RET-20260508-DS1-002', '매', 10, 670.00, 6700.00, '2026-05-08', 'returned', '2026-05-08 14:21:00' + WHERE @DS1 IS NOT NULL +UNION ALL SELECT @LG_IDX, IFNULL(@SO1, 0), @DS2, '10132', '일반용 10L', 'RET-20260515-DS2-001', '매', 40, 340.00, 13600.00, '2026-05-15', 'returned', '2026-05-15 16:00:00' + WHERE @DS2 IS NOT NULL +UNION ALL SELECT @LG_IDX, IFNULL(@SO1, 0), @DS4, '10162', '일반용 30L', 'RET-20260522-DS4-001', '매', 15, 1080.00, 16200.00, '2026-05-22', 'returned', '2026-05-22 11:45:00' + WHERE @DS4 IS NOT NULL; + +-- E2E·북구(lg_idx=1) 등 designated_shop 이 있는 지자체 +SET @LG_T = 1; +SET @DS_T1 = (SELECT MIN(ds_idx) FROM designated_shop WHERE ds_lg_idx = @LG_T); +SET @DS_T2 = (SELECT MIN(ds_idx) + 1 FROM designated_shop WHERE ds_lg_idx = @LG_T); +SET @SO_T = (SELECT so_idx FROM shop_order WHERE so_lg_idx = @LG_T ORDER BY so_idx ASC LIMIT 1); + +DELETE FROM `bag_dispose` + WHERE `bd_lg_idx` = @LG_T AND `bd_dispose_date` BETWEEN '2026-05-01' AND '2026-05-31'; +DELETE FROM `bag_return_scan_code` + WHERE `brsc_lg_idx` = @LG_T AND `brsc_code` LIKE 'RET-202605-LG1-%'; + +INSERT INTO `bag_dispose` (`bd_lg_idx`, `bd_dispose_date`, `bd_location`, `bd_bag_code`, `bd_bag_name`, `bd_qty`, `bd_reason`, `bd_regdate`) VALUES +(@LG_T, '2026-05-07', '북구 종량제 물류창고', '10152', '일반용 20L', 60, '유통기한 경과', '2026-05-07 10:00:00'), +(@LG_T, '2026-05-14', '북구 종량제 물류창고', '10132', '일반용 10L', 35, '오염·훼손', '2026-05-14 11:00:00'); + +INSERT INTO `bag_return_scan_code` + (`brsc_lg_idx`, `brsc_so_idx`, `brsc_ds_idx`, `brsc_bag_code`, `brsc_bag_name`, `brsc_code`, `brsc_unit`, `brsc_qty`, `brsc_unit_price`, `brsc_amount`, `brsc_return_date`, `brsc_state`, `brsc_regdate`) +SELECT @LG_T, IFNULL(@SO_T, 0), @DS_T1, '10152', '일반용 20L', 'RET-202605-LG1-001', '매', 20, 670.00, 13400.00, '2026-05-09', 'returned', '2026-05-09 14:00:00' + WHERE @DS_T1 IS NOT NULL +UNION ALL SELECT @LG_T, IFNULL(@SO_T, 0), @DS_T2, '10132', '일반용 10L', 'RET-202605-LG1-002', '매', 30, 340.00, 10200.00, '2026-05-16', 'returned', '2026-05-16 15:00:00' + WHERE @DS_T2 IS NOT NULL; + +-- 테스터 지자체관리자(mb_lg_idx=10 등) 환경: 파기만 시드(판매소 없을 수 있음) +SET @LG_T = 10; +DELETE FROM `bag_dispose` + WHERE `bd_lg_idx` = @LG_T AND `bd_dispose_date` BETWEEN '2026-05-01' AND '2026-05-31'; +INSERT INTO `bag_dispose` (`bd_lg_idx`, `bd_dispose_date`, `bd_location`, `bd_bag_code`, `bd_bag_name`, `bd_qty`, `bd_reason`, `bd_regdate`) VALUES +(@LG_T, '2026-05-06', '중구 종량제 물류창고', '10152', '일반용 20L', 50, '유통기한 경과', '2026-05-06 10:00:00'); diff --git a/writable/database/menu_link_number_lookup.sql b/writable/database/menu_link_number_lookup.sql new file mode 100644 index 0000000..c9d0f9d --- /dev/null +++ b/writable/database/menu_link_number_lookup.sql @@ -0,0 +1,10 @@ +-- 도움말 > 번호알기 메뉴를 전용 화면으로 연결 +-- mysql --default-character-set=utf8mb4 -u ... -p DBNAME < writable/database/menu_link_number_lookup.sql + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +UPDATE `menu` m +INNER JOIN `menu_type` t ON t.mt_idx = m.mt_idx AND t.mt_code = 'site' +SET m.mm_link = 'bag/number-lookup' +WHERE m.mm_name IN ('번호알기', '번호 알기') + AND (TRIM(COALESCE(m.mm_link, '')) = '' OR m.mm_link = 'bag/help'); diff --git a/writable/database/menu_site_fill_empty_second_level_links.sql b/writable/database/menu_site_fill_empty_second_level_links.sql index c47e56f..cbc475b 100644 --- a/writable/database/menu_site_fill_empty_second_level_links.sql +++ b/writable/database/menu_site_fill_empty_second_level_links.sql @@ -64,7 +64,7 @@ SET m.mm_link = CASE m.mm_name WHEN '도움말 항목' THEN 'bag/help' WHEN '원격 요청' THEN 'bag/help' WHEN 'pda 리셋' THEN 'bag/help' - WHEN '번호알기' THEN 'bag/help' + WHEN '번호알기' THEN 'bag/number-lookup' WHEN 'Data Backup' THEN 'bag/help' WHEN '컴포트 설정' THEN 'bag/help' WHEN 'Version 정보' THEN 'bag/help' diff --git a/writable/database/menu_site_seed_from_csv.sql b/writable/database/menu_site_seed_from_csv.sql index 5e26a8b..799690b 100644 --- a/writable/database/menu_site_seed_from_csv.sql +++ b/writable/database/menu_site_seed_from_csv.sql @@ -349,7 +349,7 @@ SELECT @mt_site, 1, t.mm_name, WHEN '도움말 항목' THEN 'bag/help' WHEN '원격 요청' THEN 'bag/help' WHEN 'pda 리셋' THEN 'bag/help' - WHEN '번호알기' THEN 'bag/help' + WHEN '번호알기' THEN 'bag/number-lookup' WHEN 'Data Backup' THEN 'bag/help' WHEN '컴포트 설정' THEN 'bag/help' WHEN 'Version 정보' THEN 'bag/help'