From 0f1d414f37b86507e133d043c2a8a858965c860a Mon Sep 17 00:00:00 2001 From: taekyoungc Date: Mon, 1 Jun 2026 16:15:15 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=C2=B7=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EB=B4=89=ED=88=AC=20=EB=AC=BC=EB=A5=98=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5(=EC=88=98=EB=B6=88=C2=B7=ED=86=B5=EA=B3=84?= =?UTF-8?q?=C2=B7=EB=A0=88=ED=8F=AC=ED=8A=B8=C2=B7=EC=9E=AC=EA=B3=A0=C2=B7?= =?UTF-8?q?=EB=B0=9C=EC=A3=BC)=EA=B3=BC=20DB=C2=B7=EB=A9=94=EB=89=B4=C2=B7?= =?UTF-8?q?E2E=EB=A5=BC=20=EC=9A=B4=EC=98=81=20=EB=B0=98=EC=98=81=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 통계 분석(전년대비·월별·계절별), 수급계획·LOT 수불, 지정판매소·실사·메뉴 링크 등을 포함한다. Co-authored-by: Cursor --- CLAUDE.md | 2 + README.md | 34 +- app/Config/Routes.php | 27 +- app/Controllers/Admin/BagInventory.php | 5 +- app/Controllers/Admin/BagIssue.php | 292 +- app/Controllers/Admin/BagPrice.php | 6 +- app/Controllers/Admin/Company.php | 6 +- app/Controllers/Admin/Dashboard.php | 10 +- app/Controllers/Admin/FreeRecipient.php | 47 +- app/Controllers/Admin/Menu.php | 13 + app/Controllers/Admin/SalesAgency.php | 6 +- app/Controllers/Admin/SalesReport.php | 3209 +++++++++++++++-- app/Controllers/Admin/ShopOrder.php | 35 +- app/Controllers/Bag.php | 2372 +++++++++++- app/Controllers/Home.php | 46 +- app/Helpers/admin_helper.php | 41 +- app/Helpers/export_helper.php | 438 +++ app/Libraries/BagAnalyticsReportBuilder.php | 659 ++++ app/Libraries/BagFlowReportBuilder.php | 463 +++ app/Libraries/BagLotFlowBuilder.php | 673 ++++ app/Libraries/BagSupplyPlanBuilder.php | 494 +++ app/Libraries/Blockchain/SqlLedger.php | 106 + app/Models/BagIssueItemCodeModel.php | 23 + app/Models/BlockchainLedgerModel.php | 25 + app/Models/CodeDetailModel.php | 6 +- app/Models/DesignatedShopModel.php | 1 + app/Models/MenuModel.php | 35 + app/Models/PackagingUnitModel.php | 25 + app/Models/ShopOrderModel.php | 2 +- app/Views/admin/bag_issue/index.php | 4 +- app/Views/admin/bag_order/index.php | 2 +- app/Views/admin/bag_price/index.php | 2 +- .../admin/designated_shop/barcode_print.php | 7 + app/Views/admin/designated_shop/manage.php | 87 + app/Views/admin/free_recipient/create.php | 21 +- app/Views/admin/free_recipient/edit.php | 21 +- app/Views/admin/free_recipient/index.php | 31 +- app/Views/admin/layout.php | 5 + app/Views/admin/menu/index.php | 6 +- .../admin/sales_report/daily_summary.php | 208 +- .../admin/sales_report/hometax_process.php | 322 ++ app/Views/admin/sales_report/lot_flow.php | 301 +- app/Views/admin/sales_report/misc_flow.php | 415 ++- app/Views/admin/sales_report/period_sales.php | 243 +- app/Views/admin/sales_report/returns.php | 165 +- app/Views/admin/sales_report/sales_ledger.php | 341 +- app/Views/admin/sales_report/shop_sales.php | 281 +- .../admin/sales_report/supply_demand.php | 351 +- app/Views/admin/sales_report/yearly_sales.php | 284 +- app/Views/admin/shop_order/create.php | 287 +- app/Views/admin/shop_order/index.php | 9 +- app/Views/auth/login.php | 1 + app/Views/auth/login_two_factor.php | 1 + app/Views/auth/register.php | 1 + app/Views/auth/totp_setup.php | 1 + app/Views/bag/analytics_monthly_trend.php | 123 + app/Views/bag/analytics_seasonal_trend.php | 104 + app/Views/bag/analytics_yoy.php | 99 + app/Views/bag/code_details.php | 9 +- app/Views/bag/code_kinds.php | 12 +- app/Views/bag/create_bag_issue.php | 522 ++- app/Views/bag/daily_inventory.php | 1 + app/Views/bag/dashboard_blend_lite_inner.php | 332 ++ app/Views/bag/designated_shop_return.php | 333 ++ .../bag/designated_shop_return_cancel.php | 260 ++ app/Views/bag/designated_shop_sale.php | 520 +++ app/Views/bag/designated_shop_sale_return.php | 424 +++ app/Views/bag/flow.php | 357 +- app/Views/bag/inventory.php | 143 +- app/Views/bag/inventory_adjust.php | 43 - app/Views/bag/inventory_inquiry.php | 1 + app/Views/bag/inventory_inspection_detail.php | 90 + app/Views/bag/inventory_inspection_select.php | 6 +- app/Views/bag/issue.php | 455 ++- app/Views/bag/layout/main.php | 47 +- app/Views/bag/lg_dashboard.php | 1 + app/Views/bag/lg_dashboard_charts.php | 1 + app/Views/bag/lg_dashboard_compact.php | 212 ++ app/Views/bag/lg_dashboard_dense.php | 1 + app/Views/bag/lg_dashboard_modern.php | 1 + app/Views/bag/lg_dashboard_simple.php | 201 ++ app/Views/bag/order_lot_seed.php | 130 + app/Views/bag/order_phone.php | 454 +++ app/Views/bag/order_phone_manage.php | 261 ++ app/Views/bag/receiving_status.php | 2 +- app/Views/bag/waste_suibal_enterprise.php | 1 + app/Views/components/header_brand.php | 2 +- app/Views/components/print_header.php | 24 +- app/Views/home/dashboard.php | 1 + app/Views/welcome_message.php | 1 + composer.json | 1 + composer.lock | 423 ++- e2e/bag-site.spec.js | 32 +- e2e/menu-links-smoke.spec.js | 7 +- e2e/new-features.spec.js | 96 +- e2e/phase3-order.spec.js | 8 + e2e/phase5-reports.spec.js | 41 +- jobs.md | 13 +- .../database/after_company_feature_tables.sql | 51 +- .../database/bag_issue_item_code_table.sql | 22 + writable/database/bag_price_test_seed.sql | 31 +- ...blockchain_ledger_fix_comments_utf8mb4.sql | 20 + .../database/blockchain_ledger_tables.sql | 24 + .../code_detail_bag_o_missing_by_kind.sql | 38 + writable/database/code_master_init_daegu.sql | 22 +- .../database/designated_shop_ds_sa_idx.sql | 3 + .../designated_shop_ds_sa_idx_apply.sql | 16 + .../designated_shop_ds_sa_idx_backfill.sql | 29 + ...ix_bag_price_packaging_daegu_realistic.sql | 156 + ...ce_packaging_daegu_realistic_all_codes.sql | 296 ++ ...fix_menu_site_reports_links_bag_prefix.sql | 13 + ...x_packaging_unit_daegu_donggu_official.sql | 72 + .../free_recipient_reset_to_screenshot.sql | 70 + .../free_recipient_seed_from_screenshot.sql | 79 + ...ventory_inspection_pack_snapshot_table.sql | 22 + ...entory_inspection_sheet_snapshot_table.sql | 19 + .../database/inventory_inspection_tables.sql | 29 + .../manager_remote_realistic_update.sql | 66 + writable/database/menu_analytics_links.sql | 14 + .../database/menu_link_lot_seed_issue.sql | 11 + ...enu_site_fill_empty_second_level_links.sql | 11 +- .../menu_site_prune_inventory_submenus.sql | 20 + writable/database/menu_site_seed_from_csv.sql | 20 +- writable/database/menu_type_add_site.sql | 2 +- writable/database/order_tables.sql | 11 +- .../database/receiving_pack_code_tables.sql | 23 + writable/database/sales_tables.sql | 20 +- writable/database/seed_realistic_data.sql | 140 +- writable/database/shop_order_add_channel.sql | 10 + 129 files changed, 18068 insertions(+), 1585 deletions(-) create mode 100644 app/Libraries/BagAnalyticsReportBuilder.php create mode 100644 app/Libraries/BagFlowReportBuilder.php create mode 100644 app/Libraries/BagLotFlowBuilder.php create mode 100644 app/Libraries/BagSupplyPlanBuilder.php create mode 100644 app/Libraries/Blockchain/SqlLedger.php create mode 100644 app/Models/BagIssueItemCodeModel.php create mode 100644 app/Models/BlockchainLedgerModel.php create mode 100644 app/Views/admin/designated_shop/manage.php create mode 100644 app/Views/admin/sales_report/hometax_process.php create mode 100644 app/Views/bag/analytics_monthly_trend.php create mode 100644 app/Views/bag/analytics_seasonal_trend.php create mode 100644 app/Views/bag/analytics_yoy.php create mode 100644 app/Views/bag/dashboard_blend_lite_inner.php create mode 100644 app/Views/bag/designated_shop_return.php create mode 100644 app/Views/bag/designated_shop_return_cancel.php create mode 100644 app/Views/bag/designated_shop_sale.php create mode 100644 app/Views/bag/designated_shop_sale_return.php delete mode 100644 app/Views/bag/inventory_adjust.php create mode 100644 app/Views/bag/inventory_inspection_detail.php create mode 100644 app/Views/bag/lg_dashboard_compact.php create mode 100644 app/Views/bag/lg_dashboard_simple.php create mode 100644 app/Views/bag/order_lot_seed.php create mode 100644 app/Views/bag/order_phone.php create mode 100644 app/Views/bag/order_phone_manage.php create mode 100644 writable/database/bag_issue_item_code_table.sql create mode 100644 writable/database/blockchain_ledger_fix_comments_utf8mb4.sql create mode 100644 writable/database/blockchain_ledger_tables.sql create mode 100644 writable/database/code_detail_bag_o_missing_by_kind.sql create mode 100644 writable/database/designated_shop_ds_sa_idx.sql create mode 100644 writable/database/designated_shop_ds_sa_idx_apply.sql create mode 100644 writable/database/designated_shop_ds_sa_idx_backfill.sql create mode 100644 writable/database/fix_bag_price_packaging_daegu_realistic.sql create mode 100644 writable/database/fix_bag_price_packaging_daegu_realistic_all_codes.sql create mode 100644 writable/database/fix_menu_site_reports_links_bag_prefix.sql create mode 100644 writable/database/fix_packaging_unit_daegu_donggu_official.sql create mode 100644 writable/database/free_recipient_reset_to_screenshot.sql create mode 100644 writable/database/free_recipient_seed_from_screenshot.sql create mode 100644 writable/database/inventory_inspection_pack_snapshot_table.sql create mode 100644 writable/database/inventory_inspection_sheet_snapshot_table.sql create mode 100644 writable/database/inventory_inspection_tables.sql create mode 100644 writable/database/manager_remote_realistic_update.sql create mode 100644 writable/database/menu_analytics_links.sql create mode 100644 writable/database/menu_link_lot_seed_issue.sql create mode 100644 writable/database/menu_site_prune_inventory_submenus.sql create mode 100644 writable/database/receiving_pack_code_tables.sql create mode 100644 writable/database/shop_order_add_channel.sql diff --git a/CLAUDE.md b/CLAUDE.md index b887f9a..4fac10c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,8 @@ assets/ # 기획 문서 (엑셀) php spark serve --port=8045 ``` +- 로컬에서 **Apache + 여러 VirtualHost + PHP-FPM**을 쓰면 `localhost`·포트·FPM(예: 9001) 설정에 따라 500/503이 나기 쉽습니다. **일상 개발은 위 내장 서버를 기본**으로 두고, `.env`의 `app.baseURL`을 `http://localhost:8045/` 등과 맞추는 것을 권장합니다. + ## 테스트 (Playwright E2E) 모든 작업 완료 후 반드시 Playwright 브라우저 테스트를 수행합니다. diff --git a/README.md b/README.md index 6d25969..bf8baf1 100644 --- a/README.md +++ b/README.md @@ -190,12 +190,23 @@ assets/ # 기획 문서 (엑셀) | `/bag/code-kinds` | 기본코드 종류 | 종류 목록·세부코드 링크 (조회; CRUD는 관리자만) | | `/bag/code-details/{ck_idx}` | 기본코드 세부 | 해당 종류의 세부코드 목록 (조회; CRUD는 관리자만) | | `/bag/purchase-inbound` | 발주 입고 관리 | 발주/입고 목록 + 등록 버튼 | +| `/bag/order/create` | 발주 등록 | 발주서 신규 작성 | +| `/bag/order/change` | 발주 변경 | 발주 변경 목록/수정 진입 | +| `/bag/order/revise/{bo_idx}` | 발주 수정 | 선택 발주 수정 화면 | +| `/bag/order/lot-seed` | LOT-No 디스켓 불출 | 발주 LOT 기준 seed 생성/다운로드 | | `/bag/issue` | 불출 관리 | 불출 목록 + 처리/취소 | | `/bag/inventory` | 재고 관리 | 봉투별 현재 재고 조회 | | `/bag/sales` | 판매 관리 | 주문/판매/반품 + 등록 | +| `/bag/order/phone` | 전화 주문 접수 | 전화 주문 접수표 작성/저장 | +| `/bag/order/phone/manage` | 전화 주문 접수 관리 | 접수 리스트 선택 후 품목 수량 수정/취소 | +| `/bag/sale/designated` | 지정판매소 판매 | 주문 선택 + 바코드 스캔 + 판매 저장 | +| `/bag/receiving/batch` | 일괄 입고 | 미입고 건 선택 일괄 입고 | | `/bag/sales-stats` | 판매 현황 | 기간별 판매 데이터 | | `/bag/flow` | 봉투 수불 관리 | 봉투코드별 입출고 수불 요약 | -| `/bag/analytics` | 통계 분석 관리 | Phase 6 예정 | +| `/bag/analytics` | 통계 분석 (→ 전년 대비로 리다이렉트) | | +| `/bag/analytics/year-over-year` | 전년 대비 판매 분석 (w_gm604r) | | +| `/bag/analytics/monthly-trend` | 월별 판매 추이 분석 (w_gm606r) | | +| `/bag/analytics/seasonal-trend` | 계절별 판매 추이 분석 (w_gm607r) | | | `/bag/window` | 창 | Phase 6 예정 | | `/bag/help` | 도움말 | 시스템 안내 | @@ -249,6 +260,27 @@ assets/ # 기획 문서 (엑셀) --- +## 주문/판매 실무 흐름 (현재 구현) + +1. 전화 주문 접수: `/bag/order/phone` +2. 전화 주문 관리(수정/취소): `/bag/order/phone/manage` +3. 지정판매소 판매 처리(바코드 스캔): `/bag/sale/designated` +4. 판매/재고 반영: `bag_sale` 기록 + `bag_inventory` 차감 + 주문 수령상태(`so_received`) 갱신 + +--- + +## 바코드 생성/사용 시점 (현재 코드 기준) + +- **현재 코드 구현** + - 박스/팩/낱장 바코드는 `bag_receiving_pack_code`에 저장됩니다. + - 생성 트리거는 입고 처리(`receiving/scanner/store`, `receiving/batch/store`) 시점의 `createReceivingPackCodes()` 호출입니다. + - 판매 단계(`/bag/sale/designated`)에서는 생성된 코드를 스캔하여 `in_stock -> sold` 상태로 전환합니다. +- **요구사항 문서 관점** + - 노션 요구사항에는 발주 단계에서 바코드 원시데이터 생성 후 제작업체 인쇄 흐름이 명시되어 있습니다. + - 현재 구현과 요구사항 간 시점 차이가 존재하므로, 운영 정책 확정 후 발주 단계 생성으로 이관 검토가 필요합니다. + +--- + ## 모델 (25개) | 모델 | 테이블 | 용도 | diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 20404c5..b8df63f 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -7,11 +7,14 @@ use CodeIgniter\Router\RouteCollection; */ $routes->get('/', 'Home::index'); $routes->get('dashboard', 'Home::dashboard'); +$routes->get('dashboard/simple', 'Home::dashboardSimple'); +$routes->get('dashboard/compact', 'Home::dashboardCompact'); $routes->get('dashboard/classic-mock', 'Home::dashboardClassicMock'); $routes->get('dashboard/modern', 'Home::dashboardModern'); $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('bag/inventory-inquiry', 'Home::inventoryInquiry'); $routes->get('bag/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise'); @@ -41,18 +44,24 @@ $routes->post('bag/inventory/inspection/(:num)/apply', 'Bag::inspectionApply/$1' $routes->get('bag/sales', 'Bag::sales'); $routes->get('bag/sales-stats', 'Bag::salesStats'); $routes->get('bag/flow', 'Bag::flow'); +$routes->get('bag/flow/export', 'Bag::flowExport'); $routes->get('bag/analytics', 'Bag::analytics'); +$routes->get('bag/analytics/year-over-year', 'Bag::analyticsYearOverYear'); +$routes->get('bag/analytics/monthly-trend', 'Bag::analyticsMonthlyTrend'); +$routes->get('bag/analytics/seasonal-trend', 'Bag::analyticsSeasonalTrend'); $routes->get('bag/window', 'Bag::window'); $routes->get('bag/help', 'Bag::help'); // 사이트 메뉴 CRUD (사이트 레이아웃) -$routes->get('bag/inventory/adjust', 'Bag::inventoryAdjust'); -$routes->post('bag/inventory/adjust', 'Bag::inventoryAdjustStore'); $routes->get('bag/issue/create', 'Bag::issueCreate'); $routes->post('bag/issue/store', 'Bag::issueStore'); $routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1'); $routes->post('bag/issue/cancel-save', 'Bag::issueCancelSave'); $routes->get('bag/order/create', 'Bag::orderCreate'); +$routes->get('bag/order/phone', 'Bag::phoneOrderCreate'); +$routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage'); +$routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate'); +$routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1'); $routes->get('bag/order/change', 'Bag::orderChange'); $routes->get('bag/order/revise/(:num)', 'Bag::orderRevise/$1'); $routes->get('bag/order/lot-seed', 'Bag::orderLotSeed'); @@ -71,6 +80,18 @@ $routes->get('bag/receiving/status', 'Bag::receivingStatus'); $routes->get('bag/receiving/status/export', 'Bag::receivingStatusExport'); $routes->get('bag/sale/create', 'Bag::saleCreate'); $routes->post('bag/sale/store', 'Bag::saleStore'); +$routes->get('bag/sale/designated', 'Bag::designatedShopSaleCreate'); +$routes->get('bag/sale/designated/dev-saleable-barcodes', 'Bag::designatedShopDevSaleableBarcodes'); +$routes->get('bag/sale/dev-all-sales-history', 'Bag::devAllSalesHistory'); +$routes->post('bag/sale/designated/scan', 'Bag::designatedShopSaleScan'); +$routes->post('bag/sale/designated/save', 'Bag::designatedShopSaleSave'); +$routes->get('bag/sale/designated-return', 'Bag::designatedShopSaleReturnCreate'); +$routes->post('bag/sale/designated-return/scan', 'Bag::designatedShopSaleReturnScan'); +$routes->post('bag/sale/designated-return/save', 'Bag::designatedShopSaleReturnSave'); +$routes->get('bag/sale/designated-return-cancel', 'Bag::designatedShopSaleReturnCancelCreate'); +$routes->post('bag/sale/designated-return-cancel/save', 'Bag::designatedShopSaleReturnCancelSave'); +$routes->get('bag/sale/designated-cancel', 'Bag::designatedShopReturnCreate'); +$routes->post('bag/sale/designated-cancel/submit', 'Bag::designatedShopReturnCancel'); $routes->get('bag/shop-order/create', 'Bag::shopOrderCreate'); $routes->post('bag/shop-order/store', 'Bag::shopOrderStore'); @@ -175,9 +196,11 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void $routes->get('reports/shop-sales', 'Admin\SalesReport::shopSales'); $routes->get('reports/hometax-export', 'Admin\SalesReport::hometaxExport'); $routes->get('reports/returns', 'Admin\SalesReport::returns'); + $routes->get('reports/returns/export', 'Admin\SalesReport::returnsExport'); $routes->get('reports/lot-flow', 'Admin\SalesReport::lotFlow'); $routes->get('reports/misc-flow', 'Admin\SalesReport::miscFlow'); $routes->post('reports/misc-flow', 'Admin\SalesReport::miscFlowStore'); + $routes->post('reports/misc-flow/delete', 'Admin\SalesReport::miscFlowDelete'); $routes->get('password-change', 'Admin\PasswordChange::index'); $routes->post('password-change', 'Admin\PasswordChange::update'); diff --git a/app/Controllers/Admin/BagInventory.php b/app/Controllers/Admin/BagInventory.php index dfdb251..604cfa1 100644 --- a/app/Controllers/Admin/BagInventory.php +++ b/app/Controllers/Admin/BagInventory.php @@ -43,8 +43,9 @@ class BagInventory extends BaseController ]; } - export_csv( - '재고현황_' . date('Ymd') . '.csv', + export_xlsx( + '재고현황_' . date('Ymd') . '.xlsx', + '재고현황', ['번호', '봉투코드', '봉투명', '현재재고(낱장)', '최종갱신'], $rows ); diff --git a/app/Controllers/Admin/BagIssue.php b/app/Controllers/Admin/BagIssue.php index c8207d9..5053f7d 100644 --- a/app/Controllers/Admin/BagIssue.php +++ b/app/Controllers/Admin/BagIssue.php @@ -4,17 +4,56 @@ namespace App\Controllers\Admin; use App\Controllers\BaseController; use App\Models\BagIssueModel; +use App\Models\BagIssueItemCodeModel; use App\Models\BagInventoryModel; use App\Models\CodeKindModel; use App\Models\CodeDetailModel; +use App\Models\FreeRecipientModel; +use App\Models\PackagingUnitModel; class BagIssue extends BaseController { private BagIssueModel $issueModel; + private BagIssueItemCodeModel $issueItemCodeModel; public function __construct() { $this->issueModel = model(BagIssueModel::class); + $this->issueItemCodeModel = model(BagIssueItemCodeModel::class); + } + + /** + * 낱장 수량을 품목코드 단위로 분해한다. + * + * @return array + */ + private function buildIssueCodeRows(int $bi2Idx, int $sheetQty, array $packUnit): array + { + $sheetQty = max(0, $sheetQty); + if ($sheetQty <= 0) { + return []; + } + + $chunkSize = max( + 1, + (int) ($packUnit['totalPerBox'] ?? 0), + (int) ($packUnit['packPerSheet'] ?? 0) + ); + + $rows = []; + $remaining = $sheetQty; + $seq = 1; + while ($remaining > 0) { + $qty = min($chunkSize, $remaining); + $rows[] = [ + 'issueCode' => sprintf('%d-%06d-%03d', (int) date('y'), $bi2Idx, $seq), + 'qty' => $qty, + ]; + $remaining -= $qty; + $seq++; + } + + return $rows; } public function index() @@ -62,48 +101,219 @@ class BagIssue extends BaseController 'bi2_issue_type' => 'required|max_length[20]', 'bi2_issue_date' => 'required|valid_date[Y-m-d]', 'bi2_dest_name' => 'required|max_length[100]', - 'bi2_bag_code' => 'required|max_length[50]', - 'bi2_qty' => 'required|is_natural_no_zero', + // 사이트 다건 입력(item_bag_code/item_qty)과 기존 관리자 단건 입력을 함께 허용 + 'bi2_bag_code' => 'permit_empty|max_length[50]', + 'bi2_qty' => 'permit_empty|is_natural_no_zero', ]; if (! $this->validate($rules)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } - $bagCode = $this->request->getPost('bi2_bag_code'); - $qty = (int) $this->request->getPost('bi2_qty'); - $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); - $detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $bagCode, $lgIdx) : null; - $bagName = $detail ? $detail->cd_name : ''; + $issueType = trim((string) $this->request->getPost('bi2_issue_type')); + $destType = trim((string) ($this->request->getPost('bi2_dest_type') ?? '')); + $destName = trim((string) ($this->request->getPost('bi2_dest_name') ?? '')); + $destDongCode = trim((string) ($this->request->getPost('bi2_dest_dong_code') ?? '')); + + if ($destType === '') { + $destType = '동사무소'; + } + if ($issueType === '공공용' && $destType === '동사무소') { + $destType = '구청'; + } + + if ($issueType === '무료용' && $destDongCode !== '') { + $existsFreeDong = model(FreeRecipientModel::class) + ->where('fr_lg_idx', $lgIdx) + ->where('fr_state', 1) + ->where('fr_dong_code', $destDongCode) + ->first(); + if (! $existsFreeDong) { + return redirect()->back()->withInput()->with('error', '선택한 불출처는 무료용 대상이 아닙니다.'); + } + } + + $invRows = model(BagInventoryModel::class) + ->where('bi_lg_idx', $lgIdx) + ->where('bi_qty >', 0) + ->findAll(); + $inventoryMap = []; + foreach ($invRows as $inv) { + $code = (string) ($inv->bi_bag_code ?? ''); + if ($code === '') { + continue; + } + $inventoryMap[$code] = [ + 'qty' => (int) ($inv->bi_qty ?? 0), + 'name' => (string) ($inv->bi_bag_name ?? ''), + ]; + } + + $unitRows = model(PackagingUnitModel::class) + ->where('pu_lg_idx', $lgIdx) + ->where('pu_state', 1) + ->findAll(); + $packMap = []; + foreach ($unitRows as $unit) { + $code = (string) ($unit->pu_bag_code ?? ''); + if ($code === '') { + continue; + } + $packMap[$code] = [ + 'packPerSheet' => max(1, (int) ($unit->pu_pack_per_sheet ?? 1)), + 'totalPerBox' => max(1, (int) ($unit->pu_total_per_box ?? 1)), + ]; + } + + $items = []; + $itemCodes = $this->request->getPost('item_bag_code'); + $itemQtys = $this->request->getPost('item_qty'); + $itemPacks = $this->request->getPost('item_pack'); + $itemCodes = is_array($itemCodes) ? $itemCodes : []; + $itemQtys = is_array($itemQtys) ? $itemQtys : []; + $itemPacks = is_array($itemPacks) ? $itemPacks : []; + + $count = max(count($itemCodes), count($itemQtys), count($itemPacks)); + for ($i = 0; $i < $count; $i++) { + $bagCode = trim((string) ($itemCodes[$i] ?? '')); + $qtyRaw = (int) ($itemQtys[$i] ?? 0); + $pack = trim((string) ($itemPacks[$i] ?? 'sheet')); + if ($bagCode === '' || $qtyRaw <= 0) { + continue; + } + if (! in_array($pack, ['box', 'pack', 'sheet'], true)) { + $pack = 'sheet'; + } + $packUnit = $packMap[$bagCode] ?? ['packPerSheet' => 1, 'totalPerBox' => 1]; + $sheetQty = $qtyRaw; + if ($pack === 'box') { + $sheetQty = $qtyRaw * (int) $packUnit['totalPerBox']; + } elseif ($pack === 'pack') { + $sheetQty = $qtyRaw * (int) $packUnit['packPerSheet']; + } + $sheetQty = max(1, (int) $sheetQty); + + $detail = $kindO + ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, $bagCode, $lgIdx) + : null; + $bagName = $detail ? (string) ($detail->cd_name ?? '') : (string) ($inventoryMap[$bagCode]['name'] ?? ''); + if ($bagName === '') { + $bagName = (string) $bagCode; + } + + $items[] = [ + 'bagCode' => $bagCode, + 'bagName' => $bagName, + 'pack' => $pack, + 'rawQty' => $qtyRaw, + 'sheetQty' => $sheetQty, + ]; + } + + // 기존 관리자 단건 폼과의 호환 + if ($items === []) { + $singleBagCode = trim((string) $this->request->getPost('bi2_bag_code')); + $singleQty = (int) $this->request->getPost('bi2_qty'); + if ($singleBagCode !== '' && $singleQty > 0) { + $detail = $kindO + ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, $singleBagCode, $lgIdx) + : null; + $bagName = $detail ? (string) ($detail->cd_name ?? '') : (string) ($inventoryMap[$singleBagCode]['name'] ?? ''); + if ($bagName === '') { + $bagName = (string) $singleBagCode; + } + $items[] = [ + 'bagCode' => $singleBagCode, + 'bagName' => $bagName, + 'pack' => 'sheet', + 'rawQty' => $singleQty, + 'sheetQty' => $singleQty, + ]; + } + } + + if ($items === []) { + return redirect()->back()->withInput()->with('error', '불출 품목을 1건 이상 입력해 주세요.'); + } + + $requiredByBag = []; + foreach ($items as $item) { + $code = (string) $item['bagCode']; + if (! isset($requiredByBag[$code])) { + $requiredByBag[$code] = 0; + } + $requiredByBag[$code] += (int) $item['sheetQty']; + } + foreach ($requiredByBag as $code => $requiredQty) { + $available = (int) ($inventoryMap[$code]['qty'] ?? 0); + if ($available <= 0) { + return redirect()->back()->withInput()->with('error', '입고 재고가 없는 봉투코드는 불출할 수 없습니다: ' . $code); + } + if ($available < $requiredQty) { + return redirect()->back()->withInput()->with('error', '재고가 부족합니다: ' . $code . ' (재고 ' . number_format($available) . ', 요청 ' . number_format($requiredQty) . ')'); + } + } $db = \Config\Database::connect(); $db->transStart(); + $hasIssueCodeTable = $db->tableExists('bag_issue_item_code'); - $issueData = [ - 'bi2_lg_idx' => $lgIdx, - 'bi2_year' => (int) $this->request->getPost('bi2_year'), - 'bi2_quarter' => (int) $this->request->getPost('bi2_quarter'), - 'bi2_issue_type' => $this->request->getPost('bi2_issue_type'), - 'bi2_issue_date' => $this->request->getPost('bi2_issue_date'), - 'bi2_dest_type' => $this->request->getPost('bi2_dest_type') ?? '', - 'bi2_dest_name' => $this->request->getPost('bi2_dest_name'), - 'bi2_bag_code' => $bagCode, - 'bi2_bag_name' => $bagName, - 'bi2_qty' => $qty, - 'bi2_status' => 'normal', - 'bi2_regdate' => date('Y-m-d H:i:s'), - ]; - $this->issueModel->insert($issueData); - $bi2Idx = (int) $this->issueModel->getInsertID(); - + $issueYear = (int) $this->request->getPost('bi2_year'); + $issueQuarter = (int) $this->request->getPost('bi2_quarter'); + $issueDate = (string) $this->request->getPost('bi2_issue_date'); + $createdCount = 0; helper('audit'); - audit_log('create', 'bag_issue', $bi2Idx, null, array_merge($issueData, ['bi2_idx' => $bi2Idx])); + foreach ($items as $item) { + $issueData = [ + 'bi2_lg_idx' => $lgIdx, + 'bi2_year' => $issueYear, + 'bi2_quarter' => $issueQuarter, + 'bi2_issue_type' => $issueType, + 'bi2_issue_date' => $issueDate, + 'bi2_dest_type' => $destType, + 'bi2_dest_name' => $destName, + 'bi2_bag_code' => (string) $item['bagCode'], + 'bi2_bag_name' => (string) $item['bagName'], + 'bi2_qty' => (int) $item['sheetQty'], + 'bi2_status' => 'normal', + 'bi2_regdate' => date('Y-m-d H:i:s'), + ]; + $this->issueModel->insert($issueData); + $bi2Idx = (int) $this->issueModel->getInsertID(); + audit_log('create', 'bag_issue', $bi2Idx, null, array_merge($issueData, ['bi2_idx' => $bi2Idx])); - model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, -$qty); + if ($hasIssueCodeTable) { + $codeRows = $this->buildIssueCodeRows($bi2Idx, (int) $item['sheetQty'], $packMap[(string) $item['bagCode']] ?? []); + foreach ($codeRows as $codeRow) { + $this->issueItemCodeModel->insert([ + 'bic_lg_idx' => $lgIdx, + 'bic_bi2_idx' => $bi2Idx, + 'bic_bag_code' => (string) $item['bagCode'], + 'bic_issue_code' => (string) $codeRow['issueCode'], + 'bic_qty' => (int) $codeRow['qty'], + 'bic_cancel_qty' => 0, + 'bic_state' => 'normal', + 'bic_regdate' => date('Y-m-d H:i:s'), + ]); + } + } + + model(BagInventoryModel::class)->adjustQty( + $lgIdx, + (string) $item['bagCode'], + (string) $item['bagName'], + -((int) $item['sheetQty']) + ); + $createdCount++; + } $db->transComplete(); - return redirect()->to(mgmt_url('bag-issues'))->with('success', '불출 처리되었습니다.'); + if (! $db->transStatus()) { + return redirect()->back()->withInput()->with('error', '불출 처리 중 오류가 발생했습니다.'); + } + + return redirect()->to(mgmt_url('bag-issues'))->with('success', $createdCount . '건 불출 처리되었습니다.'); } public function cancel(int $id) @@ -116,12 +326,38 @@ class BagIssue extends BaseController $db = \Config\Database::connect(); $db->transStart(); + $hasIssueCodeTable = $db->tableExists('bag_issue_item_code'); $before = (array) $item; $this->issueModel->update($id, ['bi2_status' => 'cancelled']); helper('audit'); audit_log('update', 'bag_issue', $id, $before, ['bi2_status' => 'cancelled']); - model(BagInventoryModel::class)->adjustQty((int) $item->bi2_lg_idx, $item->bi2_bag_code, $item->bi2_bag_name, (int) $item->bi2_qty); + + $restoreQty = (int) $item->bi2_qty; + if ($hasIssueCodeTable) { + $codeRows = $db->table('bag_issue_item_code') + ->select('bic_idx, bic_qty, bic_cancel_qty') + ->where('bic_lg_idx', (int) $item->bi2_lg_idx) + ->where('bic_bi2_idx', $id) + ->get() + ->getResultArray(); + $restoreQty = 0; + foreach ($codeRows as $codeRow) { + $bicIdx = (int) ($codeRow['bic_idx'] ?? 0); + $qty = (int) ($codeRow['bic_qty'] ?? 0); + $oldCancel = (int) ($codeRow['bic_cancel_qty'] ?? 0); + $restoreQty += max(0, $qty - $oldCancel); + $db->table('bag_issue_item_code') + ->where('bic_idx', $bicIdx) + ->update([ + 'bic_cancel_qty' => $qty, + 'bic_state' => 'cancelled', + ]); + } + } + + model(BagInventoryModel::class)->adjustQty((int) $item->bi2_lg_idx, $item->bi2_bag_code, $item->bi2_bag_name, $restoreQty); + $this->issueModel->update($id, ['bi2_qty' => 0, 'bi2_status' => 'cancelled']); $db->transComplete(); diff --git a/app/Controllers/Admin/BagPrice.php b/app/Controllers/Admin/BagPrice.php index 651df81..e7792f9 100644 --- a/app/Controllers/Admin/BagPrice.php +++ b/app/Controllers/Admin/BagPrice.php @@ -138,11 +138,7 @@ class BagPrice extends BaseController if ($bagCode !== null && $bagCode !== '') { $queryForPager['bag_code'] = $bagCode; } - $pagerPath = mgmt_url('bag-prices'); - if ($queryForPager !== []) { - $pagerPath .= '?' . http_build_query($queryForPager); - } - $this->priceModel->pager->setPath($pagerPath); + apply_pager_path($this->priceModel->pager, mgmt_path('bag-prices'), $queryForPager); $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $bagCodes = $kindO diff --git a/app/Controllers/Admin/Company.php b/app/Controllers/Admin/Company.php index 8548dac..aebddf5 100644 --- a/app/Controllers/Admin/Company.php +++ b/app/Controllers/Admin/Company.php @@ -42,11 +42,7 @@ class Company extends BaseController if ($companyType !== '' && in_array($companyType, $typeOptions, true)) { $queryForPager['cp_type'] = $companyType; } - $pagerPath = mgmt_url('companies'); - if ($queryForPager !== []) { - $pagerPath .= '?' . http_build_query($queryForPager); - } - $pager->setPath($pagerPath); + apply_pager_path($pager, mgmt_path('companies'), $queryForPager); return $this->renderWorkPage('업체 관리', 'admin/company/index', [ 'list' => $list, diff --git a/app/Controllers/Admin/Dashboard.php b/app/Controllers/Admin/Dashboard.php index afa3467..fb03f36 100644 --- a/app/Controllers/Admin/Dashboard.php +++ b/app/Controllers/Admin/Dashboard.php @@ -40,7 +40,10 @@ class Dashboard extends BaseController FROM bag_order_item GROUP BY boi_bo_idx ) sub ON sub.boi_bo_idx = bo.bo_idx WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal' - ", [$lgIdx])->getRow(); + AND (bo.bo_uuid, bo.bo_version) IN ( + SELECT bo_uuid, MAX(bo_version) FROM bag_order WHERE bo_lg_idx = ? GROUP BY bo_uuid + ) + ", [$lgIdx, $lgIdx])->getRow(); $stats['order_count'] = (int) ($orderStats->cnt ?? 0); $stats['order_amount'] = (int) ($orderStats->total_amount ?? 0); @@ -72,9 +75,12 @@ class Dashboard extends BaseController SELECT bo_idx, bo_lot_no, bo_order_date, bo_status FROM bag_order WHERE bo_lg_idx = ? + AND (bo_uuid, bo_version) IN ( + SELECT bo_uuid, MAX(bo_version) FROM bag_order WHERE bo_lg_idx = ? GROUP BY bo_uuid + ) ORDER BY bo_order_date DESC, bo_idx DESC LIMIT 5 - ", [$lgIdx])->getResult(); + ", [$lgIdx, $lgIdx])->getResult(); // 최근 판매 5건 $stats['recent_sales'] = $db->query(" diff --git a/app/Controllers/Admin/FreeRecipient.php b/app/Controllers/Admin/FreeRecipient.php index dd8da1b..bebf518 100644 --- a/app/Controllers/Admin/FreeRecipient.php +++ b/app/Controllers/Admin/FreeRecipient.php @@ -16,6 +16,19 @@ class FreeRecipient extends BaseController $this->model = model(FreeRecipientModel::class); } + /** + * 무료용 대상 구분(스크린샷 기준): 사람뿐 아니라 동사무소 자체도 등록 가능. + * + * @return array + */ + private function recipientTypeOptions(): array + { + return [ + 'office' => '읍.면.동 사무소', + 'target' => '무료 대상자', + ]; + } + private function getCodeOptions(string $ckCode): array { helper('admin'); @@ -33,16 +46,42 @@ class FreeRecipient extends BaseController return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } - $list = $this->model->where('fr_lg_idx', $lgIdx)->orderBy('fr_idx', 'DESC')->paginate(20); + $list = $this->model + ->where('fr_lg_idx', $lgIdx) + ->orderBy('fr_type_code', 'ASC') + ->orderBy('fr_name', 'ASC') + ->orderBy('fr_idx', 'DESC') + ->paginate(20); $pager = $this->model->pager; + $perPage = 20; + $currentPage = (int) ($pager->getCurrentPage() ?: 1); + $totalCount = (int) $this->model + ->where('fr_lg_idx', $lgIdx) + ->countAllResults(); + $dongNameMap = []; + foreach ($this->getCodeOptions('D') as $dong) { + $code = (string) ($dong->cd_code ?? ''); + if ($code === '') { + continue; + } + $dongNameMap[$code] = (string) ($dong->cd_name ?? $code); + } - return $this->renderWorkPage('무료용 대상자 관리', 'admin/free_recipient/index', ['list' => $list, 'pager' => $pager]); + return $this->renderWorkPage('무료용 대상자 관리', 'admin/free_recipient/index', [ + 'list' => $list, + 'pager' => $pager, + 'recipientTypeOptions' => $this->recipientTypeOptions(), + 'dongNameMap' => $dongNameMap, + 'totalCount' => $totalCount, + 'currentPage' => $currentPage, + 'perPage' => $perPage, + ]); } public function create() { return $this->renderWorkPage('무료용 대상자 등록', 'admin/free_recipient/create', [ - 'typeCodes' => $this->getCodeOptions('H'), + 'recipientTypeOptions' => $this->recipientTypeOptions(), 'dongCodes' => $this->getCodeOptions('D'), ]); } @@ -85,7 +124,7 @@ class FreeRecipient extends BaseController return $this->renderWorkPage('무료용 대상자 수정', 'admin/free_recipient/edit', [ 'item' => $item, - 'typeCodes' => $this->getCodeOptions('H'), + 'recipientTypeOptions' => $this->recipientTypeOptions(), 'dongCodes' => $this->getCodeOptions('D'), ]); } diff --git a/app/Controllers/Admin/Menu.php b/app/Controllers/Admin/Menu.php index f1b177e..42fd738 100644 --- a/app/Controllers/Admin/Menu.php +++ b/app/Controllers/Admin/Menu.php @@ -63,6 +63,11 @@ class Menu extends BaseController } } + if ($effectiveMtIdx > 0 && $currentTypeCode === 'site') { + $this->menuModel->pruneInventoryManagementMenus($effectiveMtIdx, $lgIdx); + $list = $this->menuModel->getAllByType($effectiveMtIdx, $lgIdx); + } + // 트리 순서대로 상위 메뉴 바로 아래에 하위 메뉴가 오도록 평탄화 if (! empty($list)) { $tree = build_menu_tree($list); @@ -109,6 +114,10 @@ class Menu extends BaseController if ($mtIdx <= 0) { return $this->response->setJSON(['status' => 0, 'msg' => 'mt_idx required']); } + $type = $this->typeModel->find($mtIdx); + if ($type && (string) ($type->mt_code ?? '') === 'site') { + $this->menuModel->pruneInventoryManagementMenus($mtIdx, $lgIdx); + } $list = $this->menuModel->getAllByType($mtIdx, $lgIdx); return $this->response->setJSON(['status' => 1, 'data' => $list]); } @@ -153,6 +162,7 @@ class Menu extends BaseController if ($mmPidx > 0) { $this->menuModel->updateCnode($mmPidx, 1); } + $this->menuModel->pruneInventoryManagementMenus($mtIdx, $lgIdx); $this->menuModel->syncTypeToAllLgs($mtIdx, $lgIdx); return redirect()->back()->with('success', '메뉴가 등록되었습니다.'); } @@ -184,6 +194,7 @@ class Menu extends BaseController 'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N', ]; $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', '메뉴가 수정되었습니다.'); } @@ -207,6 +218,7 @@ class Menu extends BaseController } $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', '메뉴가 삭제되었습니다.'); } @@ -234,6 +246,7 @@ class Menu extends BaseController $firstRow = $firstId > 0 ? $this->menuModel->find($firstId) : null; $this->menuModel->setOrder($ids, $lgIdx); if ($firstRow && (int) $firstRow->lg_idx === $lgIdx) { + $this->menuModel->pruneInventoryManagementMenus((int) $firstRow->mt_idx, $lgIdx); $this->menuModel->syncTypeToAllLgs((int) $firstRow->mt_idx, $lgIdx); } return redirect()->back()->with('success', '순서가 적용되었습니다.'); diff --git a/app/Controllers/Admin/SalesAgency.php b/app/Controllers/Admin/SalesAgency.php index ef8f5f5..de29fed 100644 --- a/app/Controllers/Admin/SalesAgency.php +++ b/app/Controllers/Admin/SalesAgency.php @@ -55,11 +55,7 @@ class SalesAgency extends BaseController 'sa_idx' => $saIdx, ]; $queryForPager = array_filter($queryForPager, static fn ($v) => $v !== null && $v !== ''); - $pagerPath = mgmt_url('sales-agencies'); - if ($queryForPager !== []) { - $pagerPath .= '?' . http_build_query($queryForPager); - } - $pager->setPath($pagerPath); + apply_pager_path($pager, mgmt_path('sales-agencies'), $queryForPager); return $this->renderWorkPage('판매 대행소 관리', 'admin/sales_agency/index', [ 'list' => $list, diff --git a/app/Controllers/Admin/SalesReport.php b/app/Controllers/Admin/SalesReport.php index dbdb658..47a34f7 100644 --- a/app/Controllers/Admin/SalesReport.php +++ b/app/Controllers/Admin/SalesReport.php @@ -3,198 +3,2249 @@ namespace App\Controllers\Admin; use App\Controllers\BaseController; -use App\Models\BagSaleModel; use App\Models\BagIssueModel; use App\Models\BagReceivingModel; use App\Models\BagInventoryModel; +use App\Models\CodeDetailModel; +use App\Models\CodeKindModel; +use App\Models\PackagingUnitModel; +use App\Models\DesignatedShopModel; +use App\Models\LocalGovernmentModel; +use App\Models\SalesAgencyModel; class SalesReport extends BaseController { /** - * P5-01: 판매 대장 (일자별/기간별) + * P5-01 / PWB-090101-001: 지정 판매소 판매 대장 (일자별·기간별, 엑셀·인쇄) */ public function salesLedger() { - helper('admin'); + helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } - $startDate = $this->request->getGet('start_date') ?? date('Y-m-01'); - $endDate = $this->request->getGet('end_date') ?? date('Y-m-d'); - $mode = $this->request->getGet('mode') ?? 'daily'; // daily or period + $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); + $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); + $modeRaw = (string) ($this->request->getGet('mode') ?? 'daily'); + $mode = $modeRaw === 'period' ? 'period' : 'daily'; + $dsIdx = (int) ($this->request->getGet('ds_idx') ?? 0); + $saIdx = (int) ($this->request->getGet('sa_idx') ?? 0); + $export = (int) ($this->request->getGet('export') ?? 0) === 1; - $saleModel = model(BagSaleModel::class); - $db = \Config\Database::connect(); - - if ($mode === 'daily') { - $result = $db->query(" - SELECT bs_sale_date, bs_ds_name, bs_bag_code, bs_bag_name, bs_type, - SUM(ABS(bs_qty)) as total_qty, - SUM(bs_amount) as total_amount - FROM bag_sale - WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ? AND bs_type IN('sale','return') - GROUP BY bs_sale_date, bs_ds_name, bs_bag_code, bs_bag_name, bs_type - ORDER BY bs_sale_date DESC, bs_ds_name, bs_bag_code - ", [$lgIdx, $startDate, $endDate])->getResult(); - } else { - $result = $db->query(" - SELECT bs_ds_name, bs_bag_code, bs_bag_name, - SUM(CASE WHEN bs_type='sale' THEN ABS(bs_qty) ELSE 0 END) as sale_qty, - SUM(CASE WHEN bs_type='sale' THEN bs_amount ELSE 0 END) as sale_amount, - SUM(CASE WHEN bs_type='return' THEN ABS(bs_qty) ELSE 0 END) as return_qty, - SUM(CASE WHEN bs_type='return' THEN bs_amount ELSE 0 END) as return_amount - FROM bag_sale - WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ? - GROUP BY bs_ds_name, bs_bag_code, bs_bag_name - ORDER BY bs_ds_name, bs_bag_code - ", [$lgIdx, $startDate, $endDate])->getResult(); + $catGet = $this->request->getGet('cat'); + $cats = is_array($catGet) ? array_values(array_filter(array_map('strval', $catGet))) : []; + if ($cats === [] || in_array('all', $cats, true)) { + $cats = []; } - return $this->renderWorkPage('판매 대장', 'admin/sales_report/sales_ledger', compact('result', 'startDate', 'endDate', 'mode')); + $db = \Config\Database::connect(); + $hasBsFee = $db->fieldExists('bs_fee', 'bag_sale'); + + $lgRow = model(LocalGovernmentModel::class)->find($lgIdx); + $lgName = $lgRow ? (string) ($lgRow->lg_name ?? '') : ''; + + $shops = model(DesignatedShopModel::class) + ->where('ds_lg_idx', $lgIdx) + ->orderBy('ds_name', 'ASC') + ->orderBy('ds_shop_no', 'ASC') + ->findAll(); + + $agencies = model(SalesAgencyModel::class) + ->where('sa_lg_idx', $lgIdx) + ->orderForDisplay() + ->findAll(); + + $hasDsSaIdx = $db->fieldExists('ds_sa_idx', 'designated_shop'); + $effectiveSaIdx = ($hasDsSaIdx && $saIdx > 0) ? $saIdx : 0; + + $detailRows = $this->fetchSalesLedgerDetailRows( + $db, + $lgIdx, + $startDate, + $endDate, + $dsIdx, + $effectiveSaIdx, + $hasBsFee + ); + + $filtered = []; + foreach ($detailRows as $row) { + if ($this->ledgerRowMatchesCategories($row, $cats)) { + $filtered[] = $row; + } + } + + if ($mode === 'daily') { + $built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee); + } else { + $built = $this->buildPeriodLedgerPresentation($filtered, $hasBsFee); + } + + if ($export) { + $headers = $mode === 'daily' + ? ['일자', '지정번호', '판매소명', '대표자', '소재지', '품명', '판매량', '판매금액', '수수료', '총액', '구분'] + : ['지정번호', '판매소명', '대표자', '소재지', '품명', '판매량', '판매금액', '수수료', '총액', '구분']; + + $exportRows = []; + foreach ($built['rows'] as $r) { + $kind = (string) ($r['kind'] ?? 'data'); + $label = $kind === 'subtotal' ? '소계' : ($kind === 'grand' ? '합계' : ''); + if ($mode === 'daily') { + $exportRows[] = [ + (string) ($r['sale_date'] ?? ''), + (string) ($r['designation_no'] ?? ''), + (string) ($r['shop_name'] ?? ''), + (string) ($r['rep_name'] ?? ''), + (string) ($r['address'] ?? ''), + (string) ($r['product_name'] ?? ''), + (string) ($r['qty'] ?? ''), + (string) ($r['amount'] ?? ''), + (string) ($r['fee'] ?? ''), + (string) ($r['total'] ?? ''), + $label, + ]; + } else { + $exportRows[] = [ + (string) ($r['designation_no'] ?? ''), + (string) ($r['shop_name'] ?? ''), + (string) ($r['rep_name'] ?? ''), + (string) ($r['address'] ?? ''), + (string) ($r['product_name'] ?? ''), + (string) ($r['qty'] ?? ''), + (string) ($r['amount'] ?? ''), + (string) ($r['fee'] ?? ''), + (string) ($r['total'] ?? ''), + $label, + ]; + } + } + + $sheetName = $mode === 'daily' ? '일자별 판매대장' : '기간별 판매대장'; + export_excel_2003_xml( + '지정판매소_판매대장_' . $startDate . '_' . $endDate, + $sheetName, + $headers, + $exportRows + ); + } + + $shopLabel = '전체'; + if ($dsIdx > 0) { + foreach ($shops as $s) { + if ((int) ($s->ds_idx ?? 0) === $dsIdx) { + $shopLabel = (string) ($s->ds_name ?? ''); + break; + } + } + } + + $agencyModel = model(SalesAgencyModel::class); + $hasKindCode = $agencyModel->hasKindCodeColumns(); + $agencyLabel = '전체'; + if ($saIdx > 0) { + foreach ($agencies as $a) { + if ((int) ($a->sa_idx ?? 0) === $saIdx) { + $agencyLabel = $hasKindCode + ? trim('[' . (string) ($a->sa_kind ?? '') . '] ' . (string) ($a->sa_code ?? '') . ' ' . (string) ($a->sa_name ?? '')) + : trim((string) ($a->sa_name ?? '')); + break; + } + } + } + + $printSubtitleLines = [ + trim($lgName . ' / 지정판매소: ' . $shopLabel . ' / 대행소: ' . $agencyLabel), + '조회기간: ' . $startDate . ' ~ ' . $endDate . ' · (단위: 매 / 원)', + ]; + + return $this->renderWorkPage('지정 판매소 판매 대장', 'admin/sales_report/sales_ledger', [ + 'ledgerRows' => $built['rows'], + 'saleLineCount' => $built['saleLineCount'], + 'startDate' => $startDate, + 'endDate' => $endDate, + 'mode' => $mode, + 'dsIdx' => $dsIdx, + 'saIdx' => $saIdx, + 'cats' => $cats, + 'shops' => $shops, + 'agencies' => $agencies, + 'lgName' => $lgName, + 'filterShopLabel' => $shopLabel, + 'filterAgencyLabel' => $agencyLabel, + 'printSubtitleLines' => $printSubtitleLines, + ]); } /** - * P5-02: 일계표 + * @return list + */ + private function fetchSalesLedgerDetailRows( + \CodeIgniter\Database\BaseConnection $db, + int $lgIdx, + string $startDate, + string $endDate, + int $dsIdx, + int $saIdx, + bool $hasBsFee + ): array { + $feeExpr = $hasBsFee ? 'COALESCE(bs.bs_fee, 0)' : '0'; + + $select = 'bs.bs_sale_date, bs.bs_ds_idx, bs.bs_bag_code, bs.bs_bag_name, ' + . 'ABS(bs.bs_qty) AS line_qty, bs.bs_amount AS line_amount, ' . $feeExpr . ' AS line_fee, ' + . 'COALESCE(ds.ds_shop_no, \'\') AS ds_shop_no, COALESCE(ds.ds_name, bs.bs_ds_name) AS ds_name, ' + . 'COALESCE(ds.ds_rep_name, \'\') AS ds_rep_name, ' + . "TRIM(CONCAT(COALESCE(ds.ds_addr,''), ' ', COALESCE(ds.ds_addr_detail,''))) AS ds_addr, " + . 'COALESCE(ds.ds_zone_code, \'\') AS ds_zone_code, COALESCE(ds.ds_gugun_code, \'\') AS ds_gugun_code'; + + $builder = $db->table('bag_sale bs'); + $builder->select($select, false); + $builder->join('designated_shop ds', 'ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx', 'left'); + $builder->where('bs.bs_lg_idx', $lgIdx); + $builder->where('bs.bs_sale_date >=', $startDate); + $builder->where('bs.bs_sale_date <=', $endDate); + $builder->where('bs.bs_type', 'sale'); + if ($dsIdx > 0) { + $builder->where('bs.bs_ds_idx', $dsIdx); + } + if ($saIdx > 0) { + $builder->where('ds.ds_sa_idx', $saIdx); + } + $builder->orderBy('bs.bs_sale_date', 'ASC'); + $builder->orderBy('ds.ds_shop_no', 'ASC'); + $builder->orderBy('bs.bs_ds_idx', 'ASC'); + $builder->orderBy('bs.bs_bag_code', 'ASC'); + + return $builder->get()->getResult(); + } + + /** + * @param list $cats 빈 배열이면 전체 + */ + private function ledgerRowMatchesCategories(object $row, array $cats): bool + { + if ($cats === []) { + return true; + } + $name = (string) ($row->bs_bag_name ?? ''); + $code = (string) ($row->bs_bag_code ?? ''); + $cat = $this->classifyLedgerProductCategory($name, $code); + + return in_array($cat, $cats, true); + } + + private function classifyLedgerProductCategory(string $bagName, string $bagCode): string + { + $s = $bagName . ' ' . $bagCode; + if (mb_strpos($s, '스티커') !== false) { + return 'sticker'; + } + if (mb_strpos($s, '용기') !== false) { + return 'container'; + } + if (mb_strpos($s, '공동주택') !== false) { + return 'apt'; + } + if (mb_strpos($s, '공공') !== false) { + return 'public_use'; + } + if (mb_strpos($s, '재사용') !== false) { + return 'reuse'; + } + if (mb_strpos($s, '음식물') !== false) { + return 'food'; + } + if (mb_strpos($s, '폐기물') !== false) { + return 'waste'; + } + + return 'general'; + } + + /** + * @param list $rows + * @return array{rows: list>, saleLineCount: int} + */ + private function buildDailyLedgerPresentation(array $rows, bool $hasBsFee): array + { + $out = []; + $saleLineCount = count($rows); + $grandQty = $grandAmt = $grandFee = $grandTot = 0; + + $prevKey = null; + $subQty = $subAmt = $subFee = $subTot = 0; + + $flushSubtotal = static function () use (&$out, &$subQty, &$subAmt, &$subFee, &$subTot): void { + if ($subQty === 0 && $subAmt === 0 && $subFee === 0 && $subTot === 0) { + return; + } + $out[] = [ + 'kind' => 'subtotal', + 'sale_date' => '', + 'designation_no' => '', + 'shop_name' => '', + 'rep_name' => '', + 'address' => '', + 'product_name' => '소 계', + 'qty' => number_format($subQty), + 'amount' => number_format((int) round($subAmt)), + 'fee' => number_format((int) round($subFee)), + 'total' => number_format((int) round($subTot)), + ]; + $subQty = $subAmt = $subFee = $subTot = 0; + }; + + foreach ($rows as $row) { + $date = (string) $row->bs_sale_date; + $dsId = (int) ($row->bs_ds_idx ?? 0); + $key = $date . '|' . $dsId; + if ($prevKey !== null && $key !== $prevKey) { + $flushSubtotal(); + } + $prevKey = $key; + + $qty = (int) round((float) ($row->line_qty ?? 0)); + $amt = (float) ($row->line_amount ?? 0); + $fee = $hasBsFee ? (float) ($row->line_fee ?? 0) : 0.0; + $total = $amt + $fee; + + $designation = $this->formatDesignationNo( + (string) ($row->ds_zone_code ?? ''), + (string) ($row->ds_gugun_code ?? ''), + (string) ($row->ds_shop_no ?? '') + ); + + $out[] = [ + 'kind' => 'data', + 'sale_date' => $date, + 'designation_no' => $designation, + 'shop_name' => (string) ($row->ds_name ?? ''), + 'rep_name' => (string) ($row->ds_rep_name ?? ''), + 'address' => trim((string) ($row->ds_addr ?? '')), + 'product_name' => (string) ($row->bs_bag_name ?? ''), + 'qty' => number_format($qty), + 'amount' => number_format((int) round($amt)), + 'fee' => $fee != 0.0 ? number_format((int) round($fee)) : '', + 'total' => number_format((int) round($total)), + ]; + + $subQty += $qty; + $subAmt += $amt; + $subFee += $fee; + $subTot += $total; + + $grandQty += $qty; + $grandAmt += $amt; + $grandFee += $fee; + $grandTot += $total; + } + $flushSubtotal(); + + $out[] = [ + 'kind' => 'grand', + 'sale_date' => '', + 'designation_no' => '', + 'shop_name' => '', + 'rep_name' => '', + 'address' => '', + 'product_name' => '합 계', + 'qty' => number_format($grandQty), + 'amount' => number_format((int) round($grandAmt)), + 'fee' => $grandFee != 0.0 ? number_format((int) round($grandFee)) : '', + 'total' => number_format((int) round($grandTot)), + ]; + + return ['rows' => $out, 'saleLineCount' => $saleLineCount]; + } + + /** + * @param list $rows + * @return array{rows: list>, saleLineCount: int} + */ + private function buildPeriodLedgerPresentation(array $rows, bool $hasBsFee): array + { + $groups = []; + foreach ($rows as $row) { + $dsId = (int) ($row->bs_ds_idx ?? 0); + $code = (string) ($row->bs_bag_code ?? ''); + $name = (string) ($row->bs_bag_name ?? ''); + $gk = $dsId . '|' . $code . '|' . $name; + if (! isset($groups[$gk])) { + $groups[$gk] = [ + 'bs_ds_idx' => $dsId, + 'ds_shop_no' => (string) ($row->ds_shop_no ?? ''), + 'ds_name' => (string) ($row->ds_name ?? ''), + 'ds_rep_name' => (string) ($row->ds_rep_name ?? ''), + 'ds_addr' => trim((string) ($row->ds_addr ?? '')), + 'ds_zone_code' => (string) ($row->ds_zone_code ?? ''), + 'ds_gugun_code' => (string) ($row->ds_gugun_code ?? ''), + 'bs_bag_code' => $code, + 'bs_bag_name' => $name, + 'qty' => 0, + 'amount' => 0.0, + 'fee' => 0.0, + ]; + } + $groups[$gk]['qty'] += (int) round((float) ($row->line_qty ?? 0)); + $groups[$gk]['amount'] += (float) ($row->line_amount ?? 0); + $groups[$gk]['fee'] += $hasBsFee ? (float) ($row->line_fee ?? 0) : 0.0; + } + + $saleLineCount = count($rows); + + uasort($groups, static function (array $a, array $b): int { + $cmp = strcmp((string) ($a['ds_shop_no'] ?? ''), (string) ($b['ds_shop_no'] ?? '')); + if ($cmp !== 0) { + return $cmp; + } + $cmp2 = strcmp((string) ($a['bs_bag_code'] ?? ''), (string) ($b['bs_bag_code'] ?? '')); + + return $cmp2 !== 0 ? $cmp2 : strcmp((string) ($a['bs_bag_name'] ?? ''), (string) ($b['bs_bag_name'] ?? '')); + }); + + $out = []; + $prevDs = null; + $subQty = $subAmt = $subFee = $subTot = 0; + $grandQty = $grandAmt = $grandFee = $grandTot = 0; + + $flushSub = static function () use (&$out, &$subQty, &$subAmt, &$subFee, &$subTot): void { + if ($subQty === 0 && $subAmt === 0 && $subFee === 0 && $subTot === 0) { + return; + } + $out[] = [ + 'kind' => 'subtotal', + 'designation_no' => '', + 'shop_name' => '', + 'rep_name' => '', + 'address' => '', + 'product_name' => '소 계', + 'qty' => number_format($subQty), + 'amount' => number_format((int) round($subAmt)), + 'fee' => number_format((int) round($subFee)), + 'total' => number_format((int) round($subTot)), + ]; + $subQty = $subAmt = $subFee = $subTot = 0; + }; + + foreach ($groups as $g) { + $dsId = (int) ($g['bs_ds_idx'] ?? 0); + if ($prevDs !== null && $dsId !== $prevDs) { + $flushSub(); + } + $prevDs = $dsId; + + $qty = (int) ($g['qty'] ?? 0); + $amt = (float) ($g['amount'] ?? 0); + $fee = (float) ($g['fee'] ?? 0); + $total = $amt + $fee; + + $designation = $this->formatDesignationNo( + (string) ($g['ds_zone_code'] ?? ''), + (string) ($g['ds_gugun_code'] ?? ''), + (string) ($g['ds_shop_no'] ?? '') + ); + + $out[] = [ + 'kind' => 'data', + 'designation_no' => $designation, + 'shop_name' => (string) ($g['ds_name'] ?? ''), + 'rep_name' => (string) ($g['ds_rep_name'] ?? ''), + 'address' => (string) ($g['ds_addr'] ?? ''), + 'product_name' => (string) ($g['bs_bag_name'] ?? ''), + 'qty' => number_format($qty), + 'amount' => number_format((int) round($amt)), + 'fee' => $fee != 0.0 ? number_format((int) round($fee)) : '', + 'total' => number_format((int) round($total)), + ]; + + $subQty += $qty; + $subAmt += $amt; + $subFee += $fee; + $subTot += $total; + + $grandQty += $qty; + $grandAmt += $amt; + $grandFee += $fee; + $grandTot += $total; + } + $flushSub(); + + $out[] = [ + 'kind' => 'grand', + 'designation_no' => '', + 'shop_name' => '', + 'rep_name' => '', + 'address' => '', + 'product_name' => '합 계', + 'qty' => number_format($grandQty), + 'amount' => number_format((int) round($grandAmt)), + 'fee' => $grandFee != 0.0 ? number_format((int) round($grandFee)) : '', + 'total' => number_format((int) round($grandTot)), + ]; + + return ['rows' => $out, 'saleLineCount' => $saleLineCount]; + } + + private function formatDesignationNo(string $zoneCode, string $gugunCode, string $shopNo): string + { + $head = trim($zoneCode) !== '' ? trim($zoneCode) : trim($gugunCode); + $shopNo = trim($shopNo); + if ($head !== '' && $shopNo !== '') { + return $head . ' - ' . $shopNo; + } + if ($shopNo !== '') { + return $shopNo; + } + + return $head; + } + + /** + * P5-02: 일계표 (일계 + 당월 누계, 대행소·구분, 엑셀·인쇄) */ public function dailySummary() { - helper('admin'); + helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } - $date = $this->request->getGet('date') ?? date('Y-m-d'); - $db = \Config\Database::connect(); + $date = (string) ($this->request->getGet('date') ?? date('Y-m-d')); + $saIdx = (int) ($this->request->getGet('sa_idx') ?? 0); + $catFilter = trim((string) ($this->request->getGet('cat') ?? '')); + $export = (int) ($this->request->getGet('export') ?? 0) === 1; - // 당일 판매 - $daily = $db->query(" - SELECT bs_bag_code, bs_bag_name, - SUM(CASE WHEN bs_type='sale' THEN ABS(bs_qty) ELSE 0 END) as sale_qty, - SUM(CASE WHEN bs_type='sale' THEN bs_amount ELSE 0 END) as sale_amount - FROM bag_sale - WHERE bs_lg_idx = ? AND bs_sale_date = ? - GROUP BY bs_bag_code, bs_bag_name - ORDER BY bs_bag_code - ", [$lgIdx, $date])->getResult(); - - // 당월 누계 $monthStart = date('Y-m-01', strtotime($date)); - $monthly = $db->query(" - SELECT bs_bag_code, bs_bag_name, - SUM(CASE WHEN bs_type='sale' THEN ABS(bs_qty) ELSE 0 END) as sale_qty, - SUM(CASE WHEN bs_type='sale' THEN bs_amount ELSE 0 END) as sale_amount - FROM bag_sale - WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ? - GROUP BY bs_bag_code, bs_bag_name - ORDER BY bs_bag_code - ", [$lgIdx, $monthStart, $date])->getResult(); + $db = \Config\Database::connect(); + $hasBsFee = $db->fieldExists('bs_fee', 'bag_sale'); + $hasDsSaIdx = $db->fieldExists('ds_sa_idx', 'designated_shop'); + $effectiveSaIdx = ($hasDsSaIdx && $saIdx > 0) ? $saIdx : 0; - return $this->renderWorkPage('일계표', 'admin/sales_report/daily_summary', compact('daily', 'monthly', 'date')); + $agencies = model(SalesAgencyModel::class) + ->where('sa_lg_idx', $lgIdx) + ->orderForDisplay() + ->findAll(); + + $aggRows = $this->fetchDailySummaryBagAggregates( + $db, + $lgIdx, + $date, + $monthStart, + $hasBsFee, + $effectiveSaIdx + ); + + $catOrder = ['general', 'food', 'sticker', 'reuse', 'apt', 'public_use', 'container', 'waste']; + $catLabels = [ + 'general' => '일반용', + 'food' => '음식물', + 'sticker' => '스티커', + 'reuse' => '재사용', + 'apt' => '공동주택용', + 'public_use' => '공공용', + 'container' => '용기', + 'waste' => '폐기물', + ]; + + $byCat = []; + foreach ($catOrder as $ck) { + $byCat[$ck] = []; + } + foreach ($aggRows as $row) { + $name = (string) ($row->bs_bag_name ?? ''); + $code = (string) ($row->bs_bag_code ?? ''); + $ck = $this->classifyLedgerProductCategory($name, $code); + if ($catFilter !== '' && $ck !== $catFilter) { + continue; + } + $dQty = (int) round((float) ($row->d_qty ?? 0)); + $dAmt = (float) ($row->d_amt ?? 0); + $dFee = $hasBsFee ? (float) ($row->d_fee ?? 0) : 0.0; + $mQty = (int) round((float) ($row->m_qty ?? 0)); + $mAmt = (float) ($row->m_amt ?? 0); + $mFee = $hasBsFee ? (float) ($row->m_fee ?? 0) : 0.0; + $byCat[$ck][] = [ + 'bag_code' => $code, + 'bag_name' => $name !== '' ? $name : $code, + 'd_qty' => $dQty, + 'd_amt' => $dAmt, + 'd_fee' => $dFee, + 'd_levy' => $dAmt + $dFee, + 'm_qty' => $mQty, + 'm_amt' => $mAmt, + 'm_fee' => $mFee, + 'm_levy' => $mAmt + $mFee, + ]; + } + + $tableRows = []; + $gD = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; + $gM = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; + + foreach ($catOrder as $ck) { + $items = $byCat[$ck] ?? []; + if ($items === []) { + continue; + } + usort($items, static fn (array $a, array $b): int => strcmp((string) ($a['bag_code'] ?? ''), (string) ($b['bag_code'] ?? ''))); + + $sD = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; + $sM = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; + + foreach ($items as $it) { + $tableRows[] = array_merge(['kind' => 'data', 'cat_key' => $ck, 'cat_label' => $catLabels[$ck] ?? $ck], $it); + $sD['qty'] += $it['d_qty']; + $sD['amt'] += $it['d_amt']; + $sD['fee'] += $it['d_fee']; + $sD['levy'] += $it['d_levy']; + $sM['qty'] += $it['m_qty']; + $sM['amt'] += $it['m_amt']; + $sM['fee'] += $it['m_fee']; + $sM['levy'] += $it['m_levy']; + } + + $tableRows[] = [ + 'kind' => 'subtotal', + 'cat_key' => $ck, + 'cat_label' => $catLabels[$ck] ?? $ck, + 'bag_name' => '소 계', + 'bag_code' => '', + 'd_qty' => $sD['qty'], + 'd_amt' => $sD['amt'], + 'd_fee' => $sD['fee'], + 'd_levy' => $sD['levy'], + 'm_qty' => $sM['qty'], + 'm_amt' => $sM['amt'], + 'm_fee' => $sM['fee'], + 'm_levy' => $sM['levy'], + ]; + + $gD['qty'] += $sD['qty']; + $gD['amt'] += $sD['amt']; + $gD['fee'] += $sD['fee']; + $gD['levy'] += $sD['levy']; + $gM['qty'] += $sM['qty']; + $gM['amt'] += $sM['amt']; + $gM['fee'] += $sM['fee']; + $gM['levy'] += $sM['levy']; + } + + if ($tableRows !== []) { + $tableRows[] = [ + 'kind' => 'grand', + 'cat_key' => '', + 'cat_label' => '', + 'bag_name' => '합 계', + 'bag_code' => '', + 'd_qty' => $gD['qty'], + 'd_amt' => $gD['amt'], + 'd_fee' => $gD['fee'], + 'd_levy' => $gD['levy'], + 'm_qty' => $gM['qty'], + 'm_amt' => $gM['amt'], + 'm_fee' => $gM['fee'], + 'm_levy' => $gM['levy'], + ]; + } + + $lgRow = model(LocalGovernmentModel::class)->find($lgIdx); + $lgName = $lgRow ? (string) ($lgRow->lg_name ?? '') : ''; + + $agencyLabel = '전체'; + if ($saIdx > 0) { + foreach ($agencies as $a) { + if ((int) ($a->sa_idx ?? 0) === $saIdx) { + $agencyLabel = trim((string) ($a->sa_name ?? '')); + break; + } + } + } + $catLabelFilter = ($catFilter !== '' && isset($catLabels[$catFilter])) ? $catLabels[$catFilter] : '전체'; + + if ($export) { + $headers = [ + '구분', + '봉투종류', + '일계_수량', + '일계_판매금액', + '일계_수수료', + '일계_징수액', + '누계(월)_수량', + '누계(월)_판매금액', + '누계(월)_수수료', + '누계(월)_징수액', + ]; + $exportRows = []; + foreach ($tableRows as $r) { + $exportRows[] = [ + (string) ($r['cat_label'] ?? ''), + (string) ($r['bag_name'] ?? ''), + (string) ($r['d_qty'] ?? ''), + (string) (int) round((float) ($r['d_amt'] ?? 0)), + (string) (int) round((float) ($r['d_fee'] ?? 0)), + (string) (int) round((float) ($r['d_levy'] ?? 0)), + (string) ($r['m_qty'] ?? ''), + (string) (int) round((float) ($r['m_amt'] ?? 0)), + (string) (int) round((float) ($r['m_fee'] ?? 0)), + (string) (int) round((float) ($r['m_levy'] ?? 0)), + ]; + } + export_excel_2003_xml( + '일계표_' . $date, + '일계표', + $headers, + $exportRows + ); + } + + $printExtraLines = [ + '조회일: ' . $date . ' · 대행소: ' . $agencyLabel . ' · 구분: ' . $catLabelFilter, + '누계 구간: ' . $monthStart . ' ~ ' . $date . ' · (단위: 매 / 원)', + ]; + + return $this->renderWorkPage('일계표', 'admin/sales_report/daily_summary', [ + 'tableRows' => $tableRows, + 'date' => $date, + 'monthStart' => $monthStart, + 'saIdx' => $saIdx, + 'catFilter' => $catFilter, + 'agencies' => $agencies, + 'catLabels' => $catLabels, + 'hasBsFee' => $hasBsFee, + 'lgName' => $lgName, + 'agencyLabel' => $agencyLabel, + 'catLabelFilter' => $catLabelFilter, + 'printExtraLines' => $printExtraLines, + ]); } /** - * P5-03: 기간별 판매현황 + * 품목(봉투코드·명)별 당일·당월 누계 집계 (판매만) + * + * @return list + */ + private function fetchDailySummaryBagAggregates( + \CodeIgniter\Database\BaseConnection $db, + int $lgIdx, + string $date, + string $monthStart, + bool $hasBsFee, + int $saIdx + ): array { + $feeDaily = $hasBsFee + ? 'SUM(CASE WHEN bs.bs_sale_date = ? THEN COALESCE(bs.bs_fee, 0) ELSE 0 END)' + : 'CAST(0 AS DECIMAL(14,2))'; + $feeMonth = $hasBsFee + ? 'SUM(CASE WHEN bs.bs_sale_date BETWEEN ? AND ? THEN COALESCE(bs.bs_fee, 0) ELSE 0 END)' + : 'CAST(0 AS DECIMAL(14,2))'; + + $sql = 'SELECT bs.bs_bag_code, bs.bs_bag_name, ' + . 'SUM(CASE WHEN bs.bs_sale_date = ? THEN ABS(bs.bs_qty) ELSE 0 END) AS d_qty, ' + . 'SUM(CASE WHEN bs.bs_sale_date = ? THEN bs.bs_amount ELSE 0 END) AS d_amt, ' + . $feeDaily . ' AS d_fee, ' + . 'SUM(CASE WHEN bs.bs_sale_date BETWEEN ? AND ? THEN ABS(bs.bs_qty) ELSE 0 END) AS m_qty, ' + . 'SUM(CASE WHEN bs.bs_sale_date BETWEEN ? AND ? THEN bs.bs_amount ELSE 0 END) AS m_amt, ' + . $feeMonth . ' AS m_fee ' + . 'FROM bag_sale bs ' + . 'LEFT JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx ' + . 'WHERE bs.bs_lg_idx = ? AND bs.bs_type = ? AND bs.bs_sale_date BETWEEN ? AND ? '; + + $params = [ + $date, + $date, + ]; + if ($hasBsFee) { + $params[] = $date; + } + $params[] = $monthStart; + $params[] = $date; + $params[] = $monthStart; + $params[] = $date; + if ($hasBsFee) { + $params[] = $monthStart; + $params[] = $date; + } + $params[] = $lgIdx; + $params[] = 'sale'; + $params[] = $monthStart; + $params[] = $date; + + if ($saIdx > 0) { + $sql .= ' AND ds.ds_sa_idx = ? '; + $params[] = $saIdx; + } + + $sql .= 'GROUP BY bs.bs_bag_code, bs.bs_bag_name ORDER BY bs.bs_bag_code'; + + return $db->query($sql, $params)->getResult(); + } + + private function isFoodOrStickerLedgerCategory(string $catKey): bool + { + return $catKey === 'food' || $catKey === 'sticker'; + } + + /** + * 기간별 판매현황 집계 (일자+품목 또는 품목만) + * + * @return list + */ + private function fetchPeriodSalesAggregates( + \CodeIgniter\Database\BaseConnection $db, + int $lgIdx, + string $startDate, + string $endDate, + bool $hasBsFee, + int $saIdx, + bool $byDaily + ): array { + $feeExpr = $hasBsFee + ? "SUM(CASE WHEN bs.bs_type = 'sale' THEN COALESCE(bs.bs_fee, 0) ELSE 0 END)" + : 'CAST(0 AS DECIMAL(14,2))'; + + $dateSelect = $byDaily ? 'bs.bs_sale_date AS bs_sale_date, ' : ''; + $groupBy = $byDaily ? 'bs.bs_sale_date, bs.bs_bag_code, bs.bs_bag_name' : 'bs.bs_bag_code, bs.bs_bag_name'; + $orderBy = $byDaily + ? 'bs.bs_sale_date ASC, bs.bs_bag_code ASC' + : 'bs.bs_bag_code ASC'; + + $sql = 'SELECT ' . $dateSelect . 'bs.bs_bag_code, bs.bs_bag_name, ' + . "SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) ELSE 0 END) AS s_qty, " + . "SUM(CASE WHEN bs.bs_type = 'sale' THEN bs.bs_amount ELSE 0 END) AS s_amt, " + . $feeExpr . ' AS s_fee, ' + . "SUM(CASE WHEN bs.bs_type = 'return' THEN ABS(bs.bs_qty) ELSE 0 END) AS r_qty, " + . "SUM(CASE WHEN bs.bs_type = 'return' THEN ABS(bs.bs_amount) ELSE 0 END) AS r_amt " + . 'FROM bag_sale bs ' + . 'LEFT JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx ' + . "WHERE bs.bs_lg_idx = ? AND bs.bs_sale_date BETWEEN ? AND ? AND bs.bs_type IN ('sale','return') "; + + $params = [$lgIdx, $startDate, $endDate]; + if ($saIdx > 0) { + $sql .= ' AND ds.ds_sa_idx = ? '; + $params[] = $saIdx; + } + $sql .= 'GROUP BY ' . $groupBy . ' ORDER BY ' . $orderBy; + + return $db->query($sql, $params)->getResult(); + } + + /** + * @param list $rawRows + * + * @return array{lines: list>, foot_all: array, foot_bag: array, foot_fs: array} + */ + private function buildPeriodSalesPresentation(array $rawRows, bool $byDaily, string $catFilter, bool $hasBsFee): array + { + $empty = static fn (): array => [ + 's_qty' => 0, 's_amt' => 0.0, 's_fee' => 0.0, 's_levy' => 0.0, + 'r_qty' => 0, 'r_amt' => 0.0, + 't_qty' => 0, 't_amt' => 0.0, 't_fee' => 0.0, 't_levy' => 0.0, + ]; + + $dataFlat = []; + foreach ($rawRows as $row) { + $name = (string) ($row->bs_bag_name ?? ''); + $code = (string) ($row->bs_bag_code ?? ''); + $ck = $this->classifyLedgerProductCategory($name, $code); + if ($catFilter !== '' && $ck !== $catFilter) { + continue; + } + $sQty = (int) round((float) ($row->s_qty ?? 0)); + $sAmt = (float) ($row->s_amt ?? 0); + $sFee = $hasBsFee ? (float) ($row->s_fee ?? 0) : 0.0; + $sLev = $sAmt + $sFee; + $rQty = (int) round((float) ($row->r_qty ?? 0)); + $rAmt = (float) ($row->r_amt ?? 0); + $tQty = $sQty - $rQty; + $tAmt = $sAmt - $rAmt; + $tFee = $sFee; + $tLev = $tAmt + $tFee; + $ymd = $byDaily ? (string) ($row->bs_sale_date ?? '') : ''; + $dataFlat[] = [ + 'code' => $code, + 'name' => $name !== '' ? $name : $code, + 'ck' => $ck, + 'ymd' => $ymd, + 's_qty' => $sQty, + 's_amt' => $sAmt, + 's_fee' => $sFee, + 's_levy' => $sLev, + 'r_qty' => $rQty, + 'r_amt' => $rAmt, + 't_qty' => $tQty, + 't_amt' => $tAmt, + 't_fee' => $tFee, + 't_levy' => $tLev, + ]; + } + + $addBucket = static function (array $b, array $row): array { + $b['s_qty'] += $row['s_qty']; + $b['s_amt'] += $row['s_amt']; + $b['s_fee'] += $row['s_fee']; + $b['s_levy'] += $row['s_levy']; + $b['r_qty'] += $row['r_qty']; + $b['r_amt'] += $row['r_amt']; + $b['t_qty'] += $row['t_qty']; + $b['t_amt'] += $row['t_amt']; + $b['t_fee'] += $row['t_fee']; + $b['t_levy'] += $row['t_levy']; + + return $b; + }; + + $footAll = $empty(); + $footBag = $empty(); + $footFs = $empty(); + foreach ($dataFlat as $row) { + $footAll = $addBucket($footAll, $row); + if ($this->isFoodOrStickerLedgerCategory((string) $row['ck'])) { + $footFs = $addBucket($footFs, $row); + } else { + $footBag = $addBucket($footBag, $row); + } + } + + if ($dataFlat === []) { + return [ + 'lines' => [], + 'foot_all' => $footAll, + 'foot_bag' => $footBag, + 'foot_fs' => $footFs, + ]; + } + + $lines = []; + + if ($byDaily) { + $byDate = []; + foreach ($dataFlat as $row) { + $d = (string) $row['ymd']; + if ($d === '') { + continue; + } + $byDate[$d][] = $row; + } + ksort($byDate); + + foreach ($byDate as $ymd => $items) { + if ($items === []) { + continue; + } + usort($items, static fn (array $a, array $b): int => strcmp((string) $a['code'], (string) $b['code'])); + $blockLen = count($items) + 3; + $i = 0; + foreach ($items as $it) { + $lines[] = array_merge($it, [ + 'kind' => 'data', + 'ymd_rowspan' => $i === 0 ? $blockLen : 0, + ]); + ++$i; + } + $dayAll = $empty(); + $dayBag = $empty(); + $dayFs = $empty(); + foreach ($items as $it) { + $dayAll = $addBucket($dayAll, $it); + if ($this->isFoodOrStickerLedgerCategory((string) $it['ck'])) { + $dayFs = $addBucket($dayFs, $it); + } else { + $dayBag = $addBucket($dayBag, $it); + } + } + foreach ( + [ + ['day_sub_all', '소 계', $dayAll], + ['day_sub_bag', '봉투계', $dayBag], + ['day_sub_fs', '음식물 / 스티커 계', $dayFs], + ] as $trip + ) { + $lines[] = [ + 'kind' => $trip[0], + 'ymd' => '', + 'ymd_rowspan' => 0, + 'code' => '', + 'name' => (string) $trip[1], + 'ck' => '', + 's_qty' => $trip[2]['s_qty'], + 's_amt' => $trip[2]['s_amt'], + 's_fee' => $trip[2]['s_fee'], + 's_levy' => $trip[2]['s_levy'], + 'r_qty' => $trip[2]['r_qty'], + 'r_amt' => $trip[2]['r_amt'], + 't_qty' => $trip[2]['t_qty'], + 't_amt' => $trip[2]['t_amt'], + 't_fee' => $trip[2]['t_fee'], + 't_levy' => $trip[2]['t_levy'], + ]; + } + } + } else { + usort($dataFlat, static fn (array $a, array $b): int => strcmp((string) $a['code'], (string) $b['code'])); + foreach ($dataFlat as $it) { + $lines[] = array_merge($it, [ + 'kind' => 'data', + 'ymd_rowspan' => 0, + ]); + } + } + + foreach ( + [ + ['foot_all', '합 계', $footAll], + ['foot_bag', '봉투계', $footBag], + ['foot_fs', '음식물 / 스티커 계', $footFs], + ] as $ft + ) { + $lines[] = [ + 'kind' => $ft[0], + 'ymd' => '', + 'ymd_rowspan' => 0, + 'code' => '', + 'name' => (string) $ft[1], + 'ck' => '', + 's_qty' => $ft[2]['s_qty'], + 's_amt' => $ft[2]['s_amt'], + 's_fee' => $ft[2]['s_fee'], + 's_levy' => $ft[2]['s_levy'], + 'r_qty' => $ft[2]['r_qty'], + 'r_amt' => $ft[2]['r_amt'], + 't_qty' => $ft[2]['t_qty'], + 't_amt' => $ft[2]['t_amt'], + 't_fee' => $ft[2]['t_fee'], + 't_levy' => $ft[2]['t_levy'], + ]; + } + + return [ + 'lines' => $lines, + 'foot_all' => $footAll, + 'foot_bag' => $footBag, + 'foot_fs' => $footFs, + ]; + } + + /** + * P5-03: 기간별 판매현황 (일자별·기간별 집계, 대행소·구분, 판매/반품/계, 엑셀·인쇄) */ public function periodSales() { - helper('admin'); + helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } - $startDate = $this->request->getGet('start_date') ?? date('Y-m-01'); - $endDate = $this->request->getGet('end_date') ?? date('Y-m-d'); + $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); + $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); + if ($startDate > $endDate) { + [$startDate, $endDate] = [$endDate, $startDate]; + } + + $saIdx = (int) ($this->request->getGet('sa_idx') ?? 0); + $catFilter = trim((string) ($this->request->getGet('cat') ?? '')); + $mode = trim((string) ($this->request->getGet('mode') ?? 'daily')); + $byDaily = ($mode !== 'period'); + $export = (int) ($this->request->getGet('export') ?? 0) === 1; + $db = \Config\Database::connect(); + $hasBsFee = $db->fieldExists('bs_fee', 'bag_sale'); + $hasDsSaIdx = $db->fieldExists('ds_sa_idx', 'designated_shop'); + $effectiveSaIdx = ($hasDsSaIdx && $saIdx > 0) ? $saIdx : 0; - $result = $db->query(" - SELECT bs_bag_code, bs_bag_name, - SUM(CASE WHEN bs_type='sale' THEN ABS(bs_qty) ELSE 0 END) as sale_qty, - SUM(CASE WHEN bs_type='sale' THEN bs_amount ELSE 0 END) as sale_amount, - SUM(CASE WHEN bs_type='return' THEN ABS(bs_qty) ELSE 0 END) as return_qty, - SUM(CASE WHEN bs_type='return' THEN bs_amount ELSE 0 END) as return_amount - FROM bag_sale - WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ? - GROUP BY bs_bag_code, bs_bag_name - ORDER BY bs_bag_code - ", [$lgIdx, $startDate, $endDate])->getResult(); + $agencies = model(SalesAgencyModel::class) + ->where('sa_lg_idx', $lgIdx) + ->orderForDisplay() + ->findAll(); - return $this->renderWorkPage('기간별 판매현황', 'admin/sales_report/period_sales', compact('result', 'startDate', 'endDate')); + $raw = $this->fetchPeriodSalesAggregates($db, $lgIdx, $startDate, $endDate, $hasBsFee, $effectiveSaIdx, $byDaily); + $pres = $this->buildPeriodSalesPresentation($raw, $byDaily, $catFilter, $hasBsFee); + $lines = $pres['lines']; + + $catLabels = [ + 'general' => '일반용', + 'food' => '음식물', + 'sticker' => '스티커', + 'reuse' => '재사용', + 'apt' => '공동주택용', + 'public_use' => '공공용', + 'container' => '용기', + 'waste' => '폐기물', + ]; + + $lgRow = model(LocalGovernmentModel::class)->find($lgIdx); + $lgName = $lgRow ? (string) ($lgRow->lg_name ?? '') : ''; + + $agencyLabel = '전체'; + if ($saIdx > 0) { + foreach ($agencies as $a) { + if ((int) ($a->sa_idx ?? 0) === $saIdx) { + $agencyLabel = trim((string) ($a->sa_name ?? '')); + break; + } + } + } + $catLabelFilter = ($catFilter !== '' && isset($catLabels[$catFilter])) ? $catLabels[$catFilter] : '전체'; + $modeLabel = $byDaily ? '일자별' : '기간별'; + + if ($export) { + $headers = [ + '일자', + '품목', + '판매_수량', + '판매_판매금액', + '판매_수수료', + '판매_징수액', + '반품_수량', + '반품_금액', + '계_수량', + '계_판매금액', + '계_수수료', + '계_징수액', + ]; + $exportRows = []; + foreach ($lines as $ln) { + $kind = (string) ($ln['kind'] ?? ''); + $ymd = (string) ($ln['ymd'] ?? ''); + if (! $byDaily && $kind === 'data') { + $ymd = ''; + } + $exportRows[] = [ + $ymd, + (string) ($ln['name'] ?? ''), + (string) ($ln['s_qty'] ?? '0'), + (string) (int) round((float) ($ln['s_amt'] ?? 0)), + $hasBsFee ? (string) (int) round((float) ($ln['s_fee'] ?? 0)) : '', + (string) (int) round((float) ($ln['s_levy'] ?? 0)), + (string) ($ln['r_qty'] ?? '0'), + (string) (int) round((float) ($ln['r_amt'] ?? 0)), + (string) ($ln['t_qty'] ?? '0'), + (string) (int) round((float) ($ln['t_amt'] ?? 0)), + $hasBsFee ? (string) (int) round((float) ($ln['t_fee'] ?? 0)) : '', + (string) (int) round((float) ($ln['t_levy'] ?? 0)), + ]; + } + export_excel_2003_xml( + '기간별판매현황_' . $startDate . '_' . $endDate, + '기간별판매현황', + $headers, + $exportRows + ); + } + + $printExtraLines = [ + '조회기간: ' . $startDate . ' ~ ' . $endDate . ' · 대행소: ' . $agencyLabel . ' · 구분: ' . $catLabelFilter . ' · 집계: ' . $modeLabel . ' · (단위: 매 / 원)', + ]; + + return $this->renderWorkPage('기간별 판매현황', 'admin/sales_report/period_sales', [ + 'lines' => $lines, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'saIdx' => $saIdx, + 'catFilter' => $catFilter, + 'mode' => $byDaily ? 'daily' : 'period', + 'agencies' => $agencies, + 'catLabels' => $catLabels, + 'hasBsFee' => $hasBsFee, + 'lgName' => $lgName, + 'agencyLabel' => $agencyLabel, + 'catLabelFilter' => $catLabelFilter, + 'printExtraLines' => $printExtraLines, + ]); } /** - * P5-04: 년 판매 현황 (월별) + * P5-04: 년 판매 현황 (품목별 합계·월·분기, 구·군·대행소, 수수료·징수액, 엑셀·인쇄) */ public function yearlySales() { - helper('admin'); + helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } - $year = $this->request->getGet('year') ?? date('Y'); + $year = (int) ($this->request->getGet('year') ?? date('Y')); + $year = max(2000, min(2100, $year)); + $gugunCode = trim((string) ($this->request->getGet('gugun_code') ?? '')); + $saIdx = (int) ($this->request->getGet('sa_idx') ?? 0); + $export = (int) ($this->request->getGet('export') ?? 0) === 1; + $db = \Config\Database::connect(); + $hasBsFee = $db->fieldExists('bs_fee', 'bag_sale'); + $hasDsSaIdx = $db->fieldExists('ds_sa_idx', 'designated_shop'); + $effectiveSaIdx = ($hasDsSaIdx && $saIdx > 0) ? $saIdx : 0; - $result = $db->query(" - SELECT bs_bag_code, bs_bag_name, - SUM(CASE WHEN MONTH(bs_sale_date)=1 THEN ABS(bs_qty) ELSE 0 END) as m01, - SUM(CASE WHEN MONTH(bs_sale_date)=2 THEN ABS(bs_qty) ELSE 0 END) as m02, - SUM(CASE WHEN MONTH(bs_sale_date)=3 THEN ABS(bs_qty) ELSE 0 END) as m03, - SUM(CASE WHEN MONTH(bs_sale_date)=4 THEN ABS(bs_qty) ELSE 0 END) as m04, - SUM(CASE WHEN MONTH(bs_sale_date)=5 THEN ABS(bs_qty) ELSE 0 END) as m05, - SUM(CASE WHEN MONTH(bs_sale_date)=6 THEN ABS(bs_qty) ELSE 0 END) as m06, - SUM(CASE WHEN MONTH(bs_sale_date)=7 THEN ABS(bs_qty) ELSE 0 END) as m07, - SUM(CASE WHEN MONTH(bs_sale_date)=8 THEN ABS(bs_qty) ELSE 0 END) as m08, - SUM(CASE WHEN MONTH(bs_sale_date)=9 THEN ABS(bs_qty) ELSE 0 END) as m09, - SUM(CASE WHEN MONTH(bs_sale_date)=10 THEN ABS(bs_qty) ELSE 0 END) as m10, - SUM(CASE WHEN MONTH(bs_sale_date)=11 THEN ABS(bs_qty) ELSE 0 END) as m11, - SUM(CASE WHEN MONTH(bs_sale_date)=12 THEN ABS(bs_qty) ELSE 0 END) as m12, - SUM(ABS(bs_qty)) as total - FROM bag_sale - WHERE bs_lg_idx = ? AND YEAR(bs_sale_date) = ? AND bs_type = 'sale' - GROUP BY bs_bag_code, bs_bag_name - ORDER BY bs_bag_code - ", [$lgIdx, $year])->getResult(); + $monthRows = $this->fetchYearlySalesMonthlyAggregates( + $db, + $lgIdx, + $year, + $gugunCode, + $effectiveSaIdx, + $hasBsFee + ); - return $this->renderWorkPage('년 판매 현황', 'admin/sales_report/yearly_sales', compact('result', 'year')); + $agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll(); + $gugunOptions = $this->yearlySalesGugunDropdownRows($lgIdx); + $built = $this->buildYearlySalesPresentation($monthRows, $hasBsFee); + + $lgRow = model(LocalGovernmentModel::class)->find($lgIdx); + $lgName = $lgRow ? (string) ($lgRow->lg_name ?? '') : ''; + + $gugunLabel = '전체'; + if ($gugunCode !== '') { + foreach ($gugunOptions as $g) { + if (($g['code'] ?? '') === $gugunCode) { + $gugunLabel = trim((string) ($g['name'] ?? $gugunCode)); + break; + } + } + } + + $agencyLabel = '전체'; + if ($saIdx > 0) { + foreach ($agencies as $a) { + if ((int) ($a->sa_idx ?? 0) === $saIdx) { + $agencyLabel = trim((string) ($a->sa_name ?? '')); + break; + } + } + } + + if ($export) { + $headers = array_merge(['품목', '구분'], array_map(static fn (array $c): string => (string) ($c['label'] ?? ''), $built['colSpec'])); + $exportRows = []; + foreach ($built['itemBlocks'] as $block) { + foreach ($block['lines'] as $li) { + $exportRows[] = array_merge( + [(string) ($block['name'] ?? ''), (string) ($li['measure'] ?? '')], + array_map(static fn ($v): string => (string) $v, $li['exportCells'] ?? []) + ); + } + } + if ($built['itemBlocks'] !== []) { + foreach ($built['footerBlock']['lines'] as $li) { + $exportRows[] = array_merge( + [(string) ($built['footerBlock']['name'] ?? ''), (string) ($li['measure'] ?? '')], + array_map(static fn ($v): string => (string) $v, $li['exportCells'] ?? []) + ); + } + } + $exportStamp = date('Ymd_His'); + export_excel_2003_xml( + '년판매현황_' . $year . '_' . $exportStamp, + $year . '년판매', + $headers, + $exportRows + ); + } + + $printExtraLines = [ + '구·군: ' . $gugunLabel . ' · 대행소: ' . $agencyLabel, + '(단위: 매 / 원)', + ]; + + return $this->renderWorkPage($year . '년 판매 현황', 'admin/sales_report/yearly_sales', [ + 'year' => $year, + 'gugunCode' => $gugunCode, + 'saIdx' => $saIdx, + 'agencies' => $agencies, + 'gugunOptions' => $gugunOptions, + 'colSpec' => $built['colSpec'], + 'itemBlocks' => $built['itemBlocks'], + 'footerBlock' => $built['footerBlock'], + 'hasBsFee' => $hasBsFee, + 'lgName' => $lgName, + 'gugunLabel' => $gugunLabel, + 'agencyLabel' => $agencyLabel, + 'printExtraLines' => $printExtraLines, + 'hasYearlyData' => $built['itemBlocks'] !== [], + ]); } /** - * P5-05: 지정판매소별 판매현황 + * @return list + */ + private function yearlySalesColumnSpec(): array + { + return [ + ['id' => 'total', 'label' => '합계'], + ['id' => 'm1', 'label' => '1월'], + ['id' => 'm2', 'label' => '2월'], + ['id' => 'm3', 'label' => '3월'], + ['id' => 'q1', 'label' => '1분기'], + ['id' => 'm4', 'label' => '4월'], + ['id' => 'm5', 'label' => '5월'], + ['id' => 'm6', 'label' => '6월'], + ['id' => 'q2', 'label' => '2분기'], + ['id' => 'm7', 'label' => '7월'], + ['id' => 'm8', 'label' => '8월'], + ['id' => 'm9', 'label' => '9월'], + ['id' => 'q3', 'label' => '3분기'], + ['id' => 'm10', 'label' => '10월'], + ['id' => 'm11', 'label' => '11월'], + ['id' => 'm12', 'label' => '12월'], + ['id' => 'q4', 'label' => '4분기'], + ]; + } + + private function codeKindIdxByCkCodeForSalesReport(string $ckCode): ?int + { + $k = model(CodeKindModel::class) + ->where('ck_code', $ckCode) + ->where('ck_state', 1) + ->first(); + + return $k !== null ? (int) $k->ck_idx : null; + } + + /** + * @return list + */ + private function yearlySalesGugunDropdownRows(int $lgIdx): array + { + $map = []; + $ckIdx = $this->codeKindIdxByCkCodeForSalesReport('C'); + if ($ckIdx !== null) { + foreach (model(CodeDetailModel::class)->getByKind($ckIdx, true, $lgIdx) as $r) { + $map[trim((string) ($r->cd_code ?? ''))] = trim((string) ($r->cd_name ?? '')); + } + } + + $db = \Config\Database::connect(); + $codes = $db->query( + "SELECT DISTINCT TRIM(ds_gugun_code) AS c FROM designated_shop WHERE ds_lg_idx = ? AND TRIM(ds_gugun_code) != '' ORDER BY c", + [$lgIdx] + )->getResult(); + + $out = []; + foreach ($codes as $o) { + $c = trim((string) ($o->c ?? '')); + if ($c === '') { + continue; + } + $name = $map[$c] ?? $c; + $out[$c] = ['code' => $c, 'name' => $name !== '' ? $name : $c]; + } + + foreach ($map as $c => $name) { + if ($c === '' || isset($out[$c])) { + continue; + } + $out[$c] = ['code' => $c, 'name' => $name !== '' ? $name : $c]; + } + + $rows = array_values($out); + usort($rows, static fn (array $a, array $b): int => strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? ''))); + + return $rows; + } + + /** + * 품목·월별 판매 집계 (판매만) + * + * @return list + */ + private function fetchYearlySalesMonthlyAggregates( + \CodeIgniter\Database\BaseConnection $db, + int $lgIdx, + int $year, + string $gugunCode, + int $saIdx, + bool $hasBsFee + ): array { + $feeExpr = $hasBsFee + ? 'SUM(COALESCE(bs.bs_fee, 0))' + : 'CAST(0 AS DECIMAL(14,2))'; + + $sql = 'SELECT bs.bs_bag_code AS c, bs.bs_bag_name AS n, MONTH(bs.bs_sale_date) AS mo, ' + . 'SUM(ABS(bs.bs_qty)) AS qty, SUM(bs.bs_amount) AS amt, ' + . $feeExpr . ' AS fee ' + . 'FROM bag_sale bs ' + . 'LEFT JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx ' + . 'WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? AND bs.bs_type = ? '; + + $params = [$lgIdx, $year, 'sale']; + + if ($gugunCode !== '') { + $sql .= ' AND ds.ds_gugun_code = ? '; + $params[] = $gugunCode; + } + if ($saIdx > 0) { + $sql .= ' AND ds.ds_sa_idx = ? '; + $params[] = $saIdx; + } + + $sql .= 'GROUP BY bs.bs_bag_code, bs.bs_bag_name, MONTH(bs.bs_sale_date) ' + . 'ORDER BY bs.bs_bag_code, MONTH(bs.bs_sale_date)'; + + return $db->query($sql, $params)->getResult(); + } + + /** + * @param list $monthRows + * + * @return array{ + * colSpec: list, + * itemBlocks: list, exportCells: list}>>}, + * footerBlock: array{name: string, lines: list, exportCells: list}>} + * } + */ + private function buildYearlySalesPresentation(array $monthRows, bool $hasBsFee): array + { + $colSpec = $this->yearlySalesColumnSpec(); + + /** @var array}> $products */ + $products = []; + foreach ($monthRows as $row) { + $code = trim((string) ($row->c ?? '')); + $name = trim((string) ($row->n ?? '')); + $mo = (int) ($row->mo ?? 0); + if ($code === '' || $mo < 1 || $mo > 12) { + continue; + } + $pkey = $code . "\x1f" . ($name !== '' ? $name : $code); + if (! isset($products[$pkey])) { + $products[$pkey] = [ + 'code' => $code, + 'name' => $name !== '' ? $name : $code, + 'm' => [], + ]; + for ($i = 1; $i <= 12; $i++) { + $products[$pkey]['m'][$i] = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0]; + } + } + $products[$pkey]['m'][$mo]['qty'] = (int) round((float) ($row->qty ?? 0)); + $products[$pkey]['m'][$mo]['amt'] = (float) ($row->amt ?? 0); + $products[$pkey]['m'][$mo]['fee'] = $hasBsFee ? (float) ($row->fee ?? 0) : 0.0; + } + + $sumMonths = static function (array $prod, array $months): array { + $q = 0; + $a = 0.0; + $f = 0.0; + foreach ($months as $mi) { + $b = $prod['m'][$mi] ?? ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0]; + $q += (int) ($b['qty'] ?? 0); + $a += (float) ($b['amt'] ?? 0); + $f += (float) ($b['fee'] ?? 0); + } + + return [ + 'qty' => $q, + 'amt' => $a, + 'fee' => $f, + 'levy' => $a + $f, + ]; + }; + + $bucketForSpec = static function (array $prod, array $col) use ($sumMonths): array { + $id = (string) ($col['id'] ?? ''); + if ($id === 'total') { + return $sumMonths($prod, range(1, 12)); + } + if (preg_match('/^m(\d{1,2})$/', $id, $m)) { + $mi = (int) $m[1]; + + return $sumMonths($prod, [$mi]); + } + if ($id === 'q1') { + return $sumMonths($prod, [1, 2, 3]); + } + if ($id === 'q2') { + return $sumMonths($prod, [4, 5, 6]); + } + if ($id === 'q3') { + return $sumMonths($prod, [7, 8, 9]); + } + if ($id === 'q4') { + return $sumMonths($prod, [10, 11, 12]); + } + + return ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; + }; + + $measureDefs = [ + ['key' => 'qty', 'label' => '수량'], + ['key' => 'amt', 'label' => '판매금액'], + ['key' => 'fee', 'label' => '수수료'], + ['key' => 'levy', 'label' => '징수액'], + ]; + + $cellsToExportRow = static function (array $cellsByCol, array $colSpecList, array $measure, bool $hasBsFee): array { + $key = (string) ($measure['key'] ?? ''); + $out = []; + foreach ($colSpecList as $col) { + $cid = (string) ($col['id'] ?? ''); + $cell = $cellsByCol[$cid] ?? ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; + if ($key === 'fee' && ! $hasBsFee) { + $out[] = ''; + } elseif ($key === 'qty') { + $out[] = (string) ((int) ($cell['qty'] ?? 0)); + } else { + $out[] = (string) (int) round((float) ($cell[$key] ?? 0)); + } + } + + return $out; + }; + + uasort($products, static function (array $a, array $b): int { + $ca = strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? '')); + if ($ca !== 0) { + return $ca; + } + + return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')); + }); + + $itemBlocks = []; + $footerCells = []; + foreach ($colSpec as $col) { + $footerCells[(string) $col['id']] = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; + } + + foreach ($products as $prod) { + $cellsByCol = []; + foreach ($colSpec as $col) { + $cid = (string) $col['id']; + $cellsByCol[$cid] = $bucketForSpec($prod, $col); + $footerCells[$cid]['qty'] += (int) $cellsByCol[$cid]['qty']; + $footerCells[$cid]['amt'] += (float) $cellsByCol[$cid]['amt']; + $footerCells[$cid]['fee'] += (float) $cellsByCol[$cid]['fee']; + $footerCells[$cid]['levy'] += (float) $cellsByCol[$cid]['levy']; + } + + $lines = []; + foreach ($measureDefs as $md) { + $lineCells = []; + foreach ($colSpec as $col) { + $cid = (string) $col['id']; + $lineCells[$cid] = $cellsByCol[$cid]; + } + $lines[] = [ + 'measure' => (string) ($md['label'] ?? ''), + 'measureKey' => (string) ($md['key'] ?? ''), + 'cells' => $lineCells, + 'exportCells' => $cellsToExportRow($cellsByCol, $colSpec, $md, $hasBsFee), + ]; + } + $itemBlocks[] = [ + 'name' => (string) ($prod['name'] ?? ''), + 'lines' => $lines, + ]; + } + + $footerLines = []; + foreach ($measureDefs as $md) { + $key = (string) ($md['key'] ?? ''); + $fc = []; + foreach ($colSpec as $col) { + $cid = (string) $col['id']; + $fc[$cid] = $footerCells[$cid] ?? ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]; + } + $footerLines[] = [ + 'measure' => (string) ($md['label'] ?? ''), + 'measureKey' => (string) ($md['key'] ?? ''), + 'cells' => $fc, + 'exportCells' => $cellsToExportRow($footerCells, $colSpec, $md, $hasBsFee), + ]; + } + + return [ + 'colSpec' => $colSpec, + 'itemBlocks' => $itemBlocks, + 'footerBlock' => [ + 'name' => '전체 합계', + 'lines' => $footerLines, + ], + ]; + } + + /** + * P5-05: 지정판매소별 판매현황 (기간·읍면동·봉투종류·구분·수량/금액, 월별 12열, 엑셀·인쇄) */ public function shopSales() { - helper('admin'); + helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } - $startDate = $this->request->getGet('start_date') ?? date('Y-m-01'); - $endDate = $this->request->getGet('end_date') ?? date('Y-m-d'); + $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-01-01')); + $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); + if ($startDate > $endDate) { + [$startDate, $endDate] = [$endDate, $startDate]; + } + + $zoneCode = trim((string) ($this->request->getGet('zone_code') ?? '')); + $bagCode = trim((string) ($this->request->getGet('bag_code') ?? '')); + $catFilter = trim((string) ($this->request->getGet('cat') ?? '')); + $metric = trim((string) ($this->request->getGet('metric') ?? 'qty')); + if ($metric !== 'amt') { + $metric = 'qty'; + } + $export = (int) ($this->request->getGet('export') ?? 0) === 1; + + $lgRow = model(LocalGovernmentModel::class)->find($lgIdx); + $lgName = $lgRow ? trim((string) ($lgRow->lg_name ?? '')) : ''; + $fixedGugun = $lgRow ? trim((string) ($lgRow->lg_code ?? '')) : ''; + $db = \Config\Database::connect(); + $zoneOptions = $this->shopSalesDistinctZoneCodes($db, $lgIdx, $fixedGugun); + if ($zoneCode !== '' && ! in_array($zoneCode, $zoneOptions, true)) { + $zoneCode = ''; + } - $result = $db->query(" - SELECT bs_ds_name, - SUM(CASE WHEN bs_type='sale' THEN ABS(bs_qty) ELSE 0 END) as sale_qty, - SUM(CASE WHEN bs_type='sale' THEN bs_amount ELSE 0 END) as sale_amount, - SUM(CASE WHEN bs_type='return' THEN ABS(bs_qty) ELSE 0 END) as return_qty, - SUM(CASE WHEN bs_type='return' THEN ABS(bs_amount) ELSE 0 END) as return_amount - FROM bag_sale - WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ? - GROUP BY bs_ds_name - ORDER BY bs_ds_name - ", [$lgIdx, $startDate, $endDate])->getResult(); + $bagOptions = $this->shopSalesDistinctBagCodes($db, $lgIdx); + if ($bagCode !== '' && ! array_key_exists($bagCode, $bagOptions)) { + $bagCode = ''; + } - return $this->renderWorkPage('지정판매소별 판매현황', 'admin/sales_report/shop_sales', compact('result', 'startDate', 'endDate')); + $catLabels = [ + 'general' => '일반용', + 'food' => '음식물', + 'sticker' => '스티커', + 'reuse' => '재사용', + 'apt' => '공동주택용', + 'public_use' => '공공용', + 'container' => '용기', + 'waste' => '폐기물', + ]; + + $isAmountMetric = $metric === 'amt'; + $fifoStart = $isAmountMetric + ? $startDate + : min($startDate, date('Y-01-01', strtotime($endDate))); + + $fifoRows = $this->fetchShopSalesFifoRowsBetween( + $db, + $lgIdx, + $fifoStart, + $endDate, + $zoneCode, + $bagCode, + $fixedGugun + ); + if ($isAmountMetric) { + $perShop = $this->shopSalesMonthlyGrossSaleAmountByShop( + $fifoRows, + $startDate, + $endDate, + $catFilter + ); + } else { + $perShop = $this->shopSalesFifoMonthlyByShop( + $fifoRows, + $startDate, + $endDate, + $catFilter, + $metric + ); + if (((int) date('Y', strtotime($startDate))) === ((int) date('Y', strtotime($endDate)))) { + $this->shopSalesRollOffWindowMonthsIntoFirstVisibleMonth($perShop, $startDate, $endDate); + $this->shopSalesAbsorbNegativeVisibleMonthsIntoEarlierPositive($perShop, $startDate, $endDate); + } + } + + $designatedShopIds = $this->shopSalesDesignatedShopIds($db, $lgIdx, $fixedGugun, $zoneCode); + foreach ($designatedShopIds as $sid) { + if (! isset($perShop[$sid])) { + $perShop[$sid] = ['total' => 0.0]; + for ($i = 1; $i <= 12; $i++) { + $perShop[$sid][$i] = 0.0; + } + } + } + + $reportRows = []; + if ($designatedShopIds !== []) { + $shops = model(DesignatedShopModel::class) + ->where('ds_lg_idx', $lgIdx) + ->whereIn('ds_idx', $designatedShopIds) + ->orderBy('ds_shop_no', 'ASC') + ->orderBy('ds_idx', 'ASC') + ->findAll(); + foreach ($shops as $ds) { + $id = (int) ($ds->ds_idx ?? 0); + if ($id <= 0) { + continue; + } + $m = $perShop[$id] ?? null; + if ($m === null) { + $m = ['total' => 0.0]; + for ($i = 1; $i <= 12; $i++) { + $m[$i] = 0.0; + } + } + $addrParts = array_filter([ + trim((string) ($ds->ds_addr ?? '')), + trim((string) ($ds->ds_addr_detail ?? '')), + ], static fn (string $s): bool => $s !== ''); + $reportRows[] = [ + 'ds_idx' => $id, + 'name' => trim((string) ($ds->ds_name ?? '')), + 'rep' => trim((string) ($ds->ds_rep_name ?? '')), + 'address' => implode(' ', $addrParts), + 'months' => array_map(static fn (int $mi): float => (float) ($m[$mi] ?? 0.0), range(1, 12)), + 'total' => (float) ($m['total'] ?? 0.0), + ]; + } + } + + $grandMonths = array_fill(0, 12, 0.0); + $grandTotal = 0.0; + foreach ($reportRows as $rw) { + foreach (range(0, 11) as $i) { + $grandMonths[$i] += (float) ($rw['months'][$i] ?? 0.0); + } + $grandTotal += (float) ($rw['total'] ?? 0.0); + } + + $zoneLabel = '전체'; + if ($zoneCode !== '') { + $zoneLabel = $zoneCode; + } + $bagLabel = '전체'; + if ($bagCode !== '') { + $bagLabel = $bagCode . (isset($bagOptions[$bagCode]) && $bagOptions[$bagCode] !== '' && $bagOptions[$bagCode] !== $bagCode + ? ' (' . $bagOptions[$bagCode] . ')' + : ''); + } + $catLabelFilter = ($catFilter !== '' && isset($catLabels[$catFilter])) ? $catLabels[$catFilter] : '전체'; + $metricLabel = $metric === 'amt' ? '금액' : '수량'; + + if ($export) { + $headers = array_merge( + ['지정판매소', '대표자명', '주소', '합계'], + array_map(static fn (int $m): string => $m . '월', range(1, 12)) + ); + $exportRows = []; + foreach ($reportRows as $rw) { + $line = [ + (string) ($rw['name'] ?? ''), + (string) ($rw['rep'] ?? ''), + (string) ($rw['address'] ?? ''), + $metric === 'amt' + ? (string) (int) round((float) ($rw['total'] ?? 0)) + : (string) (int) round((float) ($rw['total'] ?? 0)), + ]; + foreach (($rw['months'] ?? []) as $mv) { + $line[] = $metric === 'amt' + ? (string) (int) round((float) $mv) + : (string) (int) round((float) $mv); + } + $exportRows[] = $line; + } + if ($reportRows !== []) { + $gLine = ['전체 합계', '', '', $metric === 'amt' ? (string) (int) round($grandTotal) : (string) (int) round($grandTotal)]; + foreach ($grandMonths as $gm) { + $gLine[] = $metric === 'amt' ? (string) (int) round($gm) : (string) (int) round($gm); + } + $exportRows[] = $gLine; + } + $exportStamp = date('Ymd_His'); + export_excel_2003_xml( + '지정판매소별판매현황_' . $exportStamp, + '지정판매소별', + $headers, + $exportRows + ); + } + + $printExtraLines = [ + '조회기간: ' . $startDate . ' ~ ' . $endDate . ' · 읍면동: ' . $zoneLabel . ' · 봉투종류: ' . $bagLabel . ' · 구분: ' . $catLabelFilter . ' · 집계: ' . $metricLabel, + $metric === 'amt' ? '(단위: 원)' : '(단위: 매)', + $metric === 'amt' + ? '금액은 조회기간 내 판매(sale) 건의 판매금액을 거래 월별로 합산합니다(반품·취소는 제외).' + : '수량은 반품·판매취소를 연초~조회 종료일 판매와 품목별 FIFO로 맞추고, 반품취소·판매는 원복합니다. 월별 표시는 조회기간 안만 반영하며, 조회 밖 달의 합은 기간 내 첫 달에 합산됩니다.', + ]; + + return $this->renderWorkPage('지정 판매소별 판매현황', 'admin/sales_report/shop_sales', [ + 'startDate' => $startDate, + 'endDate' => $endDate, + 'zoneCode' => $zoneCode, + 'bagCode' => $bagCode, + 'catFilter' => $catFilter, + 'metric' => $metric, + 'zoneOptions' => $zoneOptions, + 'bagOptions' => $bagOptions, + 'catLabels' => $catLabels, + 'reportRows' => $reportRows, + 'grandMonths' => $grandMonths, + 'grandTotal' => $grandTotal, + 'lgName' => $lgName, + 'zoneLabel' => $zoneLabel, + 'bagLabel' => $bagLabel, + 'catLabelFilter' => $catLabelFilter, + 'metricLabel' => $metricLabel, + 'printExtraLines' => $printExtraLines, + ]); } /** - * P5-06: 홈택스 세금계산서 엑셀 내보내기 + * 판매 집계와 무관하게, 조회 조건(지자체·구군·읍면동)에 해당하는 지정 판매소 ID 목록 + * + * @return list + */ + private function shopSalesDesignatedShopIds( + \CodeIgniter\Database\BaseConnection $db, + int $lgIdx, + string $fixedGugunCode, + string $zoneCode + ): array { + $sql = 'SELECT ds_idx FROM designated_shop WHERE ds_lg_idx = ? '; + $params = [$lgIdx]; + if ($fixedGugunCode !== '') { + $sql .= ' AND ds_gugun_code = ? '; + $params[] = $fixedGugunCode; + } + if ($zoneCode !== '') { + $sql .= ' AND TRIM(ds_zone_code) = ? '; + $params[] = $zoneCode; + } + $sql .= ' ORDER BY ds_shop_no ASC, ds_idx ASC'; + $ids = []; + foreach ($db->query($sql, $params)->getResult() as $row) { + $id = (int) ($row->ds_idx ?? 0); + if ($id > 0) { + $ids[] = $id; + } + } + + return $ids; + } + + /** + * @return list + */ + private function shopSalesDistinctZoneCodes( + \CodeIgniter\Database\BaseConnection $db, + int $lgIdx, + string $fixedGugunCode + ): array { + $sql = "SELECT DISTINCT TRIM(ds_zone_code) AS z FROM designated_shop WHERE ds_lg_idx = ? AND TRIM(ds_zone_code) != '' "; + $params = [$lgIdx]; + if ($fixedGugunCode !== '') { + $sql .= ' AND ds_gugun_code = ? '; + $params[] = $fixedGugunCode; + } + $sql .= ' ORDER BY z'; + $out = []; + foreach ($db->query($sql, $params)->getResult() as $o) { + $z = trim((string) ($o->z ?? '')); + if ($z !== '') { + $out[] = $z; + } + } + + return $out; + } + + /** + * @return array code => name + */ + private function shopSalesDistinctBagCodes(\CodeIgniter\Database\BaseConnection $db, int $lgIdx): array + { + $rows = $db->query( + 'SELECT DISTINCT TRIM(bs_bag_code) AS c, TRIM(bs_bag_name) AS n FROM bag_sale WHERE bs_lg_idx = ? AND TRIM(bs_bag_code) != \'\' ORDER BY c LIMIT 400', + [$lgIdx] + )->getResult(); + $map = []; + foreach ($rows as $o) { + $c = trim((string) ($o->c ?? '')); + if ($c === '') { + continue; + } + $map[$c] = trim((string) ($o->n ?? '')); + } + + return $map; + } + + /** + * 지정판매소별 판매현황 FIFO용: 연초~end 또는 조회 시작 중 이른 날부터 end까지 로드(선입선출 재고 맞춤) + * + * @return list + */ + private function fetchShopSalesFifoRowsBetween( + \CodeIgniter\Database\BaseConnection $db, + int $lgIdx, + string $startDate, + string $endDate, + string $zoneCode, + string $bagCode, + string $fixedGugunCode + ): array { + $sql = 'SELECT bs.bs_idx, bs.bs_ds_idx AS ds_idx, bs.bs_sale_date AS d, bs.bs_type AS t, ' + . 'TRIM(bs.bs_bag_code) AS bag_code, TRIM(bs.bs_bag_name) AS bag_name, ' + . 'ABS(bs.bs_qty) AS q, bs.bs_unit_price AS unit_p, ABS(bs.bs_amount) AS line_amt ' + . 'FROM bag_sale bs ' + . 'INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx ' + . 'WHERE bs.bs_lg_idx = ? AND bs.bs_sale_date BETWEEN ? AND ? ' + . "AND bs.bs_type IN ('sale','return','cancel','return_cancel') "; + $params = [$lgIdx, $startDate, $endDate]; + if ($fixedGugunCode !== '') { + $sql .= ' AND ds.ds_gugun_code = ? '; + $params[] = $fixedGugunCode; + } + if ($zoneCode !== '') { + $sql .= ' AND TRIM(ds.ds_zone_code) = ? '; + $params[] = $zoneCode; + } + if ($bagCode !== '') { + $sql .= ' AND TRIM(bs.bs_bag_code) = ? '; + $params[] = $bagCode; + } + $sql .= 'ORDER BY bs.bs_sale_date ASC, bs.bs_idx ASC'; + + return $db->query($sql, $params)->getResult(); + } + + /** + * 금액 집계: 조회기간 내 sale 건의 판매금액을 거래 월별 합산(기능목록 「판매된 금액」) + * + * @param list $rows + * + * @return array> + */ + private function shopSalesMonthlyGrossSaleAmountByShop( + array $rows, + string $startDate, + string $endDate, + string $catFilter + ): array { + /** @var array> $perShop */ + $perShop = []; + + foreach ($rows as $r) { + if ((string) ($r->t ?? '') !== 'sale') { + continue; + } + + $name = (string) ($r->bag_name ?? ''); + $code = (string) ($r->bag_code ?? ''); + $ck = $this->classifyLedgerProductCategory($name, $code); + if ($catFilter !== '' && $ck !== $catFilter) { + continue; + } + + $ds = (int) ($r->ds_idx ?? 0); + if ($ds <= 0) { + continue; + } + + $dStr = (string) ($r->d ?? ''); + if ($dStr < $startDate || $dStr > $endDate) { + continue; + } + + $saleMonth = (int) date('n', strtotime($dStr)); + if ($saleMonth < 1 || $saleMonth > 12) { + continue; + } + + $add = $this->shopSalesResolveLineAmount($r); + if ($add <= 0.0) { + continue; + } + + if (! isset($perShop[$ds])) { + $perShop[$ds] = ['total' => 0.0]; + for ($i = 1; $i <= 12; $i++) { + $perShop[$ds][$i] = 0.0; + } + } + + $perShop[$ds][$saleMonth] += $add; + $perShop[$ds]['total'] += $add; + } + + return $perShop; + } + + /** + * @param object $row fetchShopSalesFifoRowsBetween 결과 행 + */ + private function shopSalesResolveLineAmount(object $row): float + { + $q = (float) ($row->q ?? 0); + $unit = (float) ($row->unit_p ?? 0); + $line = (float) ($row->line_amt ?? 0); + if ($q > 0.0 && $unit > 0.0) { + return $q * $unit; + } + + return $line > 0.0 ? $line : 0.0; + } + + /** + * 판매·반품취소는 조회기간 내 월별 가산(+FIFO 적재), 반품·판매취소는 FIFO로 원판매월 차감 + * + * @param list $rows + * + * @return array> + */ + private function shopSalesFifoMonthlyByShop( + array $rows, + string $startDate, + string $endDate, + string $catFilter, + string $metric + ): array { + /** @var array> $perShop */ + $perShop = []; + /** @var array>> $queues */ + $queues = []; + + foreach ($rows as $r) { + $name = (string) ($r->bag_name ?? ''); + $code = (string) ($r->bag_code ?? ''); + $ck = $this->classifyLedgerProductCategory($name, $code); + if ($catFilter !== '' && $ck !== $catFilter) { + continue; + } + + $ds = (int) ($r->ds_idx ?? 0); + $bag = trim((string) ($r->bag_code ?? '')); + if ($ds <= 0 || $bag === '') { + continue; + } + + $dStr = (string) ($r->d ?? ''); + $t = (string) ($r->t ?? ''); + $q = (float) ($r->q ?? 0); + if ($q <= 0.0) { + continue; + } + + $saleMonth = (int) date('n', strtotime($dStr)); + if ($saleMonth < 1 || $saleMonth > 12) { + continue; + } + $inWindow = ($dStr >= $startDate && $dStr <= $endDate); + + if ($t === 'sale' || $t === 'return_cancel') { + $this->shopSalesFifoApplyInbound($perShop, $queues, $ds, $bag, $q, $saleMonth, $inWindow, $metric, (float) ($r->line_amt ?? 0), (float) ($r->unit_p ?? 0)); + } elseif ($t === 'return' || $t === 'cancel') { + $this->shopSalesFifoApplyOutbound($perShop, $queues, $ds, $bag, $q, $saleMonth, $metric, (float) ($r->line_amt ?? 0)); + } + } + + foreach (array_keys($perShop) as $ds) { + $sum = 0.0; + for ($i = 1; $i <= 12; $i++) { + $sum += (float) ($perShop[$ds][$i] ?? 0.0); + } + $perShop[$ds]['total'] = $sum; + } + + return $perShop; + } + + /** + * @param array> $perShop + * @param array>> $queues + */ + private function shopSalesFifoApplyInbound( + array &$perShop, + array &$queues, + int $ds, + string $bag, + float $q, + int $saleMonth, + bool $inWindow, + string $metric, + float $lineAmt, + float $unitPrice + ): void { + if (! isset($perShop[$ds])) { + $perShop[$ds] = ['total' => 0.0]; + for ($i = 1; $i <= 12; $i++) { + $perShop[$ds][$i] = 0.0; + } + } + if (! isset($queues[$ds][$bag])) { + $queues[$ds][$bag] = []; + } + $queues[$ds][$bag][] = [ + 'qty' => $q, + 'm' => $saleMonth, + 'unit' => $unitPrice, + ]; + if ($inWindow) { + $perShop[$ds][$saleMonth] += $q; + $perShop[$ds]['total'] += $q; + } + } + + /** + * @param array> $perShop + * @param array>> $queues + */ + private function shopSalesFifoApplyOutbound( + array &$perShop, + array &$queues, + int $ds, + string $bag, + float $q, + int $eventMonth, + string $metric, + float $lineAmt + ): void { + if (! isset($perShop[$ds])) { + $perShop[$ds] = ['total' => 0.0]; + for ($i = 1; $i <= 12; $i++) { + $perShop[$ds][$i] = 0.0; + } + } + if (! isset($queues[$ds][$bag])) { + $queues[$ds][$bag] = []; + } + + $need = $q; + $origRQty = $q; + $origRAmt = $lineAmt; + $returnMonth = $eventMonth; + + while ($need > 1e-9 && ($queues[$ds][$bag] ?? []) !== []) { + $lot0 = &$queues[$ds][$bag][0]; + $take = min($need, $lot0['qty']); + $deduct = $take; + $lotMonth = (int) ($lot0['m'] ?? $returnMonth); + if ($lotMonth >= 1 && $lotMonth <= 12) { + $perShop[$ds][$lotMonth] -= $deduct; + $perShop[$ds]['total'] -= $deduct; + } + $need -= $take; + $lot0['qty'] -= $take; + if ($lot0['qty'] <= 1e-9) { + array_shift($queues[$ds][$bag]); + } + unset($lot0); + } + + if ($need > 1e-9) { + $deduct = $need; + if ($returnMonth >= 1 && $returnMonth <= 12) { + $perShop[$ds][$returnMonth] -= $deduct; + $perShop[$ds]['total'] -= $deduct; + } + } + } + + /** + * 조회기간과 겹치지 않는 달의 합계를, 겹치는 첫 달로 옮김(표시상 조회 밖 달에만 음수가 남는 것을 방지) + * + * @param array> $perShop + */ + private function shopSalesRollOffWindowMonthsIntoFirstVisibleMonth(array &$perShop, string $displayStart, string $displayEnd): void + { + $year = (int) date('Y', strtotime($displayStart)); + if ($year !== (int) date('Y', strtotime($displayEnd))) { + return; + } + + foreach (array_keys($perShop) as $ds) { + $roll = 0.0; + $firstVisible = 0; + for ($m = 1; $m <= 12; $m++) { + $monthStart = sprintf('%04d-%02d-01', $year, $m); + $monthEnd = date('Y-m-t', strtotime($monthStart)); + $overlaps = ! ($monthEnd < $displayStart || $monthStart > $displayEnd); + if ($overlaps) { + if ($firstVisible === 0) { + $firstVisible = $m; + } + + continue; + } + $roll += (float) ($perShop[$ds][$m] ?? 0.0); + $perShop[$ds][$m] = 0.0; + } + if ($firstVisible >= 1 && abs($roll) > 1e-12) { + $perShop[$ds][$firstVisible] = (float) ($perShop[$ds][$firstVisible] ?? 0.0) + $roll; + } + $sum = 0.0; + for ($i = 1; $i <= 12; $i++) { + $sum += (float) ($perShop[$ds][$i] ?? 0.0); + } + $perShop[$ds]['total'] = $sum; + } + } + + + /** + * 조회 구간에 겹치는 달 중 음수는, 같은 구간 안에서 더 이른 달의 양수부터 상쇄하고 남은 부분은 마지막 달에 반영(0 미만은 0으로 맞춤) + * + * @param array> $perShop + */ + private function shopSalesAbsorbNegativeVisibleMonthsIntoEarlierPositive(array &$perShop, string $displayStart, string $displayEnd): void + { + $year = (int) date('Y', strtotime($displayStart)); + if ($year !== (int) date('Y', strtotime($displayEnd))) { + return; + } + $visible = []; + for ($m = 1; $m <= 12; $m++) { + $monthStart = sprintf('%04d-%02d-01', $year, $m); + $monthEnd = date('Y-m-t', strtotime($monthStart)); + if (! ($monthEnd < $displayStart || $monthStart > $displayEnd)) { + $visible[] = $m; + } + } + if ($visible === []) { + return; + } + $lastVisible = $visible[array_key_last($visible)]; + + foreach (array_keys($perShop) as $ds) { + $need = 0.0; + foreach ($visible as $m) { + $v = (float) ($perShop[$ds][$m] ?? 0.0); + if ($v < 0.0) { + $need -= $v; + $perShop[$ds][$m] = 0.0; + } + } + if ($need <= 1e-12) { + $sum = 0.0; + for ($i = 1; $i <= 12; $i++) { + $sum += (float) ($perShop[$ds][$i] ?? 0.0); + } + $perShop[$ds]['total'] = $sum; + + continue; + } + foreach ($visible as $m) { + if ($need <= 1e-12) { + break; + } + $v = (float) ($perShop[$ds][$m] ?? 0.0); + if ($v <= 0.0) { + continue; + } + $take = min($v, $need); + $perShop[$ds][$m] = $v - $take; + $need -= $take; + } + if ($need > 1e-12) { + $tail = (float) ($perShop[$ds][$lastVisible] ?? 0.0) - $need; + $perShop[$ds][$lastVisible] = $tail < 0.0 ? 0.0 : $tail; + } + $sum = 0.0; + for ($i = 1; $i <= 12; $i++) { + $sum += (float) ($perShop[$ds][$i] ?? 0.0); + } + $perShop[$ds]['total'] = $sum; + } + } + + /** + * P5-06: 홈택스 처리 — 판매기간·작성일자 조회, 일괄발급 엑셀 양식 표, 엑셀·인쇄 */ public function hometaxExport() { @@ -204,51 +2255,302 @@ class SalesReport extends BaseController return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } - $startDate = $this->request->getGet('start_date') ?? date('Y-m-01'); - $endDate = $this->request->getGet('end_date') ?? date('Y-m-d'); - - $db = \Config\Database::connect(); - $rows = $db->query(" - SELECT bs.bs_sale_date, ds.ds_biz_no as buyer_biz_no, ds.ds_name as buyer_name, - bs.bs_bag_name, ABS(bs.bs_qty) as qty, bs.bs_unit_price, bs.bs_amount - FROM bag_sale bs - LEFT JOIN designated_shop ds ON bs.bs_ds_idx = ds.ds_idx - WHERE bs.bs_lg_idx = ? AND bs.bs_sale_date BETWEEN ? AND ? AND bs.bs_type = 'sale' - ORDER BY bs.bs_sale_date, ds.ds_name - ", [$lgIdx, $startDate, $endDate])->getResult(); - - // 지자체 정보 (공급자) - $lg = model(\App\Models\LocalGovernmentModel::class)->find($lgIdx); - $supplierBizNo = $lg->lg_biz_no ?? ''; - $supplierName = $lg->lg_name ?? ''; - - $csvRows = []; - foreach ($rows as $row) { - $amount = (int) $row->bs_amount; - $tax = (int) round($amount * 0.1); - $csvRows[] = [ - str_replace('-', '', $row->bs_sale_date), // 작성일자 (YYYYMMDD) - $supplierBizNo, // 공급자사업자번호 - $supplierName, // 공급자상호 - $row->buyer_biz_no ?? '', // 공급받는자사업자번호 - $row->buyer_name ?? '', // 공급받는자상호 - $row->bs_bag_name, // 품목 - (int) $row->qty, // 수량 - (int) $row->bs_unit_price, // 단가 - $amount, // 공급가액 - $tax, // 세액 - ]; + $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-01-01')); + $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); + $writeDate = (string) ($this->request->getGet('write_date') ?? date('Y-m-d')); + if ($startDate > $endDate) { + [$startDate, $endDate] = [$endDate, $startDate]; } - export_csv( - '홈택스_세금계산서_' . date('Ymd') . '.csv', - ['작성일자', '공급자사업자번호', '공급자상호', '공급받는자사업자번호', '공급받는자상호', '품목', '수량', '단가', '공급가액', '세액'], - $csvRows - ); + // 최초 진입(search 미지정)도 기본 기간·작성일로 즉시 조회. 명시적으로 search=0이면 미조회. + $searchGet = (string) ($this->request->getGet('search') ?? ''); + $searched = $searchGet !== '0'; + $export = (int) ($this->request->getGet('export') ?? 0) === 1; + + if ($export && ! $searched) { + return redirect()->to(mgmt_url('reports/hometax-export?' . http_build_query([ + 'start_date' => $startDate, + 'end_date' => $endDate, + 'write_date' => $writeDate, + 'search' => '1', + 'export' => '1', + ]))); + } + + $lg = model(LocalGovernmentModel::class)->find($lgIdx); + if (! $lg) { + return redirect()->to(work_area_home_url())->with('error', '지자체 정보를 찾을 수 없습니다.'); + } + $lgName = trim((string) ($lg->lg_name ?? '')); + + $headers = $this->hometaxBulkColumnHeaders(); + $writeYmd = str_replace('-', '', $writeDate); + + /** @var list $rows */ + $rows = []; + $totalCount = 0; + $totalSupplyAmount = 0.0; + $totalTaxAmount = 0.0; + $missingBizCount = 0; + + if ($searched) { + $db = \Config\Database::connect(); + $rows = $db->query( + 'SELECT bs.bs_sale_date, bs.bs_bag_name, ABS(bs.bs_qty) AS qty, bs.bs_unit_price, bs.bs_amount, ' + . 'ds.ds_biz_no, ds.ds_name, ds.ds_rep_name, ds.ds_branch_no, ' + . 'ds.ds_addr, ds.ds_addr_detail, ds.ds_biz_type, ds.ds_biz_kind, ds.ds_email ' + . 'FROM bag_sale bs ' + . 'INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx ' + . 'WHERE bs.bs_lg_idx = ? AND bs.bs_sale_date BETWEEN ? AND ? AND bs.bs_type = \'sale\' ' + . 'ORDER BY bs.bs_sale_date ASC, ds.ds_name ASC, bs.bs_idx ASC', + [$lgIdx, $startDate, $endDate] + )->getResult(); + + $totalCount = count($rows); + foreach ($rows as $r) { + $amt = (float) ($r->bs_amount ?? 0); + $totalSupplyAmount += $amt; + $totalTaxAmount += (int) round($amt * 0.1); + if ($this->hometaxBuyerBizMissing((string) ($r->ds_biz_no ?? ''))) { + $missingBizCount++; + } + } + } + + $displayRows = []; + foreach ($rows as $r) { + $displayRows[] = $this->hometaxBuildBulkRow($lg, $r, $writeYmd); + } + + $hometaxPrintPages = $this->hometaxPrintPageDefinitions(count($headers)); + + if ($export) { + export_excel_2003_xml_workbook( + '홈택스처리_' . date('Ymd_His'), + $this->hometaxBuildPrintStyleExcelSheets($headers, $displayRows, $hometaxPrintPages) + ); + } + + $printExtraLines = [ + '판매일자: ' . $startDate . ' ~ ' . $endDate . ' · 작성일자: ' . $writeDate, + '엑셀·인쇄는 동일한 2쪽 열 구성(1쪽 공급자·공급받는자, 2쪽 금액·품목)입니다.', + ]; + + return $this->renderWorkPage('홈택스 처리', 'admin/sales_report/hometax_process', [ + 'startDate' => $startDate, + 'endDate' => $endDate, + 'writeDate' => $writeDate, + 'searched' => $searched, + 'headers' => $headers, + 'displayRows' => $displayRows, + 'hometaxPrintPages' => $hometaxPrintPages, + 'hometaxColMinPx' => $this->hometaxColumnMinWidthsPx(), + 'totalCount' => $totalCount, + 'totalSupplyAmount' => $totalSupplyAmount, + 'totalTaxAmount' => $totalTaxAmount, + 'missingBizCount' => $missingBizCount, + 'lgName' => $lgName, + 'printExtraLines' => $printExtraLines, + ]); } /** - * P5-08: 반품/파기 현황 + * 인쇄·엑셀 공통: 1쪽(앞 14열) / 2쪽(식별 3열 + 나머지) + * + * @return list}> + */ + private function hometaxPrintPageDefinitions(int $colCount): array + { + if ($colCount <= 0) { + return []; + } + + $splitEnd = 13; + $pages = [ + [ + 'label' => '1쪽 — 전자세금계산서·공급자·공급받는자', + 'sheet_name' => '1쪽 공급자공급받는자', + 'cols' => range(0, min($splitEnd, $colCount - 1)), + ], + ]; + + if ($splitEnd + 1 < $colCount) { + $page2Cols = [0, 1, 2]; + for ($i = $splitEnd + 1; $i < $colCount; $i++) { + $page2Cols[] = $i; + } + $pages[] = [ + 'label' => '2쪽 — 식별정보·금액·품목', + 'sheet_name' => '2쪽 금액품목', + 'cols' => $page2Cols, + ]; + } + + return $pages; + } + + /** + * @return list + */ + private function hometaxColumnMinWidthsPx(): array + { + return [ + 52, 72, 72, 88, 48, 80, 56, 140, 48, 48, 100, + 88, 48, 80, 56, 140, 48, 48, 100, + 72, 72, 48, 72, 48, 48, 56, 56, 56, + ]; + } + + /** + * @param list $headers + * @param list> $displayRows + * @param list> $printPages + * + * @return list, rows: list>, col_widths: list}> + */ + private function hometaxBuildPrintStyleExcelSheets(array $headers, array $displayRows, array $printPages): array + { + $minPx = $this->hometaxColumnMinWidthsPx(); + $sheets = []; + + foreach ($printPages as $page) { + /** @var list $cols */ + $cols = array_values((array) ($page['cols'] ?? [])); + if ($cols === []) { + continue; + } + + $sheetHeaders = []; + $colWidths = []; + foreach ($cols as $ci) { + $sheetHeaders[] = (string) ($headers[$ci] ?? ''); + $colWidths[] = (int) ($minPx[$ci] ?? 72); + } + + $sheetRows = []; + foreach ($displayRows as $row) { + $line = []; + foreach ($cols as $ci) { + $line[] = (string) ($row[$ci] ?? ''); + } + $sheetRows[] = $line; + } + + $sheets[] = [ + 'name' => (string) ($page['sheet_name'] ?? 'Sheet'), + 'headers' => $sheetHeaders, + 'rows' => $sheetRows, + 'col_widths' => $colWidths, + ]; + } + + return $sheets; + } + + /** + * 홈택스 일괄발급 엑셀과 동일한 열 제목(순서 고정) + * + * @return list + */ + private function hometaxBulkColumnHeaders(): array + { + return [ + '전자세금계산서종류', + '작성일자', + '공급일자', + '공급자등록번호', + '공급자종사업장번호', + '공급자상호', + '공급자성명', + '공급자사업장주소', + '공급자업태', + '공급자종목', + '공급자이메일1', + '공급받는자사업자등록번호', + '공급받는자종사업장번호', + '공급받는자상호', + '공급받는자성명', + '공급받는자사업장주소', + '공급받는자업태', + '공급받는자종목', + '공급받는자이메일1', + '공급가액합계', + '세액합계', + '영수청구구분', + '품목명', + '규격', + '수량', + '단가', + '공급가액', + '세액', + '비고', + ]; + } + + private function hometaxOnlyDigits(?string $s): string + { + return (string) preg_replace('/\D+/', '', (string) ($s ?? '')); + } + + private function hometaxBuyerBizMissing(string $raw): bool + { + $d = $this->hometaxOnlyDigits($raw); + + return $d === '' || strlen($d) < 10; + } + + /** + * @return list + */ + private function hometaxBuildBulkRow(object $lg, object $r, string $writeYmd): array + { + $supplyYmd = str_replace('-', '', (string) ($r->bs_sale_date ?? '')); + $amount = (int) round((float) ($r->bs_amount ?? 0)); + $tax = (int) round($amount * 0.1); + $qty = (int) ($r->qty ?? 0); + $unit = (int) round((float) ($r->bs_unit_price ?? 0)); + + $supplierBiz = $this->hometaxOnlyDigits((string) ($lg->lg_biz_no ?? '')); + $buyerBiz = $this->hometaxOnlyDigits((string) ($r->ds_biz_no ?? '')); + + $buyerAddr = trim(trim((string) ($r->ds_addr ?? '')) . ' ' . trim((string) ($r->ds_addr_detail ?? ''))); + + return [ + '01', + $writeYmd, + $supplyYmd, + $supplierBiz, + '', + trim((string) ($lg->lg_name ?? '')), + '', + trim((string) ($lg->lg_addr ?? '')), + '', + '', + '', + $buyerBiz, + trim((string) ($r->ds_branch_no ?? '')), + trim((string) ($r->ds_name ?? '')), + trim((string) ($r->ds_rep_name ?? '')), + $buyerAddr, + trim((string) ($r->ds_biz_type ?? '')), + trim((string) ($r->ds_biz_kind ?? '')), + trim((string) ($r->ds_email ?? '')), + (string) $amount, + (string) $tax, + '02', + trim((string) ($r->bs_bag_name ?? '')), + '', + (string) $qty, + (string) $unit, + (string) $amount, + (string) $tax, + '', + ]; + } + + + /** + * P5-08: 반품/파기 현황 (레거시 sm805r) */ public function returns() { @@ -258,23 +2560,126 @@ class SalesReport extends BaseController return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } - $startDate = $this->request->getGet('start_date') ?? date('Y-m-01'); - $endDate = $this->request->getGet('end_date') ?? date('Y-m-d'); - $db = \Config\Database::connect(); + $queried = $this->request->getGet('search') === '1'; + $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); + $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); + if ($startDate > $endDate) { + [$startDate, $endDate] = [$endDate, $startDate]; + } - $result = $db->query(" - SELECT bs_sale_date, bs_ds_name, bs_bag_code, bs_bag_name, bs_type, - ABS(bs_qty) as qty, ABS(bs_amount) as amount - FROM bag_sale - WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ? AND bs_type IN('return','cancel') - ORDER BY bs_sale_date DESC, bs_ds_name - ", [$lgIdx, $startDate, $endDate])->getResult(); + $ioType = (string) ($this->request->getGet('io_type') ?? 'out'); + if (! in_array($ioType, ['in', 'out'], true)) { + $ioType = 'out'; + } - return $this->renderWorkPage('반품/파기 현황', 'admin/sales_report/returns', compact('result', 'startDate', 'endDate')); + $result = $queried + ? $this->fetchReturnDisposeRows($lgIdx, $startDate, $endDate, $ioType) + : []; + + return $this->renderWorkPage('반품/파기 현황', 'admin/sales_report/returns', [ + 'result' => $result, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'ioType' => $ioType, + 'queried' => $queried, + 'exportQuery' => $this->returnsExportQueryString(), + ]); + } + + public function returnsExport() + { + helper(['admin', 'export']); + $lgIdx = admin_effective_lg_idx(); + if (! $lgIdx) { + return redirect()->to(mgmt_url('reports/returns'))->with('error', '지자체를 선택해 주세요.'); + } + + if ($this->request->getGet('search') !== '1') { + 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')); + $ioType = (string) ($this->request->getGet('io_type') ?? 'out'); + if (! in_array($ioType, ['in', 'out'], true)) { + $ioType = 'out'; + } + + $rows = []; + foreach ($this->fetchReturnDisposeRows($lgIdx, $startDate, $endDate, $ioType) as $row) { + $rows[] = [ + (string) $row->bs_sale_date, + (string) $row->bs_ds_name, + $this->returnDisposeKindLabel($row), + (string) (int) $row->qty, + $this->returnDisposeTypeLabel((string) $row->bs_type), + ]; + } + + $ioLabel = $ioType === 'in' ? '입고' : '출고'; + export_excel_2003_xml( + '반품파기현황_' . $startDate . '_' . $endDate . '.xls', + '반품파기현황', + ['일자', '반품처', '종류', '수량', '구분'], + $rows + ); + + return null; } /** - * P5-10: LOT 수불 조회 + * @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(); + + 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}) + ORDER BY bs_sale_date ASC, bs_ds_name ASC, bs_bag_code ASC + ", array_merge([$lgIdx, $startDate, $endDate], $bsTypes))->getResult(); + } + + private function returnDisposeKindLabel(object $row): string + { + $name = trim((string) ($row->bs_bag_name ?? '')); + $code = trim((string) ($row->bs_bag_code ?? '')); + if ($name !== '') { + return $name; + } + + return $code !== '' ? $code : '-'; + } + + private function returnDisposeTypeLabel(string $bsType): string + { + return match ($bsType) { + 'return' => '반품', + '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 — 바코드/봉투번호) */ public function lotFlow() { @@ -284,25 +2689,33 @@ class SalesReport extends BaseController return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } - $lotNo = $this->request->getGet('lot_no') ?? ''; - $order = null; - $items = []; - $receivings = []; + $barcode = trim((string) ($this->request->getGet('barcode') ?? $this->request->getGet('bag_no') ?? '')); + $lotNo = trim((string) ($this->request->getGet('lot_no') ?? '')); + $queried = $this->request->getGet('search') === '1' + || $barcode !== '' + || $lotNo !== ''; - if ($lotNo !== '') { - $db = \Config\Database::connect(); - $order = $db->query("SELECT * FROM bag_order WHERE bo_lg_idx = ? AND bo_lot_no = ?", [$lgIdx, $lotNo])->getRow(); - if ($order) { - $items = $db->query("SELECT * FROM bag_order_item WHERE boi_bo_idx = ? ORDER BY boi_bag_code", [(int) $order->bo_idx])->getResult(); - $receivings = $db->query("SELECT * FROM bag_receiving WHERE br_bo_idx = ? ORDER BY br_receive_date", [(int) $order->bo_idx])->getResult(); - } + $builder = new \App\Libraries\BagLotFlowBuilder(); + if ($barcode !== '') { + $result = $builder->buildByBarcode($lgIdx, $barcode, true); + } elseif ($lotNo !== '') { + $result = $builder->buildByLotNo($lgIdx, $lotNo, true); + $barcode = $lotNo; + } else { + $result = $builder->buildByBarcode($lgIdx, '', $queried); } - return $this->renderWorkPage('LOT 수불 조회', 'admin/sales_report/lot_flow', compact('lotNo', 'order', 'items', 'receivings')); + return $this->renderWorkPage('LOT 수불 조회', 'admin/sales_report/lot_flow', [ + 'barcode' => $barcode, + 'lotNo' => $lotNo, + 'queried' => $queried, + 'result' => $result, + 'testSamples' => $builder->loadTestSamples($lgIdx), + ]); } /** - * P5-11: 기타 입출고 목록 + * P5-11: 기타 입출고 (레거시 w_gb401e — 수불년월·구분·마스터/상세) */ public function miscFlow() { @@ -312,36 +2725,126 @@ class SalesReport extends BaseController return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } - $startDate = $this->request->getGet('start_date') ?? date('Y-m-01'); - $endDate = $this->request->getGet('end_date') ?? date('Y-m-d'); - $db = \Config\Database::connect(); + $flowPeriod = $this->miscFlowParsePeriodFromRequest($this->request); + $flowYear = $flowPeriod['y']; + $flowMonthNum = $flowPeriod['m']; + $bagCodeFilter = trim((string) ($this->request->getGet('bag_code') ?? '')); + $bagKind = trim((string) ($this->request->getGet('bag_kind') ?? '')); + $bagCancelOnly = (int) ($this->request->getGet('bag_cancel') ?? 0) === 1; + $selKey = trim((string) ($this->request->getGet('sel_key') ?? '')); - // bag_misc_flow 테이블이 존재하는지 확인 + $db = \Config\Database::connect(); $tableExists = $db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() > 0; - $result = []; + + $kindE = model(CodeKindModel::class)->where('ck_code', 'E')->first(); + $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); + $bagKindOptions = $kindE + ? model(CodeDetailModel::class)->getByKind((int) $kindE->ck_idx, true, $lgIdx) + : []; + $bagCodes = $kindO ? model(CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) : []; + + $groups = []; + $rawRows = []; + $totalRowsForLg = 0; if ($tableExists) { - $result = $db->query(" - SELECT * FROM bag_misc_flow - WHERE bmf_lg_idx = ? AND bmf_date BETWEEN ? AND ? - ORDER BY bmf_date DESC, bmf_idx DESC - ", [$lgIdx, $startDate, $endDate])->getResult(); + $totalRow = $db->query( + 'SELECT COUNT(*) AS c FROM bag_misc_flow WHERE bmf_lg_idx = ?', + [$lgIdx] + )->getRow(); + $totalRowsForLg = (int) ($totalRow->c ?? 0); + + $rawRows = $this->miscFlowFetchRows($db, $lgIdx, $flowYear, $flowMonthNum); + + foreach ($rawRows as $row) { + if ($bagCodeFilter !== '' && ! str_contains((string) $row->bmf_bag_code, $bagCodeFilter)) { + continue; + } + if ($bagKind !== '' && ! $this->miscFlowBagCodeMatchesKind((string) $row->bmf_bag_code, $bagKind)) { + continue; + } + if ($bagCancelOnly && (string) $row->bmf_type !== 'out') { + continue; + } + $key = $this->miscFlowGroupKey($row); + if (! isset($groups[$key])) { + $groups[$key] = [ + 'key' => $key, + 'date' => (string) $row->bmf_date, + 'type' => (string) $row->bmf_type, + 'typeLabel' => (string) $row->bmf_type === 'in' ? '입고' : '출고', + 'reason' => (string) $row->bmf_reason, + 'totalQty' => 0, + 'lines' => [], + ]; + } + $groups[$key]['totalQty'] += (int) $row->bmf_qty; + $groups[$key]['lines'][] = $row; + } } - // 봉투 코드 목록 - $kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first(); - $bagCodes = $kindO ? model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) : []; + $groupList = array_values($groups); + $selectedGroup = null; + $detailLines = []; + $selectedBagKind = ''; + $selectedBagKindLabel = ''; - return $this->renderWorkPage('기타 입출고', 'admin/sales_report/misc_flow', compact('result', 'startDate', 'endDate', 'bagCodes', 'tableExists')); + if ($groupList !== []) { + if ($selKey !== '' && isset($groups[$selKey])) { + $selectedGroup = $groups[$selKey]; + } else { + $selectedGroup = $groupList[0]; + $selKey = (string) $selectedGroup['key']; + } + $detailLines = $selectedGroup['lines']; + if ($detailLines !== []) { + $selectedBagKind = $this->miscFlowInferBagKindFromCode((string) $detailLines[0]->bmf_bag_code); + foreach ($bagKindOptions as $opt) { + if ((string) $opt->cd_code === $selectedBagKind) { + $selectedBagKindLabel = (string) $opt->cd_name; + break; + } + } + } + } + + $packagingMap = model(PackagingUnitModel::class)->latestActiveMapByBagCode($lgIdx); + $filters = [ + 'flow_y' => $flowYear, + 'flow_m' => $flowMonthNum, + 'bag_code' => $bagCodeFilter, + 'bag_kind' => $bagKind, + 'bag_cancel' => $bagCancelOnly, + 'sel_key' => $selKey, + ]; + + return $this->renderWorkPage('기타 입출고', 'admin/sales_report/misc_flow', [ + 'groupList' => $groupList, + 'selectedGroup' => $selectedGroup, + 'detailLines' => $detailLines, + 'filters' => $filters, + 'dateYearMin' => (int) date('Y') - 12, + 'dateYearMax' => (int) date('Y') + 2, + 'bagCodes' => $bagCodes, + 'bagKindOptions' => $bagKindOptions, + 'packagingMap' => $packagingMap, + 'selectedBagKind' => $selectedBagKind, + 'selectedBagKindLabel' => $selectedBagKindLabel, + 'tableExists' => $tableExists, + 'totalRowsForLg' => $totalRowsForLg, + 'fetchedRowCount' => count($rawRows), + ]); } /** - * P5-11: 기타 입출고 등록 처리 + * P5-11: 기타 입출고 품목 등록 */ public function miscFlowStore() { helper('admin'); $lgIdx = admin_effective_lg_idx(); - if (!$lgIdx) return redirect()->to(mgmt_url('reports/misc-flow'))->with('error', '지자체를 선택해 주세요.'); + if (! $lgIdx) { + return redirect()->to(mgmt_url('reports/misc-flow'))->with('error', '지자체를 선택해 주세요.'); + } $rules = [ 'bmf_type' => 'required|in_list[in,out]', @@ -354,34 +2857,255 @@ class SalesReport extends BaseController return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } - $bagCode = $this->request->getPost('bmf_bag_code'); - $qty = (int) $this->request->getPost('bmf_qty'); - $type = $this->request->getPost('bmf_type'); + $bagCode = (string) $this->request->getPost('bmf_bag_code'); + $bagKindFilter = trim((string) $this->request->getPost('bmf_bag_kind')); + if ($bagKindFilter !== '' && ! $this->miscFlowBagCodeMatchesKind($bagCode, $bagKindFilter)) { + return redirect()->back()->withInput()->with('error', '선택한 봉투구분과 봉투코드가 일치하지 않습니다.'); + } - // 봉투명 조회 - $kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first(); - $detail = $kindO ? model(\App\Models\CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $bagCode, $lgIdx) : null; - $bagName = $detail ? $detail->cd_name : ''; + $qty = (int) $this->request->getPost('bmf_qty'); + $type = (string) $this->request->getPost('bmf_type'); + + $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); + $detail = $kindO + ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, $bagCode, $lgIdx) + : null; + $bagName = $detail ? (string) $detail->cd_name : ''; $db = \Config\Database::connect(); + if ($db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() === 0) { + return redirect()->to(mgmt_url('reports/misc-flow'))->with('error', 'bag_misc_flow 테이블이 없습니다.'); + } + $db->transStart(); $db->query(" INSERT INTO bag_misc_flow (bmf_lg_idx, bmf_type, bmf_bag_code, bmf_bag_name, bmf_qty, bmf_date, bmf_reason, bmf_regdate) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ", [$lgIdx, $type, $bagCode, $bagName, $qty, $this->request->getPost('bmf_date'), $this->request->getPost('bmf_reason'), date('Y-m-d H:i:s')]); + ", [ + $lgIdx, + $type, + $bagCode, + $bagName, + $qty, + $this->request->getPost('bmf_date'), + $this->request->getPost('bmf_reason'), + date('Y-m-d H:i:s'), + ]); - // 재고 조정 $delta = ($type === 'in') ? $qty : -$qty; model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, $delta); $db->transComplete(); - return redirect()->to(mgmt_url('reports/misc-flow'))->with('success', '기타 입출고가 등록되었습니다.'); + $redirectQs = $this->miscFlowRedirectQueryFromPost(); + $newKey = $this->miscFlowGroupKey((object) [ + 'bmf_date' => $this->request->getPost('bmf_date'), + 'bmf_type' => $type, + 'bmf_reason' => $this->request->getPost('bmf_reason'), + ]); + + return redirect()->to(mgmt_url('reports/misc-flow') . '?' . $redirectQs . '&sel_key=' . rawurlencode($newKey)) + ->with('success', '기타 입출고가 등록되었습니다.'); } /** - * P5-07: 봉투 수불 현황 + * P5-11: 선택 건(그룹) 삭제 + 재고 복원 + */ + public function miscFlowDelete() + { + helper('admin'); + $lgIdx = admin_effective_lg_idx(); + if (! $lgIdx) { + return redirect()->to(mgmt_url('reports/misc-flow'))->with('error', '지자체를 선택해 주세요.'); + } + + $selKey = trim((string) $this->request->getPost('sel_key')); + if ($selKey === '') { + return redirect()->back()->with('error', '삭제할 입출고를 선택해 주세요.'); + } + + $db = \Config\Database::connect(); + if ($db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() === 0) { + return redirect()->to(mgmt_url('reports/misc-flow'))->with('error', 'bag_misc_flow 테이블이 없습니다.'); + } + + $flowPeriod = $this->miscFlowParsePeriodFromRequest($this->request); + $rows = $this->miscFlowFetchRows($db, $lgIdx, $flowPeriod['y'], $flowPeriod['m']); + + $toDelete = []; + foreach ($rows as $row) { + if ($this->miscFlowGroupKey($row) === $selKey) { + $toDelete[] = $row; + } + } + + if ($toDelete === []) { + return redirect()->back()->with('error', '삭제할 내역을 찾을 수 없습니다.'); + } + + $inventory = model(BagInventoryModel::class); + $db->transStart(); + foreach ($toDelete as $row) { + $qty = (int) $row->bmf_qty; + $delta = ((string) $row->bmf_type === 'in') ? -$qty : $qty; + $inventory->adjustQty( + $lgIdx, + (string) $row->bmf_bag_code, + (string) $row->bmf_bag_name, + $delta + ); + $db->query('DELETE FROM bag_misc_flow WHERE bmf_idx = ? AND bmf_lg_idx = ?', [(int) $row->bmf_idx, $lgIdx]); + } + $db->transComplete(); + + $redirectQs = $this->miscFlowRedirectQueryFromPost(false); + + return redirect()->to(mgmt_url('reports/misc-flow') . ($redirectQs !== '' ? '?' . $redirectQs : '')) + ->with('success', '선택한 기타 입출고가 삭제되었습니다.'); + } + + /** + * @return array{y: string, m: string} + */ + private function miscFlowParsePeriodFromRequest($request): array + { + $flowYear = trim((string) ($request->getGet('flow_y') ?? $request->getPost('flow_y') ?? '')); + $flowMonthNum = trim((string) ($request->getGet('flow_m') ?? $request->getPost('flow_m') ?? '')); + + if ($flowYear === '' && $flowMonthNum === '') { + $legacy = $this->miscFlowNormalizeMonth((string) ($request->getGet('flow_month') ?? $request->getPost('flow_month') ?? '')); + if ($legacy !== '' && preg_match('/^(\d{4})-(\d{1,2})$/', $legacy, $m) === 1) { + $flowYear = $m[1]; + $flowMonthNum = (string) (int) $m[2]; + } + } + + if ($flowYear !== '' && ctype_digit($flowYear)) { + $flowYear = sprintf('%04d', (int) $flowYear); + } else { + $flowYear = ''; + } + + if ($flowMonthNum !== '' && ctype_digit($flowMonthNum)) { + $monthInt = (int) $flowMonthNum; + $flowMonthNum = ($monthInt >= 1 && $monthInt <= 12) ? (string) $monthInt : ''; + } else { + $flowMonthNum = ''; + } + + if ($flowYear === '') { + $flowMonthNum = ''; + } + + return ['y' => $flowYear, 'm' => $flowMonthNum]; + } + + private function miscFlowNormalizeMonth(string $raw): string + { + $raw = str_replace('.', '-', trim($raw)); + if ($raw === '') { + return ''; + } + if (preg_match('/^\d{4}-\d{1,2}$/', $raw) === 1) { + [$y, $m] = explode('-', $raw, 2); + + return sprintf('%04d-%02d', (int) $y, (int) $m); + } + if (preg_match('/^\d{6}$/', $raw) === 1) { + return substr($raw, 0, 4) . '-' . substr($raw, 4, 2); + } + + return ''; + } + + /** + * @param \CodeIgniter\Database\BaseConnection $db + * @return list + */ + private function miscFlowFetchRows($db, int $lgIdx, string $flowYear, string $flowMonthNum): array + { + if ($flowYear !== '' && $flowMonthNum !== '') { + $monthStart = sprintf('%04d-%02d-01', (int) $flowYear, (int) $flowMonthNum); + $monthEnd = date('Y-m-t', strtotime($monthStart)); + + return $db->query(" + SELECT * FROM bag_misc_flow + WHERE bmf_lg_idx = ? AND bmf_date BETWEEN ? AND ? + ORDER BY bmf_date DESC, bmf_regdate DESC, bmf_idx DESC + ", [$lgIdx, $monthStart, $monthEnd])->getResult(); + } + + if ($flowYear !== '') { + $yearStart = sprintf('%04d-01-01', (int) $flowYear); + $yearEnd = sprintf('%04d-12-31', (int) $flowYear); + + return $db->query(" + SELECT * FROM bag_misc_flow + WHERE bmf_lg_idx = ? AND bmf_date BETWEEN ? AND ? + ORDER BY bmf_date DESC, bmf_regdate DESC, bmf_idx DESC + ", [$lgIdx, $yearStart, $yearEnd])->getResult(); + } + + return $db->query(" + SELECT * FROM bag_misc_flow + WHERE bmf_lg_idx = ? + ORDER BY bmf_date DESC, bmf_regdate DESC, bmf_idx DESC + ", [$lgIdx])->getResult(); + } + + private function miscFlowGroupKey(object $row): string + { + $date = (string) ($row->bmf_date ?? ''); + $type = (string) ($row->bmf_type ?? ''); + $reason = (string) ($row->bmf_reason ?? ''); + + return md5($date . '|' . $type . '|' . $reason); + } + + private function miscFlowInferBagKindFromCode(string $bagCode): string + { + $bagCode = trim($bagCode); + if ($bagCode === '') { + return ''; + } + if (strlen($bagCode) >= 2 && ctype_digit(substr($bagCode, 0, 2))) { + return substr($bagCode, 0, 2); + } + + return ''; + } + + private function miscFlowBagCodeMatchesKind(string $bagCode, string $bagKind): bool + { + if ($bagKind === '') { + return true; + } + + return str_starts_with($bagCode, $bagKind); + } + + private function miscFlowRedirectQueryFromPost(bool $includeSelKey = true): string + { + $params = array_filter([ + 'flow_y' => $this->request->getPost('flow_y') ?? $this->request->getGet('flow_y'), + 'flow_m' => $this->request->getPost('flow_m') ?? $this->request->getGet('flow_m'), + 'bag_code' => $this->request->getPost('bag_code') ?? $this->request->getGet('bag_code'), + 'bag_kind' => $this->request->getPost('bag_kind') ?? $this->request->getGet('bag_kind'), + 'bag_cancel' => $this->request->getPost('bag_cancel') ?? $this->request->getGet('bag_cancel'), + ], static fn ($v) => $v !== null && $v !== '' && $v !== '0'); + if ($includeSelKey) { + $sel = $this->request->getPost('sel_key'); + if ($sel !== null && $sel !== '') { + $params['sel_key'] = $sel; + } + } + + return http_build_query($params); + } + + /** + * 쓰레기봉투 수급 계획 (레거시 w_gm820r) */ public function supplyDemand() { @@ -391,41 +3115,50 @@ class SalesReport extends BaseController return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); } - $startDate = $this->request->getGet('start_date') ?? date('Y-m-01'); - $endDate = $this->request->getGet('end_date') ?? date('Y-m-d'); - $db = \Config\Database::connect(); + $refDate = (string) ($this->request->getGet('ref_date') ?? date('Y-m-d')); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $refDate)) { + $refDate = date('Y-m-d'); + } - // 입고 합계 - $receiving = $db->query(" - SELECT br_bag_code, br_bag_name, - SUM(br_qty_sheet) as recv_qty - FROM bag_receiving - WHERE br_lg_idx = ? AND br_receive_date BETWEEN ? AND ? - GROUP BY br_bag_code, br_bag_name - ", [$lgIdx, $startDate, $endDate])->getResult(); + $leadDays = (int) ($this->request->getGet('lead_days') ?? 40); + if ($leadDays < 1 || $leadDays > 365) { + $leadDays = 40; + } - // 판매 합계 - $sales = $db->query(" - SELECT bs_bag_code, bs_bag_name, - SUM(CASE WHEN bs_type='sale' THEN ABS(bs_qty) ELSE 0 END) as sale_qty, - SUM(CASE WHEN bs_type='return' THEN ABS(bs_qty) ELSE 0 END) as return_qty - FROM bag_sale - WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ? - GROUP BY bs_bag_code, bs_bag_name - ", [$lgIdx, $startDate, $endDate])->getResult(); + $stockScope = (string) ($this->request->getGet('stock_scope') ?? 'all'); + $salesScope = (string) ($this->request->getGet('sales_scope') ?? 'all'); + if (! in_array($stockScope, ['all', 'legacy', 'barcode'], true)) { + $stockScope = 'all'; + } + if (! in_array($salesScope, ['all', 'legacy', 'barcode'], true)) { + $salesScope = 'all'; + } - // 불출 합계 - $issues = $db->query(" - SELECT bi2_bag_code, bi2_bag_name, - SUM(bi2_qty) as issue_qty - FROM bag_issue - WHERE bi2_lg_idx = ? AND bi2_issue_date BETWEEN ? AND ? AND bi2_status = 'normal' - GROUP BY bi2_bag_code, bi2_bag_name - ", [$lgIdx, $startDate, $endDate])->getResult(); + $queried = $this->request->getGet('search') === '1'; + $built = (new \App\Libraries\BagSupplyPlanBuilder())->build( + $lgIdx, + $refDate, + $leadDays, + $stockScope, + $salesScope, + $queried + ); - // 현재 재고 - $inventory = model(BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->findAll(); + $scopeLabel = static fn (string $s): string => match ($s) { + 'legacy' => '기존 봉투', + 'barcode' => '바코드 봉투', + default => 'ALL', + }; - return $this->renderWorkPage('봉투 수불 현황', 'admin/sales_report/supply_demand', compact('receiving', 'sales', 'issues', 'inventory', 'startDate', 'endDate')); + return $this->renderWorkPage('쓰레기봉투 수급 계획', 'admin/sales_report/supply_demand', [ + 'refDate' => $refDate, + 'leadDays' => $leadDays, + 'stockScope' => $stockScope, + 'salesScope' => $salesScope, + 'rows' => $built['rows'], + 'queried' => $built['queried'], + 'stockLabel' => $scopeLabel($stockScope), + 'salesLabel' => $scopeLabel($salesScope), + ]); } } diff --git a/app/Controllers/Admin/ShopOrder.php b/app/Controllers/Admin/ShopOrder.php index 57bb419..8486e46 100644 --- a/app/Controllers/Admin/ShopOrder.php +++ b/app/Controllers/Admin/ShopOrder.php @@ -57,8 +57,21 @@ class ShopOrder extends BaseController $shops = model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll(); $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; + $priceMap = model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx); + $unitRows = model(PackagingUnitModel::class) + ->where('pu_lg_idx', $lgIdx) + ->where('pu_state', 1) + ->findAll(); + $unitMap = []; + foreach ($unitRows as $unit) { + $code = (string) ($unit->pu_bag_code ?? ''); + if ($code === '' || isset($unitMap[$code])) { + continue; + } + $unitMap[$code] = $unit; + } - return $this->renderWorkPage('주문 접수', 'admin/shop_order/create', compact('shops', 'bagCodes')); + return $this->renderWorkPage('주문 접수', 'admin/shop_order/create', compact('shops', 'bagCodes', 'priceMap', 'unitMap')); } public function store() @@ -81,7 +94,7 @@ class ShopOrder extends BaseController $dsIdx = (int) $this->request->getPost('so_ds_idx'); $shop = model(DesignatedShopModel::class)->find($dsIdx); - $this->orderModel->insert([ + $orderData = [ 'so_lg_idx' => $lgIdx, 'so_ds_idx' => $dsIdx, 'so_ds_name' => $shop ? $shop->ds_name : '', @@ -91,8 +104,24 @@ class ShopOrder extends BaseController 'so_status' => 'normal', 'so_orderer_idx' => session()->get('mb_idx'), 'so_regdate' => date('Y-m-d H:i:s'), - ]); + ]; + // shop_order.so_channel 이 아직 반영되지 않은 DB와의 호환 처리 + if ($db->fieldExists('so_channel', 'shop_order')) { + $orderData['so_channel'] = 'phone'; + } + + $insertOk = $this->orderModel->insert($orderData); + if ($insertOk === false) { + $db->transRollback(); + $errors = $this->orderModel->errors(); + $msg = ! empty($errors) ? implode(' / ', array_values($errors)) : '주문 저장에 실패했습니다.'; + return redirect()->back()->withInput()->with('error', $msg); + } $soIdx = (int) $this->orderModel->getInsertID(); + if ($soIdx <= 0) { + $db->transRollback(); + return redirect()->back()->withInput()->with('error', '주문번호 생성에 실패했습니다. DB 스키마를 확인해 주세요.'); + } $bagCodes = $this->request->getPost('item_bag_code') ?? []; $qtys = $this->request->getPost('item_qty') ?? []; diff --git a/app/Controllers/Bag.php b/app/Controllers/Bag.php index 73c2f13..e209699 100644 --- a/app/Controllers/Bag.php +++ b/app/Controllers/Bag.php @@ -21,9 +21,12 @@ use App\Models\CompanyModel; use App\Models\PackagingUnitModel; use App\Models\SalesAgencyModel; use App\Models\ShopOrderModel; +use App\Models\ShopOrderItemModel; use App\Models\DesignatedShopModel; use App\Models\LocalGovernmentModel; use App\Models\ManagerModel; +use App\Libraries\BagAnalyticsReportBuilder; +use App\Libraries\BagFlowReportBuilder; use Config\Roles; class Bag extends BaseController @@ -37,6 +40,38 @@ class Bag extends BaseController return admin_effective_lg_idx(); } + /** + * 통계 분석: 사이트 메뉴와 동일하게 작업 지자체 PK (Super Admin 미선택 시 기본 지자체). + */ + private function analyticsLgIdx(): int + { + helper('admin'); + + return resolve_site_menu_lg_idx(); + } + + /** + * [개발용 패널] DB naive datetime 을 한국 표준시로 변환해 JSON 에 내려준다. + * MySQL TIMESTAMP/UTC 세션 등으로 `Y-m-d H:i:s` 가 UTC 기준일 때, 그대로 찍히면 현지 시각과 9시간 어긋난다. + */ + private function formatDevPanelEventTime(?string $dbValue): string + { + if ($dbValue === null) { + return ''; + } + $trim = trim($dbValue); + if ($trim === '' || str_starts_with($trim, '0000-00-00')) { + return ''; + } + try { + $utc = new \DateTimeImmutable($trim, new \DateTimeZone('UTC')); + + return $utc->setTimezone(new \DateTimeZone('Asia/Seoul'))->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return $trim; + } + } + /** * 입고 화면용 인계자: 제작업체(company) 담당자. * @@ -402,11 +437,7 @@ class Bag extends BaseController $queryForPager, static fn ($v) => $v !== null && $v !== '' ); - $pagerPath = site_url('bag/prices'); - if ($queryForPager !== []) { - $pagerPath .= '?' . http_build_query($queryForPager); - } - $priceModel->pager->setPath($pagerPath); + apply_pager_path($priceModel->pager, 'bag/prices', $queryForPager); $pager = $priceModel->pager; $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); @@ -3266,41 +3297,262 @@ SQL); public function flow(): string { $lgIdx = $this->lgIdx(); - $data = ['receiving' => [], 'sales' => [], 'issues' => [], 'inventory' => [], 'startDate' => null, 'endDate' => null]; + $queried = $this->request->getGet('search') === '1' + || $this->request->getGet('start_date') !== null + || $this->request->getGet('end_date') !== null; - if ($lgIdx) { - $startDate = $this->request->getGet('start_date'); - $endDate = $this->request->getGet('end_date'); - $data['startDate'] = $startDate; - $data['endDate'] = $endDate; - - $data['inventory'] = model(BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->findAll(); - - $recvBuilder = model(BagReceivingModel::class)->where('br_lg_idx', $lgIdx); - if ($startDate) $recvBuilder->where('br_receive_date >=', $startDate); - if ($endDate) $recvBuilder->where('br_receive_date <=', $endDate); - $data['receiving'] = $recvBuilder->findAll(); - - $saleBuilder = model(BagSaleModel::class)->where('bs_lg_idx', $lgIdx); - if ($startDate) $saleBuilder->where('bs_sale_date >=', $startDate); - if ($endDate) $saleBuilder->where('bs_sale_date <=', $endDate); - $data['sales'] = $saleBuilder->findAll(); - - $issueBuilder = model(BagIssueModel::class)->where('bi2_lg_idx', $lgIdx); - if ($startDate) $issueBuilder->where('bi2_issue_date >=', $startDate); - if ($endDate) $issueBuilder->where('bi2_issue_date <=', $endDate); - $data['issues'] = $issueBuilder->findAll(); + $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); + $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); + if ($startDate > $endDate) { + [$startDate, $endDate] = [$endDate, $startDate]; } - return $this->render('봉투 수불 관리', 'bag/flow', $data); + $aggMode = (string) ($this->request->getGet('agg_mode') ?? 'period'); + if (! in_array($aggMode, ['daily', 'period'], true)) { + $aggMode = 'period'; + } + + $bagCode = trim((string) ($this->request->getGet('bag_code') ?? '')); + $bagKind = trim((string) ($this->request->getGet('bag_kind') ?? '')); + $saIdx = (int) ($this->request->getGet('sa_idx') ?? 0); + + $report = ['rows' => [], 'bagKindLabels' => [], 'queried' => false]; + $bagProducts = []; + $agencies = []; + + if ($lgIdx) { + $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); + if ($kindO) { + foreach (model(CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) as $d) { + $code = (string) ($d->cd_code ?? ''); + if ($code === '') { + continue; + } + $bagProducts[] = ['code' => $code, 'name' => (string) ($d->cd_name ?? $code)]; + } + } + $agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll(); + if ($queried) { + $report = (new BagFlowReportBuilder())->build( + $lgIdx, + $startDate, + $endDate, + $aggMode, + $bagCode, + $bagKind, + $saIdx, + true + ); + } + } + + $kindE = model(CodeKindModel::class)->where('ck_code', 'E')->first(); + $bagKindOptions = $kindE + ? model(CodeDetailModel::class)->getByKind((int) $kindE->ck_idx, true, $lgIdx) + : []; + + return $this->render('기간별 봉투 수불 현황', 'bag/flow', [ + 'startDate' => $startDate, + 'endDate' => $endDate, + 'aggMode' => $aggMode, + 'bagCode' => $bagCode, + 'bagKind' => $bagKind, + 'saIdx' => $saIdx, + 'bagProducts' => $bagProducts, + 'bagKindOptions' => $bagKindOptions, + 'agencies' => $agencies, + 'rows' => $report['rows'], + 'queried' => $queried && $lgIdx !== null, + 'exportQuery' => $this->flowExportQueryString(), + ]); + } + + public function flowExport() + { + helper(['export', 'admin']); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->to(base_url('bag/flow'))->with('error', '지자체를 선택해 주세요.'); + } + + $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); + $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); + $aggMode = (string) ($this->request->getGet('agg_mode') ?? 'period'); + if (! in_array($aggMode, ['daily', 'period'], true)) { + $aggMode = 'period'; + } + + $report = (new BagFlowReportBuilder())->build( + $lgIdx, + $startDate, + $endDate, + $aggMode, + trim((string) ($this->request->getGet('bag_code') ?? '')), + trim((string) ($this->request->getGet('bag_kind') ?? '')), + (int) ($this->request->getGet('sa_idx') ?? 0), + true + ); + + $lgName = ''; + $lgRow = model(\App\Models\LocalGovernmentModel::class)->find($lgIdx); + if ($lgRow) { + $lgName = (string) ($lgRow->lg_name ?? ''); + } + + $aggLabel = $aggMode === 'daily' ? '일자별' : '기간별'; + $metaLines = [ + '출력일: ' . date('Y-m-d'), + '조회기간: ' . $startDate . ' ~ ' . $endDate . ' (' . $aggLabel . ')', + '(단위: 매)', + ]; + + export_bag_flow_report_excel( + 'bag_flow_' . $startDate . '_' . $endDate . '_' . date('Ymd_His'), + $lgName, + '기간별 봉투 수불 현황', + $metaLines, + $report['rows'] + ); + + return null; + } + + private function flowExportQueryString(): string + { + $params = array_filter([ + 'search' => '1', + 'start_date' => $this->request->getGet('start_date'), + 'end_date' => $this->request->getGet('end_date'), + 'agg_mode' => $this->request->getGet('agg_mode'), + 'bag_code' => $this->request->getGet('bag_code'), + 'bag_kind' => $this->request->getGet('bag_kind'), + 'sa_idx' => $this->request->getGet('sa_idx'), + ], static fn ($v) => $v !== null && $v !== ''); + + return $params === [] ? 'search=1' : http_build_query($params); } // ────────────────────────────────────────────── - // 통계 분석 관리 + // 통계 분석 관리 (w_gm604r / w_gm606r / w_gm607r) // ────────────────────────────────────────────── - public function analytics(): string + public function analytics(): RedirectResponse { - return $this->render('통계 분석 관리', 'bag/analytics', []); + $year = (int) date('Y'); + + return redirect()->to(site_url('bag/analytics/year-over-year') . '?' . http_build_query([ + 'search' => '1', + 'year' => $year, + ])); + } + + public function analyticsYearOverYear(): string + { + $lgIdx = $this->analyticsLgIdx(); + + $year = (int) ($this->request->getGet('year') ?? (int) date('Y')); + if ($year < 2000 || $year > 2100) { + $year = (int) date('Y'); + } + $gugunCode = trim((string) ($this->request->getGet('gugun_code') ?? '')); + $dsIdx = (int) ($this->request->getGet('ds_idx') ?? 0); + // 진입 시 기본 조회(현재 연도). search=0 일 때만 미조회 + $queried = $this->request->getGet('search') !== '0'; + + $builder = new BagAnalyticsReportBuilder(); + $filters = $builder->loadFilterOptions($lgIdx); + $report = $builder->buildYearOverYear($lgIdx, $year, $gugunCode, $dsIdx, $queried); + + $gugunLabel = '전체'; + foreach ($filters['gugunOptions'] as $opt) { + if (($opt['code'] ?? '') === $gugunCode && $gugunCode !== '') { + $gugunLabel = (string) ($opt['name'] ?? $gugunCode); + break; + } + } + + return $this->render('전년 대비 판매 분석', 'bag/analytics_yoy', [ + 'year' => $year, + 'gugunCode' => $gugunCode, + 'gugunLabel' => $gugunLabel, + 'dsIdx' => $dsIdx, + 'queried' => $queried, + 'filters' => $filters, + 'report' => $report, + 'lgName' => $filters['lgName'], + ]); + } + + public function analyticsMonthlyTrend(): string + { + $lgIdx = $this->analyticsLgIdx(); + + $baseYm = (string) ($this->request->getGet('base_ym') ?? date('Y-m')); + if (! preg_match('/^\d{4}-\d{2}$/', $baseYm)) { + $baseYm = date('Y-m'); + } + $trendBasis = (string) ($this->request->getGet('trend_basis') ?? 'year_avg'); + if (! in_array($trendBasis, ['year_avg', 'month'], true)) { + $trendBasis = 'year_avg'; + } + $deviationMin = (float) ($this->request->getGet('deviation_min') ?? 0); + $gugunCode = trim((string) ($this->request->getGet('gugun_code') ?? '')); + $saIdx = (int) ($this->request->getGet('sa_idx') ?? 0); + // 진입 시 기본 조회(현재 연·월). search=0 일 때만 미조회 + $queried = $this->request->getGet('search') !== '0'; + + $builder = new BagAnalyticsReportBuilder(); + $filters = $builder->loadFilterOptions($lgIdx); + $report = $builder->buildMonthlyTrend($lgIdx, $baseYm, $trendBasis, $deviationMin, $gugunCode, $saIdx, $queried); + $lgPickNotice = admin_effective_lg_idx() === null + ? '작업 지자체가 선택되지 않아 기본 지자체(' . ($filters['lgName'] ?? '') . ') 기준으로 조회합니다. 상단에서 지자체를 선택하면 해당 데이터로 바뀝니다.' + : ''; + + return $this->render('월별 판매 추이 분석', 'bag/analytics_monthly_trend', [ + 'baseYm' => $baseYm, + 'trendBasis' => $trendBasis, + 'deviationMin' => $deviationMin, + 'gugunCode' => $gugunCode, + 'saIdx' => $saIdx, + 'queried' => $queried, + 'filters' => $filters, + 'rows' => $report['rows'] ?? [], + 'reportMeta' => $report['meta'] ?? [], + 'lgName' => $filters['lgName'], + 'lgPickNotice' => $lgPickNotice, + ]); + } + + public function analyticsSeasonalTrend(): string + { + $lgIdx = $this->analyticsLgIdx(); + + $baseYear = (int) ($this->request->getGet('base_year') ?? (int) date('Y')); + if ($baseYear < 2000 || $baseYear > 2100) { + $baseYear = (int) date('Y'); + } + $season = BagAnalyticsReportBuilder::normalizeSeason((string) ($this->request->getGet('season') ?? 'spring')); + $seasonDef = BagAnalyticsReportBuilder::seasonCatalog()[$season]; + $deviationMin = (float) ($this->request->getGet('deviation_min') ?? 0); + $gugunCode = trim((string) ($this->request->getGet('gugun_code') ?? '')); + // 진입 시 기본 조회(현재 연도). search=0 일 때만 미조회 + $queried = $this->request->getGet('search') !== '0'; + + $builder = new BagAnalyticsReportBuilder(); + $filters = $builder->loadFilterOptions($lgIdx); + $rows = $builder->buildSeasonalTrend($lgIdx, $baseYear, $season, $deviationMin, $gugunCode, $queried); + + return $this->render('계절별 판매 추이 분석', 'bag/analytics_seasonal_trend', [ + 'baseYear' => $baseYear, + 'season' => $season, + 'seasonLabel' => $seasonDef['label'], + 'seasonMonthsLabel' => $seasonDef['months_label'], + 'deviationMin' => $deviationMin, + 'gugunCode' => $gugunCode, + 'queried' => $queried, + 'filters' => $filters, + 'rows' => $rows, + 'lgName' => $filters['lgName'], + ]); } // ────────────────────────────────────────────── @@ -3319,55 +3571,6 @@ SQL); return $this->render('도움말', 'bag/help', []); } - // ────────────────────────────────────────────── - // 재고 조정 (실사) - // ────────────────────────────────────────────── - public function inventoryAdjust(): string - { - $lgIdx = $this->lgIdx(); - $inventory = $lgIdx ? model(BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code')->findAll() : []; - return $this->render('재고 조정', 'bag/inventory_adjust', compact('inventory')); - } - - public function inventoryAdjustStore() - { - helper('admin'); - $lgIdx = $this->lgIdx(); - if (! $lgIdx) { - return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.'); - } - - $rules = [ - 'bag_code' => 'required|max_length[50]', - 'adjust_type' => 'required|in_list[set,add,sub]', - 'qty' => 'required|is_natural', - ]; - if (! $this->validate($rules)) { - return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); - } - - $bagCode = $this->request->getPost('bag_code'); - $type = $this->request->getPost('adjust_type'); - $qty = (int) $this->request->getPost('qty'); - - $invModel = model(BagInventoryModel::class); - $existing = $invModel->where('bi_lg_idx', $lgIdx)->where('bi_bag_code', $bagCode)->first(); - - if ($type === 'set') { - if ($existing) { - $invModel->update($existing->bi_idx, ['bi_qty' => $qty, 'bi_updated_at' => date('Y-m-d H:i:s')]); - } - } elseif ($type === 'add') { - $bagName = $existing ? $existing->bi_bag_name : ''; - $invModel->adjustQty($lgIdx, $bagCode, $bagName, $qty); - } elseif ($type === 'sub') { - $bagName = $existing ? $existing->bi_bag_name : ''; - $invModel->adjustQty($lgIdx, $bagCode, $bagName, -$qty); - } - - return redirect()->to(site_url('bag/inventory'))->with('success', '재고가 조정되었습니다.'); - } - // ══════════════════════════════════════════════ // CRUD — 사이트 레이아웃으로 등록/처리 폼 제공 // ══════════════════════════════════════════════ @@ -3554,7 +3757,7 @@ SQL); $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; $priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : []; - $units = $lgIdx ? model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll() : []; + $unitMapRows = $lgIdx ? model(PackagingUnitModel::class)->latestActiveMapByBagCode($lgIdx) : []; $recentOrders = $lgIdx ? model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->whereLatestHead($lgIdx)->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->findAll(12) : []; @@ -3577,8 +3780,8 @@ SQL); $priceMap[(string) $bagCode] = (float) ($price->bp_order_price ?? 0); } $unitMap = []; - foreach ($units as $unit) { - $unitMap[(string) $unit->pu_bag_code] = [ + foreach ($unitMapRows as $bagCode => $unit) { + $unitMap[(string) $bagCode] = [ 'boxPerPack' => (int) $unit->pu_box_per_pack, 'packPerSheet' => (int) $unit->pu_pack_per_sheet, 'totalPerBox' => (int) $unit->pu_total_per_box, @@ -3982,7 +4185,7 @@ SQL); $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; $priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : []; - $units = $lgIdx ? model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll() : []; + $unitMapRows = $lgIdx ? model(PackagingUnitModel::class)->latestActiveMapByBagCode($lgIdx) : []; $companyMap = []; foreach ($companies as $company) { $companyMap[(int) $company->cp_idx] = (string) $company->cp_name; @@ -4001,8 +4204,8 @@ SQL); $priceMap[(string) $bagCode] = (float) ($price->bp_order_price ?? 0); } $unitMap = []; - foreach ($units as $unit) { - $unitMap[(string) $unit->pu_bag_code] = [ + foreach ($unitMapRows as $bagCode => $unit) { + $unitMap[(string) $bagCode] = [ 'boxPerPack' => (int) $unit->pu_box_per_pack, 'packPerSheet' => (int) $unit->pu_pack_per_sheet, 'totalPerBox' => (int) $unit->pu_total_per_box, @@ -4946,6 +5149,1733 @@ SQL); return redirect()->to(site_url('bag/sales'))->with('success', '판매 등록되었습니다.'); } + /** + * 지정판매소 판매 화면. + */ + public function designatedShopSaleCreate(): string + { + helper('admin'); + $lgIdx = $this->lgIdx(); + $shops = $lgIdx ? model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll() : []; + $orders = $this->phoneOpenOrdersWithItems($lgIdx); + return $this->render('지정판매소 판매', 'bag/designated_shop_sale', compact('shops', 'orders')); + } + + /** + * [개발용] 주문 접수 리스트에서 선택한 판매소(ds_idx) 기준, 판매 스캔 가능 후보 바코드 목록(JSON). + * — `bag_sale_scan_code` in_stock(해당 판매소) + 미수령 전화주문 품목의 `bag_receiving_pack_code` in_stock(팩 코드). + */ + public function designatedShopDevSaleableBarcodes() + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.']); + } + $dsIdx = (int) ($this->request->getGet('ds_idx') ?? 0); + if ($dsIdx <= 0) { + return $this->response->setJSON(['ok' => false, 'message' => '판매소(ds_idx)가 필요합니다.']); + } + + $this->ensureDesignatedSaleScanCodeTable($lgIdx); + $db = \Config\Database::connect(); + $merged = []; + $soIdxHint = (int) ($this->request->getGet('so_idx') ?? 0); + + $scanRows = $db->table('bag_sale_scan_code') + ->select('bssc_code, bssc_bag_code, bssc_bag_name, bssc_unit, bssc_qty, bssc_state, bssc_so_idx') + ->where('bssc_lg_idx', $lgIdx) + ->where('bssc_ds_idx', $dsIdx) + ->where('bssc_state', 'in_stock') + ->orderBy('bssc_code', 'ASC') + ->limit(250) + ->get() + ->getResultArray(); + foreach ($scanRows as $r) { + $merged[] = [ + 'source' => '스캔(in_stock)', + 'code' => (string) ($r['bssc_code'] ?? ''), + 'bag_code' => (string) ($r['bssc_bag_code'] ?? ''), + 'bag_name' => (string) ($r['bssc_bag_name'] ?? ''), + 'unit' => (string) ($r['bssc_unit'] ?? ''), + 'qty' => (int) ($r['bssc_qty'] ?? 0), + 'so_idx' => (int) ($r['bssc_so_idx'] ?? 0), + 'state' => (string) ($r['bssc_state'] ?? ''), + 'extra' => '', + ]; + } + + $bagCodes = $this->phoneOrderBagCodesForDesignatedDev($lgIdx, $dsIdx, $soIdxHint); + if ($bagCodes !== [] && $db->tableExists('bag_receiving_pack_code')) { + // 봉투코드별 30건씩 가져와, 한 코드(예: 3L)에 in_stock 팩이 많을 때 다른 코드(예: 5L)가 누락되는 문제를 방지. + $perBagLimit = 30; + foreach ($bagCodes as $bagCode) { + $brpcRows = $db->table('bag_receiving_pack_code') + ->select('brpc_pack_code, brpc_box_code, brpc_bag_code, brpc_bag_name, brpc_state, brpc_sheet_start_code, brpc_sheet_end_code, brpc_sheet_qty') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_state', 'in_stock') + ->where('brpc_bag_code', $bagCode) + ->orderBy('brpc_pack_code', 'ASC') + ->limit($perBagLimit) + ->get() + ->getResultArray(); + foreach ($brpcRows as $r) { + $pack = (string) ($r['brpc_pack_code'] ?? ''); + if ($pack === '') { + continue; + } + $from = (string) ($r['brpc_sheet_start_code'] ?? ''); + $to = (string) ($r['brpc_sheet_end_code'] ?? ''); + $extra = $from !== '' && $to !== '' ? ($from . ' ~ ' . $to) : ''; + if (($r['brpc_box_code'] ?? '') !== '') { + $extra = trim($extra . ' / 박스:' . (string) ($r['brpc_box_code'] ?? '')); + } + $merged[] = [ + 'source' => '입고(in_stock)', + 'code' => $pack, + 'bag_code' => (string) ($r['brpc_bag_code'] ?? ''), + 'bag_name' => (string) ($r['brpc_bag_name'] ?? ''), + 'unit' => '팩(낱장수)', + 'qty' => max(0, (int) ($r['brpc_sheet_qty'] ?? 0)), + 'so_idx' => 0, + 'state' => (string) ($r['brpc_state'] ?? ''), + 'extra' => $extra, + ]; + } + } + } + + // 선택 주문 품목은 판매 내역에 보이지만, in_stock 입고팩·스캔재고가 없으면 위 루프에는 안 나온다(예: 5L만 미입고). + if ($soIdxHint > 0) { + $order = model(ShopOrderModel::class) + ->where('so_idx', $soIdxHint) + ->where('so_lg_idx', $lgIdx) + ->where('so_status', 'normal') + ->first(); + if ($order !== null) { + $hasBagCode = static function (array $rows, string $bagCode): bool { + $want = trim($bagCode); + if ($want === '') { + return false; + } + foreach ($rows as $row) { + if (trim((string) ($row['bag_code'] ?? '')) === $want) { + return true; + } + } + + return false; + }; + $items = model(ShopOrderItemModel::class)->where('soi_so_idx', $soIdxHint)->findAll(); + foreach ($items as $it) { + $c = trim((string) ($it->soi_bag_code ?? '')); + $name = (string) ($it->soi_bag_name ?? ''); + $qty = (int) ($it->soi_qty ?? 0); + if ($c !== '') { + if (! $hasBagCode($merged, $c)) { + $merged[] = [ + 'source' => '주문품목(재고미일치)', + 'code' => '-', + 'bag_code' => $c, + 'bag_name' => $name, + 'unit' => '주문수량', + 'qty' => $qty, + 'so_idx' => $soIdxHint, + 'state' => '-', + 'extra' => 'in_stock 입고팩·해당 판매소 스캔재고에 이 봉투코드가 없습니다. 입고 데이터·지자체·봉투코드 일치 여부를 확인하세요.', + ]; + } + } elseif ($name !== '') { + $merged[] = [ + 'source' => '주문품목(봉투코드없음)', + 'code' => '-', + 'bag_code' => '', + 'bag_name' => $name, + 'unit' => '주문수량', + 'qty' => $qty, + 'so_idx' => $soIdxHint, + 'state' => '-', + 'extra' => 'shop_order_item.soi_bag_code 가 비어 있어 후보 팩을 조회할 수 없습니다.', + ]; + } + } + } + } + + return $this->response->setJSON(['ok' => true, 'rows' => $merged, 'ds_idx' => $dsIdx]); + } + + /** + * [개발용 임시] 판매관리 각 화면 하단에 표시할 "현재 전체 판매처리된 내역" JSON. + * — `bag_sale_scan_code` 의 lg_idx 전체(상태 무관) 최근 N건을 일자 역순으로 반환. + * sold/in_stock(반품 복귀) 모두 포함되어 한눈에 흐름을 보기 위함. + * 운영 배포 시 라우트·뷰 패널과 함께 제거 예정. + */ + public function devAllSalesHistory() + { + helper('admin'); + $lgIdx = $this->lgIdx(); + + $session = session(); + $diag = [ + 'mb_idx' => $session->get('mb_idx'), + 'mb_level' => $session->get('mb_level'), + 'mb_lg_idx' => $session->get('mb_lg_idx'), + 'admin_selected_lg_idx' => $session->get('admin_selected_lg_idx'), + ]; + + $resp = $this->response + ->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + ->setHeader('Pragma', 'no-cache') + ->setHeader('Expires', '0'); + + if (! $lgIdx) { + return $resp->setJSON([ + 'ok' => false, + 'message' => '작업 지자체가 선택되어 있지 않습니다. (Super Admin이면 /admin/select-local-government 에서 지자체를 선택해 주세요.)', + 'rows' => [], 'lg_idx' => null, + 'orders' => 0, 'sold' => 0, 'returned' => 0, + 'session' => $diag, + ]); + } + + $this->ensureDesignatedSaleScanCodeTable($lgIdx); + $db = \Config\Database::connect(); + + $limit = (int) ($this->request->getGet('limit') ?? 500); + if ($limit <= 0 || $limit > 2000) { + $limit = 500; + } + + // 1) 주문 접수(shop_order + shop_order_item) — 주문 1건 × 품목 N개로 펼침 + $events = []; + $orderCount = 0; + if ($db->tableExists('shop_order')) { + $hasChannel = $db->fieldExists('so_channel', 'shop_order'); + $orderSelect = 'o.so_idx, o.so_ds_idx, o.so_regdate, o.so_status' + . ($hasChannel ? ', o.so_channel' : '') + . ', d.ds_shop_no, d.ds_name'; + $orderRows = $db->table('shop_order o') + ->select($orderSelect) + ->join('designated_shop d', 'd.ds_idx = o.so_ds_idx', 'left') + ->where('o.so_lg_idx', $lgIdx) + ->orderBy('o.so_regdate', 'DESC') + ->orderBy('o.so_idx', 'DESC') + ->limit($limit) + ->get() + ->getResultArray(); + $orderCount = (int) $db->table('shop_order')->where('so_lg_idx', $lgIdx)->countAllResults(); + + $orderIds = array_values(array_filter(array_map(static fn ($r) => (int) ($r['so_idx'] ?? 0), $orderRows), static fn ($v) => $v > 0)); + $itemsByOrder = []; + if ($orderIds !== [] && $db->tableExists('shop_order_item')) { + $itemRows = $db->table('shop_order_item') + ->select('soi_so_idx, soi_bag_code, soi_bag_name, soi_qty') + ->whereIn('soi_so_idx', $orderIds) + ->get() + ->getResultArray(); + foreach ($itemRows as $it) { + $sid = (int) ($it['soi_so_idx'] ?? 0); + $itemsByOrder[$sid] = $itemsByOrder[$sid] ?? []; + $itemsByOrder[$sid][] = $it; + } + } + + foreach ($orderRows as $o) { + $sid = (int) ($o['so_idx'] ?? 0); + $items = $itemsByOrder[$sid] ?? []; + if ($items === []) { + $events[] = [ + 'event_time' => $this->formatDevPanelEventTime($o['so_regdate'] ?? null), + 'event_type' => 'order', + 'so_status' => (string) ($o['so_status'] ?? ''), + 'so_channel' => (string) ($o['so_channel'] ?? ''), + 'ds_idx' => (int) ($o['so_ds_idx'] ?? 0), + 'ds_shop_no' => (string) ($o['ds_shop_no'] ?? ''), + 'ds_name' => (string) ($o['ds_name'] ?? ''), + 'so_idx' => $sid, + 'bag_code' => '', + 'bag_name' => '', + 'code' => '', + 'unit' => '', + 'qty' => 0, + ]; + continue; + } + foreach ($items as $it) { + $events[] = [ + 'event_time' => $this->formatDevPanelEventTime($o['so_regdate'] ?? null), + 'event_type' => 'order', + 'so_status' => (string) ($o['so_status'] ?? ''), + 'so_channel' => (string) ($o['so_channel'] ?? ''), + 'ds_idx' => (int) ($o['so_ds_idx'] ?? 0), + 'ds_shop_no' => (string) ($o['ds_shop_no'] ?? ''), + 'ds_name' => (string) ($o['ds_name'] ?? ''), + 'so_idx' => $sid, + 'bag_code' => (string) ($it['soi_bag_code'] ?? ''), + 'bag_name' => (string) ($it['soi_bag_name'] ?? ''), + 'code' => '', + 'unit' => '', + 'qty' => (int) ($it['soi_qty'] ?? 0), + ]; + } + } + } + + // 2) 판매 처리 / 재고 복귀(bag_sale_scan_code) + $soldCount = 0; + $returnedCount = 0; + if ($db->tableExists('bag_sale_scan_code')) { + $scanRows = $db->table('bag_sale_scan_code b') + ->select('b.bssc_ds_idx, d.ds_shop_no, d.ds_name, b.bssc_so_idx, b.bssc_bag_code, b.bssc_bag_name, b.bssc_code, b.bssc_unit, b.bssc_qty, b.bssc_state, b.bssc_regdate') + ->join('designated_shop d', 'd.ds_idx = b.bssc_ds_idx', 'left') + ->where('b.bssc_lg_idx', $lgIdx) + ->orderBy('b.bssc_regdate', 'DESC') + ->orderBy('b.bssc_idx', 'DESC') + ->limit($limit) + ->get() + ->getResultArray(); + foreach ($scanRows as $s) { + $state = strtolower((string) ($s['bssc_state'] ?? '')); + $events[] = [ + 'event_time' => $this->formatDevPanelEventTime($s['bssc_regdate'] ?? null), + 'event_type' => $state === 'in_stock' ? 'returned' : 'sale', + 'so_status' => '', + 'so_channel' => '', + 'ds_idx' => (int) ($s['bssc_ds_idx'] ?? 0), + 'ds_shop_no' => (string) ($s['ds_shop_no'] ?? ''), + 'ds_name' => (string) ($s['ds_name'] ?? ''), + 'so_idx' => (int) ($s['bssc_so_idx'] ?? 0), + 'bag_code' => (string) ($s['bssc_bag_code'] ?? ''), + 'bag_name' => (string) ($s['bssc_bag_name'] ?? ''), + 'code' => (string) ($s['bssc_code'] ?? ''), + 'unit' => (string) ($s['bssc_unit'] ?? ''), + 'qty' => (int) ($s['bssc_qty'] ?? 0), + ]; + } + $soldCount = (int) $db->table('bag_sale_scan_code')->where('bssc_lg_idx', $lgIdx)->where('bssc_state', 'sold')->countAllResults(); + $returnedCount = (int) $db->table('bag_sale_scan_code')->where('bssc_lg_idx', $lgIdx)->where('bssc_state', 'in_stock')->countAllResults(); + } + + // 시간 역순 통합 정렬 후 limit + usort($events, static function (array $a, array $b): int { + return strcmp((string) ($b['event_time'] ?? ''), (string) ($a['event_time'] ?? '')); + }); + if (count($events) > $limit) { + $events = array_slice($events, 0, $limit); + } + + return $resp->setJSON([ + 'ok' => true, + 'rows' => $events, + 'limit' => $limit, + 'lg_idx' => $lgIdx, + 'orders' => $orderCount, + 'sold' => $soldCount, + 'returned' => $returnedCount, + 'session' => $diag, + ]); + } + + /** + * 지정판매소 판매 화면 개발용 바코드 표: 해당 판매소의 전화주문(정상) 품목 봉투코드. + * 주문 리스트와 동일하게 so_received 로 제한하지 않는다(완료 주문도 표시되므로). + * + * @return list + */ + private function phoneOrderBagCodesForDesignatedDev(int $lgIdx, int $dsIdx, int $soIdxHint): array + { + $db = \Config\Database::connect(); + $orderModel = model(ShopOrderModel::class) + ->where('so_lg_idx', $lgIdx) + ->where('so_ds_idx', $dsIdx) + ->where('so_status', 'normal'); + if ($db->fieldExists('so_channel', 'shop_order')) { + $orderModel->where('so_channel', 'phone'); + } + $orders = $orderModel->findAll(); + $ids = array_values(array_filter(array_map(static fn ($o): int => (int) ($o->so_idx ?? 0), $orders), static fn (int $v): bool => $v > 0)); + $codes = []; + if ($ids !== []) { + $items = model(ShopOrderItemModel::class)->whereIn('soi_so_idx', $ids)->findAll(); + foreach ($items as $it) { + $c = trim((string) ($it->soi_bag_code ?? '')); + if ($c !== '') { + $codes[$c] = true; + } + } + } + + if ($soIdxHint > 0) { + // so_ds_idx 는 요청 ds와 DB 불일치가 있어도 품목 봉투코드 병합은 수행(리스트·주문은 동일 LG 내에서 선택됨). + $order = model(ShopOrderModel::class) + ->where('so_idx', $soIdxHint) + ->where('so_lg_idx', $lgIdx) + ->where('so_status', 'normal') + ->first(); + if ($order !== null) { + $rows = model(ShopOrderItemModel::class)->where('soi_so_idx', $soIdxHint)->findAll(); + foreach ($rows as $it) { + $c = trim((string) ($it->soi_bag_code ?? '')); + if ($c !== '') { + $codes[$c] = true; + } + } + } + } + + return array_keys($codes); + } + + /** + * 바코드 스캔 검증 API. + */ + public function designatedShopSaleScan() + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.']); + } + + $soIdx = (int) ($this->request->getPost('so_idx') ?? 0); + $barcode = trim((string) ($this->request->getPost('barcode') ?? '')); + $pendingMapRaw = (string) ($this->request->getPost('pending_by_bag') ?? '{}'); + $pendingMap = json_decode($pendingMapRaw, true); + if (! is_array($pendingMap)) { + $pendingMap = []; + } + if ($soIdx <= 0 || $barcode === '') { + return $this->response->setJSON(['ok' => false, 'message' => '주문 또는 바코드 값이 올바르지 않습니다.']); + } + + $order = model(ShopOrderModel::class)->where('so_idx', $soIdx)->where('so_lg_idx', $lgIdx)->first(); + if (! $order || (string) ($order->so_status ?? '') !== 'normal') { + return $this->response->setJSON(['ok' => false, 'message' => '선택한 주문을 사용할 수 없습니다.']); + } + + $scan = $this->resolveDesignatedSaleBarcode($lgIdx, $barcode); + if (! $scan['ok']) { + return $this->response->setJSON($scan); + } + + $bagCode = (string) ($scan['bag_code'] ?? ''); + $orderItem = model(ShopOrderItemModel::class) + ->where('soi_so_idx', $soIdx) + ->where('soi_bag_code', $bagCode) + ->first(); + if (! $orderItem) { + return $this->response->setJSON(['ok' => false, 'message' => '선택 주문에 없는 봉투 종류입니다.']); + } + + $soldRows = model(BagSaleModel::class) + ->select('COALESCE(SUM(bs_qty), 0) AS sold_qty') + ->where('bs_lg_idx', $lgIdx) + ->where('bs_so_idx', $soIdx) + ->where('bs_bag_code', $bagCode) + // sale + cancel/return(bs_qty 음수로 저장) = 순판매량 + ->whereIn('bs_type', ['sale', 'cancel', 'return', 'return_cancel']) + ->first(); + $soldQty = (int) ($soldRows->sold_qty ?? 0); + $receiptQty = (int) ($orderItem->soi_qty ?? 0); + $pendingQty = max(0, (int) ($pendingMap[$bagCode] ?? 0)); + $remain = max(0, $receiptQty - $soldQty - $pendingQty); + $scanQty = (int) ($scan['qty'] ?? 0); + + if ($scanQty <= 0) { + return $this->response->setJSON(['ok' => false, 'message' => '수량 계산에 실패했습니다.']); + } + if ($scanQty > $remain) { + return $this->response->setJSON([ + 'ok' => false, + 'message' => '접수 잔량을 초과합니다. (잔량: ' . number_format($remain) . ')', + ]); + } + + return $this->response->setJSON(array_merge($scan, [ + 'ok' => true, + 'remain' => $remain, + 'order_item_qty' => $receiptQty, + 'already_sold_qty' => $soldQty, + ])); + } + + /** + * 지정판매소 판매 저장. + */ + public function designatedShopSaleSave() + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->back()->with('error', '지자체를 선택해 주세요.'); + } + + $soIdx = (int) ($this->request->getPost('so_idx') ?? 0); + $dsIdx = (int) ($this->request->getPost('ds_idx') ?? 0); + $scansJson = (string) ($this->request->getPost('scans_json') ?? '[]'); + $scans = json_decode($scansJson, true); + if ($soIdx <= 0 || ! is_array($scans) || $scans === []) { + return redirect()->back()->with('error', '저장할 판매 데이터가 없습니다.'); + } + + $orderModel = model(ShopOrderModel::class); + $order = $orderModel->where('so_idx', $soIdx)->where('so_lg_idx', $lgIdx)->first(); + if (! $order || (string) ($order->so_status ?? '') !== 'normal') { + return redirect()->back()->with('error', '저장 가능한 주문이 아닙니다.'); + } + if ($dsIdx <= 0) { + $dsIdx = (int) ($order->so_ds_idx ?? 0); + } + + $itemRows = model(ShopOrderItemModel::class)->where('soi_so_idx', $soIdx)->findAll(); + $itemMap = []; + foreach ($itemRows as $it) { + $itemMap[(string) ($it->soi_bag_code ?? '')] = $it; + } + + $this->ensureDesignatedSaleScanCodeTable($lgIdx); + $db = \Config\Database::connect(); + $db->transStart(); + + $saleQtyByBag = []; + $seenBarcode = []; + $now = date('Y-m-d H:i:s'); + foreach ($scans as $scan) { + if (! is_array($scan)) { + continue; + } + $barcode = trim((string) ($scan['barcode'] ?? '')); + $bagCode = trim((string) ($scan['bag_code'] ?? '')); + $bagName = trim((string) ($scan['bag_name'] ?? '')); + $unit = trim((string) ($scan['unit'] ?? '')); + $qty = max(0, (int) ($scan['qty'] ?? 0)); + $packIds = $scan['pack_ids'] ?? []; + if (! is_array($packIds)) { + $packIds = []; + } + + if ($barcode === '' || $bagCode === '' || $qty <= 0) { + continue; + } + if (isset($seenBarcode[$barcode])) { + $db->transRollback(); + return redirect()->back()->with('error', '동일 바코드가 중복되었습니다: ' . $barcode); + } + $seenBarcode[$barcode] = true; + + $exists = $db->table('bag_sale_scan_code') + ->where('bssc_lg_idx', $lgIdx) + ->where('bssc_code', $barcode) + ->where('bssc_state', 'sold') + ->countAllResults(); + if ($exists > 0) { + $db->transRollback(); + return redirect()->back()->with('error', '이미 판매 처리된 바코드가 포함되어 있습니다: ' . $barcode); + } + if (! isset($itemMap[$bagCode])) { + $db->transRollback(); + return redirect()->back()->with('error', '주문에 없는 봉투 종류가 포함되어 있습니다: ' . $bagCode); + } + + $scanCodeRow = $db->table('bag_sale_scan_code') + ->where('bssc_lg_idx', $lgIdx) + ->where('bssc_code', $barcode) + ->get() + ->getRowArray(); + if (is_array($scanCodeRow)) { + $db->table('bag_sale_scan_code') + ->where('bssc_idx', (int) ($scanCodeRow['bssc_idx'] ?? 0)) + ->update([ + 'bssc_so_idx' => $soIdx, + 'bssc_ds_idx' => $dsIdx, + 'bssc_bag_code' => $bagCode, + 'bssc_bag_name' => $bagName, + 'bssc_unit' => $unit, + 'bssc_qty' => $qty, + 'bssc_state' => 'sold', + 'bssc_regdate' => $now, + ]); + } else { + $db->table('bag_sale_scan_code')->insert([ + 'bssc_lg_idx' => $lgIdx, + 'bssc_so_idx' => $soIdx, + 'bssc_ds_idx' => $dsIdx, + 'bssc_bag_code' => $bagCode, + 'bssc_bag_name' => $bagName, + 'bssc_code' => $barcode, + 'bssc_unit' => $unit, + 'bssc_qty' => $qty, + 'bssc_state' => 'sold', + 'bssc_regdate' => $now, + ]); + } + + // 팩/박스 판매일 때만 수신 팩 상태를 sold로 전환한다. + // 낱장 판매는 동일 팩의 다른 낱장을 계속 판매해야 하므로 pack 상태를 유지한다. + if ($packIds !== [] && $unit !== '낱장') { + $db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->whereIn('brpc_idx', array_map(static fn ($v): int => (int) $v, $packIds)) + ->set(['brpc_state' => 'sold']) + ->update(); + } + + $saleQtyByBag[$bagCode] = ($saleQtyByBag[$bagCode] ?? 0) + $qty; + } + + foreach ($saleQtyByBag as $bagCode => $qty) { + $bagCode = (string) $bagCode; + $item = $itemMap[$bagCode] ?? null; + if (! $item) { + continue; + } + $unitPrice = (float) ($item->soi_unit_price ?? 0); + model(BagSaleModel::class)->insert([ + 'bs_lg_idx' => $lgIdx, + 'bs_so_idx' => $soIdx, + 'bs_ds_idx' => $dsIdx, + 'bs_ds_name' => (string) ($order->so_ds_name ?? ''), + 'bs_sale_date' => date('Y-m-d'), + 'bs_bag_code' => $bagCode, + 'bs_bag_name' => (string) ($item->soi_bag_name ?? ''), + 'bs_qty' => (int) $qty, + 'bs_unit_price' => $unitPrice, + 'bs_amount' => $unitPrice * (int) $qty, + 'bs_type' => 'sale', + 'bs_regdate' => $now, + ]); + model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, (string) ($item->soi_bag_name ?? ''), -((int) $qty)); + } + + $soldByBag = []; + $soldRows = model(BagSaleModel::class) + ->select('bs_bag_code, COALESCE(SUM(bs_qty),0) AS sold_qty') + ->where('bs_lg_idx', $lgIdx) + ->where('bs_so_idx', $soIdx) + ->where('bs_type', 'sale') + ->groupBy('bs_bag_code') + ->findAll(); + foreach ($soldRows as $r) { + $soldByBag[(string) ($r->bs_bag_code ?? '')] = (int) ($r->sold_qty ?? 0); + } + $allSold = true; + foreach ($itemRows as $it) { + $code = (string) ($it->soi_bag_code ?? ''); + $need = (int) ($it->soi_qty ?? 0); + $sold = (int) ($soldByBag[$code] ?? 0); + if ($sold < $need) { + $allSold = false; + break; + } + } + if ($allSold) { + $orderModel->update($soIdx, ['so_received' => 1]); + } + + $db->transComplete(); + + return redirect()->to(site_url('bag/sale/designated'))->with('success', '판매 저장이 완료되었습니다.'); + } + + /** + * 지정판매소 반품 화면. + */ + public function designatedShopSaleReturnCreate(): string|RedirectResponse + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->to(site_url('bag/sales'))->with('error', '지자체를 선택해 주세요.'); + } + + $shops = model(DesignatedShopModel::class) + ->where('ds_lg_idx', $lgIdx) + ->where('ds_state', 1) + ->orderBy('ds_name', 'ASC') + ->findAll(); + + $this->ensureDesignatedSaleScanCodeTable($lgIdx); + $db = \Config\Database::connect(); + $devSoldScans = $db->table('bag_sale_scan_code b') + ->select('b.bssc_ds_idx, d.ds_name, b.bssc_so_idx, b.bssc_bag_code, b.bssc_bag_name, b.bssc_code, b.bssc_unit, b.bssc_qty, b.bssc_state, b.bssc_regdate') + ->join('designated_shop d', 'd.ds_idx = b.bssc_ds_idx', 'left') + ->where('b.bssc_lg_idx', $lgIdx) + ->where('b.bssc_state', 'sold') + ->orderBy('b.bssc_regdate', 'DESC') + ->limit(200) + ->get() + ->getResultArray(); + + return $this->render('지정판매소 반품', 'bag/designated_shop_sale_return', [ + 'shops' => $shops, + 'devSoldScans' => $devSoldScans, + ]); + } + + /** + * 지정판매소 반품 바코드 스캔 검증. + */ + public function designatedShopSaleReturnScan() + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.']); + } + + $barcode = trim((string) ($this->request->getPost('barcode') ?? '')); + if ($barcode === '') { + return $this->response->setJSON(['ok' => false, 'message' => '바코드를 입력해 주세요.']); + } + + $this->ensureDesignatedSaleScanCodeTable($lgIdx); + $db = \Config\Database::connect(); + $scan = $db->table('bag_sale_scan_code b') + ->select('b.bssc_so_idx, b.bssc_ds_idx, b.bssc_bag_code, b.bssc_bag_name, b.bssc_code, b.bssc_unit, b.bssc_qty, d.ds_shop_no, d.ds_name, d.ds_rep_name, d.ds_tel, d.ds_addr, d.ds_addr_detail') + ->join('designated_shop d', 'd.ds_idx = b.bssc_ds_idx', 'left') + ->where('b.bssc_lg_idx', $lgIdx) + ->where('b.bssc_code', $barcode) + ->where('b.bssc_state', 'sold') + ->get() + ->getRowArray(); + if (! is_array($scan)) { + return $this->response->setJSON(['ok' => false, 'message' => '없는 바코드이거나 반품 가능한 판매코드가 아닙니다.']); + } + + $dsIdx = (int) ($scan['bssc_ds_idx'] ?? 0); + + $soIdx = (int) ($scan['bssc_so_idx'] ?? 0); + $bagCode = (string) ($scan['bssc_bag_code'] ?? ''); + $unitPrice = 0.0; + if ($soIdx > 0 && $bagCode !== '') { + $sale = model(BagSaleModel::class) + ->where('bs_lg_idx', $lgIdx) + ->where('bs_so_idx', $soIdx) + ->where('bs_ds_idx', $dsIdx) + ->where('bs_bag_code', $bagCode) + ->where('bs_type', 'sale') + ->orderBy('bs_idx', 'DESC') + ->first(); + $unitPrice = (float) ($sale->bs_unit_price ?? 0); + } + + $qty = max(0, (int) ($scan['bssc_qty'] ?? 0)); + $addr = trim((string) ($scan['ds_addr'] ?? '') . ' ' . (string) ($scan['ds_addr_detail'] ?? '')); + + return $this->response->setJSON([ + 'ok' => true, + 'code' => (string) ($scan['bssc_code'] ?? ''), + 'bag_code' => $bagCode, + 'bag_name' => (string) ($scan['bssc_bag_name'] ?? ''), + 'qty' => $qty, + 'unit' => (string) ($scan['bssc_unit'] ?? ''), + 'unit_price' => $unitPrice, + 'amount' => $unitPrice * $qty, + 'so_idx' => $soIdx, + 'ds_idx' => $dsIdx, + 'ds_shop_no' => (string) ($scan['ds_shop_no'] ?? ''), + 'ds_name' => (string) ($scan['ds_name'] ?? ''), + 'ds_rep_name' => (string) ($scan['ds_rep_name'] ?? ''), + 'ds_tel' => (string) ($scan['ds_tel'] ?? ''), + 'ds_addr' => $addr, + ]); + } + + /** + * 지정판매소 반품 저장. + */ + public function designatedShopSaleReturnSave(): RedirectResponse + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->back()->with('error', '지자체를 선택해 주세요.'); + } + + $dsIdx = (int) ($this->request->getPost('ds_idx') ?? 0); + $scansJson = (string) ($this->request->getPost('scans_json') ?? '[]'); + $scans = json_decode($scansJson, true); + if ($dsIdx <= 0 || ! is_array($scans) || $scans === []) { + return redirect()->back()->with('error', '반품할 데이터가 없습니다.'); + } + + $this->ensureDesignatedSaleScanCodeTable($lgIdx); + $this->ensureDesignatedReturnScanCodeTable($lgIdx); + $db = \Config\Database::connect(); + $returnDate = date('Y-m-d'); + $codes = []; + $scanMetaByCode = []; + foreach ($scans as $row) { + if (! is_array($row)) { + continue; + } + $code = trim((string) ($row['code'] ?? '')); + if ($code !== '') { + $codes[] = $code; + $scanMetaByCode[$code] = [ + 'unit' => trim((string) ($row['unit'] ?? '')), + 'unit_price' => (float) ($row['unit_price'] ?? 0), + ]; + } + } + $codes = array_values(array_unique($codes)); + if ($codes === []) { + return redirect()->back()->with('error', '반품할 봉투 코드를 확인해 주세요.'); + } + + $scanRows = $db->table('bag_sale_scan_code') + ->where('bssc_lg_idx', $lgIdx) + ->where('bssc_ds_idx', $dsIdx) + ->where('bssc_state', 'sold') + ->whereIn('bssc_code', $codes) + ->get() + ->getResultArray(); + if ($scanRows === []) { + return redirect()->back()->with('error', '반품 가능한 판매 코드가 없습니다.'); + } + + $db->transStart(); + + $agg = []; + $affectedOrderIds = []; + $priceCache = []; + foreach ($scanRows as $row) { + $code = (string) ($row['bssc_code'] ?? ''); + $soIdx = (int) ($row['bssc_so_idx'] ?? 0); + $bagCode = (string) ($row['bssc_bag_code'] ?? ''); + $bagName = (string) ($row['bssc_bag_name'] ?? ''); + $unit = (string) ($row['bssc_unit'] ?? ''); + $qty = max(0, (int) ($row['bssc_qty'] ?? 0)); + if ($code === '' || $bagCode === '' || $qty <= 0) { + continue; + } + if ($soIdx > 0) { + $affectedOrderIds[$soIdx] = true; + } + + $db->table('bag_sale_scan_code') + ->where('bssc_lg_idx', $lgIdx) + ->where('bssc_code', $code) + ->update(['bssc_state' => 'in_stock']); + + $this->restoreReceivingPackStateByCode($lgIdx, $code); + + $priceKey = $soIdx . '|' . $bagCode; + if (! array_key_exists($priceKey, $priceCache)) { + $latestSale = model(BagSaleModel::class) + ->where('bs_lg_idx', $lgIdx) + ->where('bs_so_idx', $soIdx) + ->where('bs_ds_idx', $dsIdx) + ->where('bs_bag_code', $bagCode) + ->where('bs_type', 'sale') + ->orderBy('bs_idx', 'DESC') + ->first(); + $priceCache[$priceKey] = (float) ($latestSale->bs_unit_price ?? 0); + } + $unitPrice = (float) ($scanMetaByCode[$code]['unit_price'] ?? $priceCache[$priceKey] ?? 0); + $unit = (string) ($scanMetaByCode[$code]['unit'] ?? $unit); + $db->table('bag_return_scan_code')->insert([ + 'brsc_lg_idx' => $lgIdx, + 'brsc_so_idx' => $soIdx, + 'brsc_ds_idx' => $dsIdx, + 'brsc_bag_code' => $bagCode, + 'brsc_bag_name' => $bagName, + 'brsc_code' => $code, + 'brsc_unit' => $unit, + 'brsc_qty' => $qty, + 'brsc_unit_price' => $unitPrice, + 'brsc_amount' => $unitPrice * $qty, + 'brsc_return_date' => $returnDate, + 'brsc_state' => 'returned', + 'brsc_regdate' => date('Y-m-d H:i:s'), + ]); + + $k = $soIdx . '|' . $bagCode; + if (! isset($agg[$k])) { + $agg[$k] = [ + 'so_idx' => $soIdx, + 'bag_code' => $bagCode, + 'bag_name' => $bagName, + 'qty' => 0, + ]; + } + $agg[$k]['qty'] += $qty; + } + + foreach ($agg as $entry) { + $soIdx = (int) ($entry['so_idx'] ?? 0); + $bagCode = (string) ($entry['bag_code'] ?? ''); + $bagName = (string) ($entry['bag_name'] ?? ''); + $qty = max(0, (int) ($entry['qty'] ?? 0)); + if ($qty <= 0 || $bagCode === '') { + continue; + } + + $sale = model(BagSaleModel::class) + ->where('bs_lg_idx', $lgIdx) + ->where('bs_so_idx', $soIdx) + ->where('bs_ds_idx', $dsIdx) + ->where('bs_bag_code', $bagCode) + ->where('bs_type', 'sale') + ->orderBy('bs_idx', 'DESC') + ->first(); + $unitPrice = (float) ($sale->bs_unit_price ?? 0); + + model(BagSaleModel::class)->insert([ + 'bs_lg_idx' => $lgIdx, + 'bs_so_idx' => $soIdx, + 'bs_ds_idx' => $dsIdx, + 'bs_ds_name' => (string) ($sale->bs_ds_name ?? ''), + 'bs_sale_date' => date('Y-m-d'), + 'bs_bag_code' => $bagCode, + 'bs_bag_name' => $bagName, + 'bs_qty' => -$qty, + 'bs_unit_price' => $unitPrice, + 'bs_amount' => $unitPrice * $qty, + 'bs_type' => 'return', + 'bs_regdate' => date('Y-m-d H:i:s'), + ]); + model(BagInventoryModel::class)->adjustQty($lgIdx, (string) $bagCode, $bagName, $qty); + } + + $this->recalculateOrderReceivedStatus($lgIdx, array_keys($affectedOrderIds)); + + $db->transComplete(); + if (! $db->transStatus()) { + return redirect()->back()->with('error', '반품 저장 중 오류가 발생했습니다.'); + } + + return redirect()->to(site_url('bag/sale/designated-return'))->with('success', '반품 저장이 완료되었습니다.'); + } + + /** + * 지정판매소 반품 취소 화면. + */ + public function designatedShopSaleReturnCancelCreate(): string|RedirectResponse + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->to(site_url('bag/sales'))->with('error', '지자체를 선택해 주세요.'); + } + + $returnDate = trim((string) ($this->request->getGet('return_date') ?? date('Y-m-d'))); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $returnDate)) { + $returnDate = date('Y-m-d'); + } + + $this->ensureDesignatedReturnScanCodeTable($lgIdx); + $db = \Config\Database::connect(); + + $shopRows = $db->table('bag_return_scan_code r') + ->select('r.brsc_ds_idx, d.ds_name, d.ds_rep_name, r.brsc_return_date') + ->join('designated_shop d', 'd.ds_idx = r.brsc_ds_idx', 'left') + ->where('r.brsc_lg_idx', $lgIdx) + ->where('r.brsc_return_date', $returnDate) + ->where('r.brsc_state', 'returned') + ->groupBy('r.brsc_ds_idx, d.ds_name, d.ds_rep_name, r.brsc_return_date') + ->orderBy('d.ds_name', 'ASC') + ->get() + ->getResultArray(); + + $returnRows = $db->table('bag_return_scan_code') + ->select('brsc_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') + ->where('brsc_lg_idx', $lgIdx) + ->where('brsc_return_date', $returnDate) + ->where('brsc_state', 'returned') + ->orderBy('brsc_ds_idx', 'ASC') + ->orderBy('brsc_bag_code', 'ASC') + ->orderBy('brsc_code', 'ASC') + ->get() + ->getResultArray(); + + $payload = []; + foreach ($returnRows as $row) { + $payload[] = [ + 'so_idx' => (int) ($row['brsc_so_idx'] ?? 0), + 'ds_idx' => (int) ($row['brsc_ds_idx'] ?? 0), + 'bag_code' => (string) ($row['brsc_bag_code'] ?? ''), + 'bag_name' => (string) ($row['brsc_bag_name'] ?? ''), + 'code' => (string) ($row['brsc_code'] ?? ''), + 'unit' => (string) ($row['brsc_unit'] ?? ''), + 'qty' => (int) ($row['brsc_qty'] ?? 0), + 'unit_price' => (float) ($row['brsc_unit_price'] ?? 0), + 'amount' => (float) ($row['brsc_amount'] ?? 0), + 'return_date' => (string) ($row['brsc_return_date'] ?? ''), + ]; + } + + return $this->render('지정판매소 반품 취소', 'bag/designated_shop_return_cancel', [ + 'returnDate' => $returnDate, + 'shops' => $shopRows, + 'returns' => $payload, + ]); + } + + /** + * 지정판매소 반품 취소 저장. + */ + public function designatedShopSaleReturnCancelSave(): RedirectResponse + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->back()->with('error', '지자체를 선택해 주세요.'); + } + + $returnDate = trim((string) ($this->request->getPost('return_date') ?? '')); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $returnDate)) { + return redirect()->back()->with('error', '반품 일자를 확인해 주세요.'); + } + + $selectedJson = (string) ($this->request->getPost('selected_codes_json') ?? '[]'); + $selectedCodes = json_decode($selectedJson, true); + if (! is_array($selectedCodes) || $selectedCodes === []) { + return redirect()->back()->with('error', '반품 취소할 봉투 코드를 선택해 주세요.'); + } + $selectedCodes = array_values(array_unique(array_map(static fn ($v): string => trim((string) $v), $selectedCodes))); + $selectedCodes = array_values(array_filter($selectedCodes, static fn ($v): bool => $v !== '')); + if ($selectedCodes === []) { + return redirect()->back()->with('error', '반품 취소할 봉투 코드를 선택해 주세요.'); + } + + $this->ensureDesignatedSaleScanCodeTable($lgIdx); + $this->ensureDesignatedReturnScanCodeTable($lgIdx); + $db = \Config\Database::connect(); + $rows = $db->table('bag_return_scan_code') + ->where('brsc_lg_idx', $lgIdx) + ->where('brsc_return_date', $returnDate) + ->where('brsc_state', 'returned') + ->whereIn('brsc_code', $selectedCodes) + ->get() + ->getResultArray(); + if ($rows === []) { + return redirect()->back()->with('error', '반품 취소 가능한 봉투 코드가 없습니다.'); + } + + $db->transStart(); + $agg = []; + $affectedOrderIds = []; + foreach ($rows as $row) { + $code = (string) ($row['brsc_code'] ?? ''); + $soIdx = (int) ($row['brsc_so_idx'] ?? 0); + $dsIdx = (int) ($row['brsc_ds_idx'] ?? 0); + $bagCode = (string) ($row['brsc_bag_code'] ?? ''); + $bagName = (string) ($row['brsc_bag_name'] ?? ''); + $unit = (string) ($row['brsc_unit'] ?? ''); + $qty = max(0, (int) ($row['brsc_qty'] ?? 0)); + $unitPrice = (float) ($row['brsc_unit_price'] ?? 0); + if ($code === '' || $bagCode === '' || $qty <= 0) { + continue; + } + if ($soIdx > 0) { + $affectedOrderIds[$soIdx] = true; + } + + $db->table('bag_return_scan_code') + ->where('brsc_idx', (int) ($row['brsc_idx'] ?? 0)) + ->set(['brsc_state' => 'cancelled']) + ->update(); + + $db->table('bag_sale_scan_code') + ->where('bssc_lg_idx', $lgIdx) + ->where('bssc_code', $code) + ->set(['bssc_state' => 'sold']) + ->update(); + + if ($unit !== '낱장') { + $db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->groupStart() + ->where('brpc_pack_code', $code) + ->orWhere('brpc_box_code', $code) + ->groupEnd() + ->set(['brpc_state' => 'sold']) + ->update(); + } + + $k = $soIdx . '|' . $dsIdx . '|' . $bagCode; + if (! isset($agg[$k])) { + $agg[$k] = [ + 'so_idx' => $soIdx, + 'ds_idx' => $dsIdx, + 'bag_code' => $bagCode, + 'bag_name' => $bagName, + 'qty' => 0, + 'unit_price' => $unitPrice, + ]; + } + $agg[$k]['qty'] += $qty; + if ($agg[$k]['unit_price'] <= 0 && $unitPrice > 0) { + $agg[$k]['unit_price'] = $unitPrice; + } + } + + foreach ($agg as $entry) { + $soIdx = (int) ($entry['so_idx'] ?? 0); + $dsIdx = (int) ($entry['ds_idx'] ?? 0); + $bagCode = (string) ($entry['bag_code'] ?? ''); + $bagName = (string) ($entry['bag_name'] ?? ''); + $qty = max(0, (int) ($entry['qty'] ?? 0)); + if ($qty <= 0 || $bagCode === '') { + continue; + } + + $order = model(ShopOrderModel::class) + ->where('so_lg_idx', $lgIdx) + ->where('so_idx', $soIdx) + ->first(); + $unitPrice = (float) ($entry['unit_price'] ?? 0); + if ($unitPrice <= 0) { + $sale = model(BagSaleModel::class) + ->where('bs_lg_idx', $lgIdx) + ->where('bs_so_idx', $soIdx) + ->where('bs_ds_idx', $dsIdx) + ->where('bs_bag_code', $bagCode) + ->where('bs_type', 'sale') + ->orderBy('bs_idx', 'DESC') + ->first(); + $unitPrice = (float) ($sale->bs_unit_price ?? 0); + } + + model(BagSaleModel::class)->insert([ + 'bs_lg_idx' => $lgIdx, + 'bs_so_idx' => $soIdx, + 'bs_ds_idx' => $dsIdx, + 'bs_ds_name' => (string) ($order->so_ds_name ?? ''), + 'bs_sale_date' => date('Y-m-d'), + 'bs_bag_code' => $bagCode, + 'bs_bag_name' => $bagName, + 'bs_qty' => $qty, + 'bs_unit_price' => $unitPrice, + 'bs_amount' => $unitPrice * $qty, + 'bs_type' => 'return_cancel', + 'bs_regdate' => date('Y-m-d H:i:s'), + ]); + model(BagInventoryModel::class)->adjustQty($lgIdx, (string) $bagCode, $bagName, -$qty); + } + + $this->recalculateOrderReceivedStatus($lgIdx, array_keys($affectedOrderIds)); + + $db->transComplete(); + if (! $db->transStatus()) { + return redirect()->back()->with('error', '반품 취소 저장 중 오류가 발생했습니다.'); + } + + return redirect()->to(site_url('bag/sale/designated-return-cancel?return_date=' . rawurlencode($returnDate))) + ->with('success', '반품 취소 저장이 완료되었습니다.'); + } + + /** + * 지정판매소 판매 취소 화면. + */ + public function designatedShopReturnCreate(): string|RedirectResponse + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->to(site_url('bag/sales'))->with('error', '지자체를 선택해 주세요.'); + } + + $saleDate = trim((string) ($this->request->getGet('sale_date') ?? date('Y-m-d'))); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $saleDate)) { + $saleDate = date('Y-m-d'); + } + + $this->ensureDesignatedSaleScanCodeTable($lgIdx); + $db = \Config\Database::connect(); + + $shopRows = $db->table('bag_sale_scan_code b') + ->select('b.bssc_ds_idx, d.ds_name') + ->join('designated_shop d', 'd.ds_idx = b.bssc_ds_idx', 'left') + ->where('b.bssc_lg_idx', $lgIdx) + ->where('b.bssc_state', 'sold') + ->where('DATE(b.bssc_regdate)', $saleDate) + ->groupBy('b.bssc_ds_idx, d.ds_name') + ->orderBy('d.ds_name', 'ASC') + ->get() + ->getResultArray(); + + $scanRows = $db->table('bag_sale_scan_code b') + ->select('bssc_idx, bssc_so_idx, bssc_ds_idx, bssc_bag_code, bssc_bag_name, bssc_code, bssc_unit, bssc_qty, bssc_regdate') + ->where('bssc_lg_idx', $lgIdx) + ->where('bssc_state', 'sold') + ->where('DATE(bssc_regdate)', $saleDate) + ->orderBy('bssc_ds_idx', 'ASC') + ->orderBy('bssc_bag_code', 'ASC') + ->orderBy('bssc_code', 'ASC') + ->get() + ->getResultArray(); + + $saleRows = model(BagSaleModel::class) + ->select('bs_so_idx, bs_ds_idx, bs_bag_code, bs_unit_price') + ->where('bs_lg_idx', $lgIdx) + ->where('bs_type', 'sale') + ->where('bs_sale_date', $saleDate) + ->findAll(); + $unitPriceMap = []; + foreach ($saleRows as $row) { + $k = (int) ($row->bs_so_idx ?? 0) . '|' . (int) ($row->bs_ds_idx ?? 0) . '|' . (string) ($row->bs_bag_code ?? ''); + if (! isset($unitPriceMap[$k])) { + $unitPriceMap[$k] = (float) ($row->bs_unit_price ?? 0); + } + } + + $salePayload = []; + foreach ($scanRows as $row) { + $soIdx = (int) ($row['bssc_so_idx'] ?? 0); + $dsIdx = (int) ($row['bssc_ds_idx'] ?? 0); + $bagCode = (string) ($row['bssc_bag_code'] ?? ''); + $priceKey = $soIdx . '|' . $dsIdx . '|' . $bagCode; + $unitPrice = (float) ($unitPriceMap[$priceKey] ?? 0); + $qty = (int) ($row['bssc_qty'] ?? 0); + $salePayload[] = [ + 'bssc_idx' => (int) ($row['bssc_idx'] ?? 0), + 'so_idx' => $soIdx, + 'ds_idx' => $dsIdx, + 'bag_code' => $bagCode, + 'bag_name' => (string) ($row['bssc_bag_name'] ?? ''), + 'code' => (string) ($row['bssc_code'] ?? ''), + 'unit' => (string) ($row['bssc_unit'] ?? ''), + 'qty' => $qty, + 'unit_price' => $unitPrice, + 'amount' => $unitPrice * $qty, + 'regdate' => (string) ($row['bssc_regdate'] ?? ''), + ]; + } + + $devSoldScans = $db->table('bag_sale_scan_code b') + ->select('b.bssc_ds_idx, d.ds_name, b.bssc_so_idx, b.bssc_bag_code, b.bssc_bag_name, b.bssc_code, b.bssc_unit, b.bssc_qty, b.bssc_state, b.bssc_regdate') + ->join('designated_shop d', 'd.ds_idx = b.bssc_ds_idx', 'left') + ->where('b.bssc_lg_idx', $lgIdx) + ->where('b.bssc_state', 'sold') + ->where('DATE(b.bssc_regdate)', $saleDate) + ->orderBy('d.ds_name', 'ASC') + ->orderBy('b.bssc_code', 'ASC') + ->limit(500) + ->get() + ->getResultArray(); + + return $this->render('지정판매소 판매 취소', 'bag/designated_shop_return', [ + 'saleDate' => $saleDate, + 'shops' => $shopRows, + 'sales' => $salePayload, + 'canCancel' => ($saleDate === date('Y-m-d')), + 'devSoldScans' => $devSoldScans, + ]); + } + + /** + * 지정판매소 판매 취소 처리. + */ + public function designatedShopReturnCancel(): RedirectResponse + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->back()->with('error', '지자체를 선택해 주세요.'); + } + + $saleDate = trim((string) ($this->request->getPost('sale_date') ?? '')); + if ($saleDate !== date('Y-m-d')) { + return redirect()->back()->with('error', '과거 판매일자는 취소 처리할 수 없습니다.'); + } + + $selectedJson = (string) ($this->request->getPost('selected_codes_json') ?? '[]'); + $selectedCodes = json_decode($selectedJson, true); + if (! is_array($selectedCodes) || $selectedCodes === []) { + return redirect()->back()->with('error', '취소할 봉투 코드를 선택해 주세요.'); + } + + $selectedCodes = array_values(array_unique(array_map(static fn ($v): string => trim((string) $v), $selectedCodes))); + $selectedCodes = array_values(array_filter($selectedCodes, static fn ($v): bool => $v !== '')); + if ($selectedCodes === []) { + return redirect()->back()->with('error', '취소할 봉투 코드를 선택해 주세요.'); + } + + $this->ensureDesignatedSaleScanCodeTable($lgIdx); + $db = \Config\Database::connect(); + $rows = $db->table('bag_sale_scan_code') + ->where('bssc_lg_idx', $lgIdx) + ->where('bssc_state', 'sold') + ->where('DATE(bssc_regdate)', $saleDate) + ->whereIn('bssc_code', $selectedCodes) + ->get() + ->getResultArray(); + if ($rows === []) { + return redirect()->back()->with('error', '취소 가능한 판매 봉투 코드가 없습니다.'); + } + + $db->transStart(); + + $agg = []; + $affectedOrderIds = []; + foreach ($rows as $row) { + $code = (string) ($row['bssc_code'] ?? ''); + $bagCode = (string) ($row['bssc_bag_code'] ?? ''); + $bagName = (string) ($row['bssc_bag_name'] ?? ''); + $soIdx = (int) ($row['bssc_so_idx'] ?? 0); + $dsIdx = (int) ($row['bssc_ds_idx'] ?? 0); + $qty = max(0, (int) ($row['bssc_qty'] ?? 0)); + $affectedOrderIds[$soIdx] = true; + + $db->table('bag_sale_scan_code') + ->where('bssc_lg_idx', $lgIdx) + ->where('bssc_code', $code) + ->set(['bssc_state' => 'in_stock']) + ->update(); + + // 취소된 코드가 다시 판매 가능하도록 수신 팩 상태도 in_stock으로 복원한다. + // - 팩코드 취소: 해당 팩 복원 + // - 박스코드 취소: 해당 박스 전체 팩 복원 + // - 낱장코드 취소: 해당 낱장이 속한 팩 복원 + $this->restoreReceivingPackStateByCode($lgIdx, $code); + + $k = $soIdx . '|' . $dsIdx . '|' . $bagCode; + if (! isset($agg[$k])) { + $agg[$k] = [ + 'so_idx' => $soIdx, + 'ds_idx' => $dsIdx, + 'bag_code' => $bagCode, + 'bag_name' => $bagName, + 'qty' => 0, + ]; + } + $agg[$k]['qty'] += $qty; + } + + $orders = model(ShopOrderModel::class) + ->where('so_lg_idx', $lgIdx) + ->whereIn('so_idx', array_keys($affectedOrderIds)) + ->findAll(); + $orderMap = []; + foreach ($orders as $o) { + $orderMap[(int) ($o->so_idx ?? 0)] = $o; + } + + foreach ($agg as $entry) { + $soIdx = (int) ($entry['so_idx'] ?? 0); + $dsIdx = (int) ($entry['ds_idx'] ?? 0); + $bagCode = (string) ($entry['bag_code'] ?? ''); + $bagName = (string) ($entry['bag_name'] ?? ''); + $qty = max(0, (int) ($entry['qty'] ?? 0)); + if ($qty <= 0) { + continue; + } + + $sale = model(BagSaleModel::class) + ->where('bs_lg_idx', $lgIdx) + ->where('bs_so_idx', $soIdx) + ->where('bs_ds_idx', $dsIdx) + ->where('bs_bag_code', $bagCode) + ->where('bs_type', 'sale') + ->orderBy('bs_idx', 'DESC') + ->first(); + $unitPrice = (float) ($sale->bs_unit_price ?? 0); + + model(BagSaleModel::class)->insert([ + 'bs_lg_idx' => $lgIdx, + 'bs_so_idx' => $soIdx, + 'bs_ds_idx' => $dsIdx, + 'bs_ds_name' => (string) (($orderMap[$soIdx]->so_ds_name ?? '') ?: ''), + 'bs_sale_date' => $saleDate, + 'bs_bag_code' => $bagCode, + 'bs_bag_name' => $bagName, + 'bs_qty' => -$qty, + 'bs_unit_price' => $unitPrice, + 'bs_amount' => $unitPrice * $qty, + 'bs_type' => 'cancel', + 'bs_regdate' => date('Y-m-d H:i:s'), + ]); + model(BagInventoryModel::class)->adjustQty($lgIdx, (string) $bagCode, $bagName, $qty); + } + + foreach (array_keys($affectedOrderIds) as $soIdx) { + $remainSold = $db->table('bag_sale_scan_code') + ->where('bssc_lg_idx', $lgIdx) + ->where('bssc_so_idx', (int) $soIdx) + ->where('bssc_state', 'sold') + ->countAllResults(); + if ($remainSold <= 0) { + model(ShopOrderModel::class)->update((int) $soIdx, ['so_received' => 0]); + } + } + + $db->transComplete(); + if (! $db->transStatus()) { + return redirect()->back()->with('error', '판매 취소 처리 중 오류가 발생했습니다.'); + } + + return redirect()->to(site_url('bag/sale/designated-cancel?sale_date=' . rawurlencode($saleDate))) + ->with('success', '선택한 품목이 취소 처리되었습니다.'); + } + + /** + * 미배달(미수령) 전화 주문 목록 + 품목/판매 누계. + */ + private function phoneOpenOrdersWithItems(?int $lgIdx): array + { + if (! $lgIdx) { + return []; + } + $db = \Config\Database::connect(); + $orderModel = model(ShopOrderModel::class); + $builder = $orderModel + ->where('so_lg_idx', $lgIdx) + ->where('so_status', 'normal'); + if ($db->fieldExists('so_channel', 'shop_order')) { + $builder->where('so_channel', 'phone'); + } + $orders = $builder->orderBy('so_idx', 'DESC')->limit(200)->findAll(); + if ($orders === []) { + return []; + } + + $orderIds = array_values(array_map(static fn ($o): int => (int) ($o->so_idx ?? 0), $orders)); + $itemRows = model(ShopOrderItemModel::class) + ->whereIn('soi_so_idx', $orderIds) + ->orderBy('soi_so_idx', 'ASC') + ->orderBy('soi_idx', 'ASC') + ->findAll(); + + $soldRows = model(BagSaleModel::class) + ->select('bs_so_idx, bs_bag_code, COALESCE(SUM(bs_qty),0) AS sold_qty') + ->where('bs_lg_idx', $lgIdx) + ->whereIn('bs_so_idx', $orderIds) + // sale + cancel/return(bs_qty sign 반영) = 순판매량 + // (cancel/return은 통상 음수(bs_qty)로 저장되어 순판매량이 되도록 함) + ->whereIn('bs_type', ['sale', 'cancel', 'return', 'return_cancel']) + ->groupBy('bs_so_idx, bs_bag_code') + ->findAll(); + $soldMap = []; + foreach ($soldRows as $row) { + $k = (int) ($row->bs_so_idx ?? 0) . '|' . (string) ($row->bs_bag_code ?? ''); + $soldMap[$k] = (int) ($row->sold_qty ?? 0); + } + + $itemsByOrder = []; + foreach ($itemRows as $it) { + $orderId = (int) ($it->soi_so_idx ?? 0); + $code = (string) ($it->soi_bag_code ?? ''); + $sold = (int) ($soldMap[$orderId . '|' . $code] ?? 0); + $qty = (int) ($it->soi_qty ?? 0); + $amount = (float) ($it->soi_amount ?? 0); + $itemsByOrder[$orderId][] = [ + 'soi_idx' => (int) ($it->soi_idx ?? 0), + 'soi_bag_code' => $code, + 'soi_bag_name' => (string) ($it->soi_bag_name ?? ''), + 'soi_qty' => $qty, + 'soi_unit_price' => (float) ($it->soi_unit_price ?? 0), + 'soi_amount' => $amount, + 'sold_qty' => $sold, + 'remain_qty' => max(0, $qty - $sold), + ]; + } + + $payload = []; + foreach ($orders as $o) { + $id = (int) ($o->so_idx ?? 0); + $payload[] = [ + 'so_idx' => $id, + 'so_ds_idx' => (int) ($o->so_ds_idx ?? 0), + 'so_ds_name' => (string) ($o->so_ds_name ?? ''), + 'so_order_date' => (string) ($o->so_order_date ?? ''), + 'so_delivery_date' => (string) ($o->so_delivery_date ?? ''), + 'so_payment_type' => (string) ($o->so_payment_type ?? ''), + 'so_status' => (string) ($o->so_status ?? 'normal'), + 'so_received' => (int) ($o->so_received ?? 0), + 'items' => $itemsByOrder[$id] ?? [], + ]; + } + + return $payload; + } + + /** + * 바코드 해석(박스/팩/낱장). + */ + private function resolveDesignatedSaleBarcode(int $lgIdx, string $barcode): array + { + $barcode = trim($barcode); + if ($barcode === '') { + return ['ok' => false, 'message' => '바코드를 입력해 주세요.']; + } + + $db = \Config\Database::connect(); + $this->ensureDesignatedSaleScanCodeTable($lgIdx); + + $already = $db->table('bag_sale_scan_code') + ->where('bssc_lg_idx', $lgIdx) + ->where('bssc_code', $barcode) + ->where('bssc_state', 'sold') + ->countAllResults(); + if ($already > 0) { + return ['ok' => false, 'message' => '해당 봉투는 판매처리 된 봉투입니다. 다른 봉투를 등록하세요.']; + } + + $pack = $db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_pack_code', $barcode) + ->get() + ->getRow(); + if ($pack) { + if ((string) ($pack->brpc_state ?? '') !== 'in_stock') { + return ['ok' => false, 'message' => '해당 봉투는 판매처리 된 봉투입니다. 다른 봉투를 등록하세요.']; + } + return [ + 'ok' => true, + 'barcode' => $barcode, + 'unit' => '팩', + 'bag_code' => (string) ($pack->brpc_bag_code ?? ''), + 'bag_name' => (string) ($pack->brpc_bag_name ?? ''), + 'qty' => max(0, (int) ($pack->brpc_sheet_qty ?? 0)), + 'pack_ids' => [(int) ($pack->brpc_idx ?? 0)], + ]; + } + + $boxRows = $db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_box_code', $barcode) + ->get() + ->getResult(); + if ($boxRows !== []) { + $first = $boxRows[0]; + $inStock = array_values(array_filter($boxRows, static fn ($r): bool => (string) ($r->brpc_state ?? '') === 'in_stock')); + if ($inStock === []) { + return ['ok' => false, 'message' => '해당 봉투는 판매처리 된 봉투입니다. 다른 봉투를 등록하세요.']; + } + $qty = 0; + $packIds = []; + foreach ($inStock as $row) { + $qty += max(0, (int) ($row->brpc_sheet_qty ?? 0)); + $packIds[] = (int) ($row->brpc_idx ?? 0); + } + return [ + 'ok' => true, + 'barcode' => $barcode, + 'unit' => '박스', + 'bag_code' => (string) ($first->brpc_bag_code ?? ''), + 'bag_name' => (string) ($first->brpc_bag_name ?? ''), + 'qty' => $qty, + 'pack_ids' => $packIds, + ]; + } + + $sheetRows = $db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_sheet_start_code <=', $barcode) + ->where('brpc_sheet_end_code >=', $barcode) + ->limit(200) + ->get() + ->getResult(); + foreach ($sheetRows as $row) { + $start = (string) ($row->brpc_sheet_start_code ?? ''); + $end = (string) ($row->brpc_sheet_end_code ?? ''); + if (! $this->barcodeInRange($barcode, $start, $end)) { + continue; + } + if ((string) ($row->brpc_state ?? '') !== 'in_stock') { + $scan = $db->table('bag_sale_scan_code') + ->select('bssc_state') + ->where('bssc_lg_idx', $lgIdx) + ->where('bssc_code', $barcode) + ->get() + ->getRow(); + // 낱장 코드가 취소로 in_stock이면 수신 팩 상태를 자동 복구한다. + if ((string) ($scan->bssc_state ?? '') === 'in_stock') { + $db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_idx', (int) ($row->brpc_idx ?? 0)) + ->set(['brpc_state' => 'in_stock']) + ->update(); + } else { + return ['ok' => false, 'message' => '해당 봉투는 판매처리 된 봉투입니다. 다른 봉투를 등록하세요.']; + } + } + return [ + 'ok' => true, + 'barcode' => $barcode, + 'unit' => '낱장', + 'bag_code' => (string) ($row->brpc_bag_code ?? ''), + 'bag_name' => (string) ($row->brpc_bag_name ?? ''), + 'qty' => 1, + 'pack_ids' => [(int) ($row->brpc_idx ?? 0)], + ]; + } + + return ['ok' => false, 'message' => '등록되지 않은 바코드입니다.']; + } + + private function barcodeInRange(string $code, string $start, string $end): bool + { + if ($start === '' || $end === '') { + return false; + } + + $extract = static function (string $v): array { + if (preg_match('/^(.*?)(\d+)$/', $v, $m) === 1) { + return [(string) $m[1], (int) $m[2], strlen((string) $m[2])]; + } + return ['', -1, 0]; + }; + [$cp, $cn, $cl] = $extract($code); + [$sp, $sn, $sl] = $extract($start); + [$ep, $en, $el] = $extract($end); + if ($cn >= 0 && $sn >= 0 && $en >= 0 && $cp === $sp && $sp === $ep && $cl === $sl && $sl === $el) { + return $cn >= $sn && $cn <= $en; + } + + return strcmp($code, $start) >= 0 && strcmp($code, $end) <= 0; + } + + private function restoreReceivingPackStateByCode(int $lgIdx, string $code): void + { + $db = \Config\Database::connect(); + $builder = $db->table('bag_receiving_pack_code') + ->select('brpc_idx') + ->where('brpc_lg_idx', $lgIdx) + ->groupStart() + ->where('brpc_pack_code', $code) + ->orWhere('brpc_box_code', $code) + ->orGroupStart() + ->where('brpc_sheet_start_code <=', $code) + ->where('brpc_sheet_end_code >=', $code) + ->groupEnd() + ->groupEnd(); + + $rows = $builder->get()->getResultArray(); + if ($rows === [] && preg_match('/^(.*-P\\d+)-S\\d+$/', $code, $m) === 1) { + $rows = $db->table('bag_receiving_pack_code') + ->select('brpc_idx') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_pack_code', (string) $m[1]) + ->get() + ->getResultArray(); + } + if ($rows === []) { + return; + } + + $packIds = array_values(array_filter(array_map(static fn ($r): int => (int) ($r['brpc_idx'] ?? 0), $rows), static fn ($v): bool => $v > 0)); + if ($packIds === []) { + return; + } + + $db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->whereIn('brpc_idx', $packIds) + ->set(['brpc_state' => 'in_stock']) + ->update(); + } + + /** + * 지정 주문들의 수령완료 상태를 순판매량 기준으로 재계산한다. + * + * @param list $orderIds + */ + private function recalculateOrderReceivedStatus(int $lgIdx, array $orderIds): void + { + $orderIds = array_values(array_filter(array_map(static fn ($v): int => (int) $v, $orderIds), static fn ($v): bool => $v > 0)); + if ($orderIds === []) { + return; + } + + foreach ($orderIds as $soIdx) { + $itemRows = model(ShopOrderItemModel::class) + ->where('soi_so_idx', $soIdx) + ->findAll(); + if ($itemRows === []) { + continue; + } + + $soldRows = model(BagSaleModel::class) + ->select('bs_bag_code, COALESCE(SUM(bs_qty),0) AS sold_qty') + ->where('bs_lg_idx', $lgIdx) + ->where('bs_so_idx', $soIdx) + ->whereIn('bs_type', ['sale', 'cancel', 'return', 'return_cancel']) + ->groupBy('bs_bag_code') + ->findAll(); + $soldMap = []; + foreach ($soldRows as $row) { + $soldMap[(string) ($row->bs_bag_code ?? '')] = (int) ($row->sold_qty ?? 0); + } + + $allSold = true; + foreach ($itemRows as $it) { + $code = (string) ($it->soi_bag_code ?? ''); + $need = (int) ($it->soi_qty ?? 0); + $sold = (int) ($soldMap[$code] ?? 0); + if ($sold < $need) { + $allSold = false; + break; + } + } + + model(ShopOrderModel::class)->update($soIdx, ['so_received' => $allSold ? 1 : 0]); + } + } + + private function ensureDesignatedSaleScanCodeTable(int $lgIdx): void + { + $db = \Config\Database::connect(); + if ($db->tableExists('bag_sale_scan_code')) { + return; + } + $sql = <<query($sql); + } + + private function ensureDesignatedReturnScanCodeTable(int $lgIdx): void + { + $db = \Config\Database::connect(); + if ($db->tableExists('bag_return_scan_code')) { + return; + } + $sql = <<query($sql); + } + // --- 주문 접수 --- public function shopOrderCreate(): string { @@ -4957,14 +6887,278 @@ SQL); return $this->render('주문 접수', 'bag/create_shop_order', compact('shops', 'bagCodes')); } + /** + * 전화 주문 접수 전용 화면. + */ + public function phoneOrderCreate(): string + { + helper('admin'); + $lgIdx = $this->lgIdx(); + $shops = $lgIdx ? model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll() : []; + + $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); + $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; + $priceMap = model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx); + + $unitRows = model(PackagingUnitModel::class) + ->where('pu_lg_idx', $lgIdx) + ->where('pu_state', 1) + ->findAll(); + $unitMap = []; + foreach ($unitRows as $unit) { + $code = (string) ($unit->pu_bag_code ?? ''); + if ($code === '' || isset($unitMap[$code])) { + continue; + } + $unitMap[$code] = $unit; + } + + $receiptNo = 1; + if ($lgIdx) { + $receiptNo = (int) model(ShopOrderModel::class)->where('so_lg_idx', $lgIdx)->countAllResults() + 1; + } + + return $this->render('전화 주문 접수', 'bag/order_phone', compact('shops', 'bagCodes', 'priceMap', 'unitMap', 'receiptNo')); + } + + /** + * 전화 주문 접수 관리 화면(리스트/상세 수정/취소). + */ + public function phoneOrderManage(): string + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->to(site_url('bag/sales'))->with('error', '지자체를 선택해 주세요.'); + } + + $db = \Config\Database::connect(); + $orderModel = model(ShopOrderModel::class); + $builder = $orderModel->where('so_lg_idx', $lgIdx); + if ($db->fieldExists('so_channel', 'shop_order')) { + $builder->where('so_channel', 'phone'); + } + $orders = $builder->orderBy('so_idx', 'DESC')->limit(200)->findAll(); + + $orderIds = array_values(array_map(static fn ($o): int => (int) ($o->so_idx ?? 0), $orders)); + $itemRows = []; + if ($orderIds !== []) { + $itemRows = model(ShopOrderItemModel::class) + ->whereIn('soi_so_idx', $orderIds) + ->orderBy('soi_so_idx', 'ASC') + ->orderBy('soi_idx', 'ASC') + ->findAll(); + } + + $unitRows = model(PackagingUnitModel::class) + ->where('pu_lg_idx', $lgIdx) + ->where('pu_state', 1) + ->findAll(); + $unitMap = []; + foreach ($unitRows as $u) { + $code = (string) ($u->pu_bag_code ?? ''); + if ($code === '' || isset($unitMap[$code])) { + continue; + } + $unitMap[$code] = [ + 'boxSheets' => (int) ($u->pu_total_per_box ?? 0), + 'packSheets' => (int) ($u->pu_pack_per_sheet ?? 0), + ]; + } + + $itemsByOrder = []; + foreach ($itemRows as $item) { + $orderId = (int) ($item->soi_so_idx ?? 0); + $code = (string) ($item->soi_bag_code ?? ''); + $pack = $unitMap[$code] ?? ['boxSheets' => 0, 'packSheets' => 0]; + $itemsByOrder[$orderId][] = [ + 'soi_idx' => (int) ($item->soi_idx ?? 0), + 'soi_bag_code' => $code, + 'soi_bag_name' => (string) ($item->soi_bag_name ?? ''), + 'soi_unit_price' => (int) ($item->soi_unit_price ?? 0), + 'soi_qty' => (int) ($item->soi_qty ?? 0), + 'soi_amount' => (int) ($item->soi_amount ?? 0), + 'soi_box_count' => (int) ($item->soi_box_count ?? 0), + 'soi_pack_count' => (int) ($item->soi_pack_count ?? 0), + 'soi_sheet_count' => (int) ($item->soi_sheet_count ?? 0), + 'box_sheets' => (int) ($pack['boxSheets'] ?? 0), + 'pack_sheets' => (int) ($pack['packSheets'] ?? 0), + ]; + } + + $payload = []; + foreach ($orders as $order) { + $id = (int) ($order->so_idx ?? 0); + $payload[] = [ + 'so_idx' => $id, + 'so_ds_name' => (string) ($order->so_ds_name ?? ''), + 'so_order_date' => (string) ($order->so_order_date ?? ''), + 'so_delivery_date' => (string) ($order->so_delivery_date ?? ''), + 'so_payment_type' => (string) ($order->so_payment_type ?? ''), + 'so_total_qty' => (int) ($order->so_total_qty ?? 0), + 'so_total_amount' => (int) ($order->so_total_amount ?? 0), + 'so_status' => (string) ($order->so_status ?? 'normal'), + 'items' => $itemsByOrder[$id] ?? [], + ]; + } + + return $this->render('전화접수 관리', 'bag/order_phone_manage', [ + 'orders' => $payload, + ]); + } + + /** + * 전화 주문 상세 품목 수량 수정 저장. + */ + public function phoneOrderUpdate() + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->to(site_url('bag/order/phone/manage'))->with('error', '지자체를 선택해 주세요.'); + } + + $soIdx = (int) $this->request->getPost('so_idx'); + if ($soIdx <= 0) { + return redirect()->back()->with('error', '주문번호가 올바르지 않습니다.'); + } + + $orderModel = model(ShopOrderModel::class); + $order = $orderModel->where('so_idx', $soIdx)->where('so_lg_idx', $lgIdx)->first(); + if (! $order) { + return redirect()->back()->with('error', '주문을 찾을 수 없습니다.'); + } + if ((string) ($order->so_status ?? '') === 'cancelled') { + return redirect()->back()->with('error', '취소된 주문은 수정할 수 없습니다.'); + } + + $itemModel = model(ShopOrderItemModel::class); + $items = $itemModel->where('soi_so_idx', $soIdx)->findAll(); + if ($items === []) { + return redirect()->back()->with('error', '주문 품목이 없습니다.'); + } + + $qtyInput = $this->request->getPost('item_qty') ?? []; + if (! is_array($qtyInput)) { + $qtyInput = []; + } + + $codeSet = []; + foreach ($items as $item) { + $code = (string) ($item->soi_bag_code ?? ''); + if ($code !== '') { + $codeSet[$code] = true; + } + } + + $unitRows = model(PackagingUnitModel::class) + ->where('pu_lg_idx', $lgIdx) + ->where('pu_state', 1) + ->whereIn('pu_bag_code', array_keys($codeSet)) + ->findAll(); + $unitMap = []; + foreach ($unitRows as $u) { + $code = (string) ($u->pu_bag_code ?? ''); + if ($code === '' || isset($unitMap[$code])) { + continue; + } + $unitMap[$code] = $u; + } + + $db = \Config\Database::connect(); + $db->transStart(); + + $sumQty = 0; + $sumAmt = 0; + foreach ($items as $item) { + $itemId = (int) ($item->soi_idx ?? 0); + $code = (string) ($item->soi_bag_code ?? ''); + $qty = isset($qtyInput[$itemId]) ? max(0, (int) $qtyInput[$itemId]) : (int) ($item->soi_qty ?? 0); + $unitPrice = (int) ($item->soi_unit_price ?? 0); + $amount = $qty * $unitPrice; + + $boxCount = 0; + $packCount = 0; + $sheetCount = $qty; + $unit = $unitMap[$code] ?? null; + if ($unit && (int) ($unit->pu_total_per_box ?? 0) > 0) { + $boxSheets = (int) $unit->pu_total_per_box; + $boxCount = intdiv($qty, $boxSheets); + $remain = $qty % $boxSheets; + $packSheets = (int) ($unit->pu_pack_per_sheet ?? 0); + if ($packSheets > 0) { + $packCount = intdiv($remain, $packSheets); + $sheetCount = $remain % $packSheets; + } else { + $sheetCount = $remain; + } + } elseif ($unit && (int) ($unit->pu_pack_per_sheet ?? 0) > 0) { + $packSheets = (int) $unit->pu_pack_per_sheet; + $packCount = intdiv($qty, $packSheets); + $sheetCount = $qty % $packSheets; + } + + $itemModel->update($itemId, [ + 'soi_qty' => $qty, + 'soi_amount' => $amount, + 'soi_box_count' => $boxCount, + 'soi_pack_count' => $packCount, + 'soi_sheet_count' => $sheetCount, + ]); + + $sumQty += $qty; + $sumAmt += $amount; + } + + $orderModel->update($soIdx, [ + 'so_total_qty' => $sumQty, + 'so_total_amount' => $sumAmt, + ]); + + $db->transComplete(); + + return redirect()->to(site_url('bag/order/phone/manage'))->with('success', '주문 수정 저장이 완료되었습니다.'); + } + + /** + * 전화 주문 취소(삭제가 아닌 상태값 변경). + */ + public function phoneOrderCancel(int $id) + { + helper('admin'); + $lgIdx = $this->lgIdx(); + if (! $lgIdx) { + return redirect()->to(site_url('bag/order/phone/manage'))->with('error', '지자체를 선택해 주세요.'); + } + + $orderModel = model(ShopOrderModel::class); + $order = $orderModel->where('so_idx', $id)->where('so_lg_idx', $lgIdx)->first(); + if (! $order) { + return redirect()->to(site_url('bag/order/phone/manage'))->with('error', '주문을 찾을 수 없습니다.'); + } + + $orderModel->update($id, ['so_status' => 'cancelled']); + + return redirect()->to(site_url('bag/order/phone/manage'))->with('success', '주문이 취소 처리되었습니다.'); + } + public function shopOrderStore() { $admin = new \App\Controllers\Admin\ShopOrder(); $admin->initController($this->request, $this->response, service('logger')); $result = $admin->store(); + + // 호출 화면에서 hidden 'return_to' 로 돌아갈 경로 지정 가능. 화이트리스트로만 허용. + $returnTo = trim((string) ($this->request->getPost('return_to') ?? '')); + $allowed = ['bag/sales', 'bag/order/phone', 'bag/shop-order/create']; + $target = in_array($returnTo, $allowed, true) ? $returnTo : 'bag/sales'; + if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) { - return redirect()->to(site_url('bag/sales'))->with('success', session()->getFlashdata('success'))->with('errors', session()->getFlashdata('errors')); + return redirect()->to(site_url($target)) + ->with('success', session()->getFlashdata('success')) + ->with('error', session()->getFlashdata('error')) + ->with('errors', session()->getFlashdata('errors')); } - return redirect()->to(site_url('bag/sales'))->with('success', '주문 접수되었습니다.'); + return redirect()->to(site_url($target))->with('success', '주문 접수되었습니다.'); } } diff --git a/app/Controllers/Home.php b/app/Controllers/Home.php index 89c7065..e01f1c7 100644 --- a/app/Controllers/Home.php +++ b/app/Controllers/Home.php @@ -9,7 +9,9 @@ class Home extends BaseController public function index() { if (session()->get('logged_in')) { - return $this->dashboard(); + // 메인(/) 본문은 「요약(simple)」 대시보드로 노출한다. + // 종래의 「종합·그래프(blend)」 본문은 /dashboard (또는 /dashboard/blend)로 이동. + return $this->dashboardSimple(); } return view('welcome_message'); @@ -28,6 +30,34 @@ class Home extends BaseController ]); } + /** + * 로그인 후 메인 — 단순형 요약 대시보드. URL: /dashboard/simple + * 기존 /dashboard 화면이 복잡하다는 피드백용으로, 핵심 지표·링크만 노출. + */ + public function dashboardSimple() + { + return view('bag/layout/main', [ + 'title' => '업무 현황 · 요약', + 'content' => view('bag/lg_dashboard_simple', [ + 'lgLabel' => $this->resolveLgLabel(), + ]), + ]); + } + + /** + * 로그인 후 메인 — 중간 밀도 대시보드. URL: /dashboard/compact + * /dashboard 보다 단순하지만 simple 보다 정보량을 늘린 화면. + */ + public function dashboardCompact() + { + return view('bag/layout/main', [ + 'title' => '업무 현황 · 컴팩트', + 'content' => view('bag/lg_dashboard_compact', [ + 'lgLabel' => $this->resolveLgLabel(), + ]), + ]); + } + /** * 디자인 시안(기존 /dashboard 연결 화면) */ @@ -74,6 +104,20 @@ class Home extends BaseController return $this->dashboard(); } + /** + * 로그인 후 메인 — 라이트(축약) 대시보드. URL: /dashboard/lite + * dashboard_blend 의 일부 KPI/표/차트만 남긴 단순화 화면. + */ + public function dashboardLite() + { + return view('bag/layout/main', [ + 'title' => '업무 현황 · 라이트', + 'content' => view('bag/dashboard_blend_lite_inner', [ + 'lgLabel' => $this->resolveLgLabel(), + ]), + ]); + } + /** * 재고 조회(수불) 화면 (목업) */ diff --git a/app/Helpers/admin_helper.php b/app/Helpers/admin_helper.php index 69ea503..71c95e1 100644 --- a/app/Helpers/admin_helper.php +++ b/app/Helpers/admin_helper.php @@ -264,13 +264,12 @@ if (! function_exists('normalize_menu_link_for_url')) { } } -if (! function_exists('mgmt_url')) { +if (! function_exists('mgmt_path')) { /** - * 업무 화면 링크: 정식 URL 은 /bag/* (adminAuth). 포장 단위 CRUD 는 packaging-units/manage 로 치환. + * 업무 화면 상대 경로 (페이저 setPath 등). 선행 슬래시 없음. */ - function mgmt_url(string $path): string + function mgmt_path(string $path): string { - helper('url'); $path = trim($path, '/'); // bag/packaging-units 는 조회 전용(Bag) — CRUD 는 /bag/packaging-units/manage/* 로 분리 if ($path === 'packaging-units') { @@ -279,7 +278,35 @@ if (! function_exists('mgmt_url')) { $path = 'packaging-units/manage/' . substr($path, strlen('packaging-units/')); } - return site_url('bag/' . $path); + return 'bag/' . $path; + } +} + +if (! function_exists('mgmt_url')) { + /** + * 업무 화면 링크: 정식 URL 은 /bag/* (adminAuth). 포장 단위 CRUD 는 packaging-units/manage 로 치환. + */ + function mgmt_url(string $path): string + { + helper('url'); + + return site_url(mgmt_path($path)); + } +} + +if (! function_exists('apply_pager_path')) { + /** + * CI4 페이저: setPath 는 상대 경로만 허용(전체 URL 시 baseURL 이중 결합). + * 검색 조건은 only() 로 유지합니다. + * + * @param \CodeIgniter\Pager\Pager $pager + */ + function apply_pager_path($pager, string $path, array $queryForPager = []): void + { + $pager->setPath($path); + if ($queryForPager !== []) { + $pager->only(array_keys($queryForPager)); + } } } @@ -367,6 +394,10 @@ if (! function_exists('menu_link_candidate_paths')) { $cands[] = 'admin/packaging-units' . ($m[1] ?? ''); } elseif (preg_match('#^admin/packaging-units(/.*)?$#', $p, $m)) { $cands[] = 'bag/packaging-units/manage' . ($m[1] ?? ''); + } elseif ($p === 'bag/inventory/inspection-select') { + // 실사 선별 조회 메뉴는 작업 화면(inspection-work)도 동일 메뉴로 활성 처리 + $cands[] = 'bag/inventory/inspection-work'; + $cands[] = 'bag/inventory/inspection'; } elseif (str_starts_with($p, 'admin/')) { $cands[] = 'bag/' . substr($p, strlen('admin/')); } elseif (str_starts_with($p, 'bag/')) { diff --git a/app/Helpers/export_helper.php b/app/Helpers/export_helper.php index 6a38908..bc4eddb 100644 --- a/app/Helpers/export_helper.php +++ b/app/Helpers/export_helper.php @@ -67,3 +67,441 @@ if (! function_exists('csv_encode_row')) { return implode(',', $escaped) . "\r\n"; } } + +if (! function_exists('export_excel_2003_xml')) { + /** + * Excel 2003 XML(SpreadsheetML)로 브라우저 다운로드 (.xls 확장자, 별도 라이브러리 불필요) + * + * @param string $filename 저장 파일명(확장자는 .xls로 정규화) + * @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자) + * @param string[] $headers 컬럼 헤더 + * @param array $rows 데이터 행(각 행은 배열, 값은 문자열로 출력) + */ + function export_excel_2003_xml(string $filename, string $sheetName, array $headers, array $rows): void + { + $filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls'; + + $safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName); + $safeSheet = function_exists('mb_substr') + ? mb_substr($safeSheet, 0, 31, 'UTF-8') + : substr($safeSheet, 0, 31); + + $esc = static function (mixed $v): string { + return htmlspecialchars((string) ($v ?? ''), ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + }; + + $parts = []; + $parts[] = ''; + $parts[] = ''; + $parts[] = ''; + $parts[] = ''; + $parts[] = ''; + + $parts[] = ''; + foreach ($headers as $h) { + $parts[] = '' . $esc($h) . ''; + } + $parts[] = ''; + + foreach ($rows as $row) { + $parts[] = ''; + foreach (array_values((array) $row) as $cell) { + $parts[] = '' . $esc($cell) . ''; + } + $parts[] = ''; + } + + $parts[] = '
'; + $parts[] = '
'; + $parts[] = '
'; + + $output = implode('', $parts); + + $response = service('response'); + $response->setHeader('Content-Type', 'application/vnd.ms-excel; charset=UTF-8'); + $response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); + $response->setHeader('Pragma', 'no-cache'); + $response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + $response->setBody($output); + $response->send(); + exit; + } +} + +if (! function_exists('export_excel_2003_xml_workbook')) { + /** + * Excel 2003 XML — 다중 시트, 인쇄 미리보기와 유사한 헤더·줄바꿈·열 너비 + * + * @param string $filename 저장 파일명 + * @param list, rows: list>, col_widths?: list}> $sheets + */ + function export_excel_2003_xml_workbook(string $filename, array $sheets): void + { + $filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls'; + + $esc = static function (mixed $v): string { + return htmlspecialchars((string) ($v ?? ''), ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + }; + + $safeSheetName = static function (string $name) use ($esc): string { + $safe = str_replace(['/', '\\', '?', '*', '[', ']', ':'], '', $name); + $safe = function_exists('mb_substr') ? mb_substr($safe, 0, 31, 'UTF-8') : substr($safe, 0, 31); + + return $esc($safe !== '' ? $safe : 'Sheet'); + }; + + $parts = []; + $parts[] = ''; + $parts[] = ''; + $parts[] = ''; + $parts[] = ''; + $parts[] = ''; + $parts[] = ''; + $parts[] = ''; + + foreach ($sheets as $sheet) { + $sheetName = $safeSheetName((string) ($sheet['name'] ?? 'Sheet')); + $headers = array_values((array) ($sheet['headers'] ?? [])); + $rows = (array) ($sheet['rows'] ?? []); + $colWidths = array_values((array) ($sheet['col_widths'] ?? [])); + + $parts[] = ''; + $parts[] = ''; + $parts[] = ''; + + $colCount = max(count($headers), 1); + for ($i = 0; $i < $colCount; $i++) { + $px = (int) ($colWidths[$i] ?? 72); + $width = max(48, min(280, $px)); + $excelW = round($width / 6.5, 1); + $parts[] = ''; + } + + $parts[] = ''; + foreach ($headers as $h) { + $parts[] = '' . $esc($h) . ''; + } + $parts[] = ''; + + foreach ($rows as $row) { + $parts[] = ''; + foreach (array_values((array) $row) as $cell) { + $parts[] = '' . $esc($cell) . ''; + } + $parts[] = ''; + } + + $parts[] = '
'; + $parts[] = '
'; + } + + $parts[] = '
'; + + $output = implode('', $parts); + + $response = service('response'); + $response->setHeader('Content-Type', 'application/vnd.ms-excel; charset=UTF-8'); + $response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); + $response->setHeader('Pragma', 'no-cache'); + $response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + $response->setBody($output); + $response->send(); + exit; + } +} + +if (! function_exists('bag_flow_report_build_spreadsheet')) { + /** + * 기간별 봉투 수불 엑셀 통합문서 생성 (PhpSpreadsheet — 열 너비·병합 안정) + * + * @param list> $reportRows + * @param list $metaLines + */ + function bag_flow_report_build_spreadsheet( + string $lgName, + string $title, + array $metaLines, + array $reportRows + ): \PhpOffice\PhpSpreadsheet\Spreadsheet { + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $spreadsheet->getDefaultStyle()->getFont()->setName('맑은 고딕')->setSize(10); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('수불현황'); + $bodyFontSize = 10; + + $lastCol = 'N'; + $colWidths = [ + 'A' => 22.0, + 'B' => 26.0, + 'C' => 12.0, + 'D' => 11.0, + 'E' => 11.0, + 'F' => 11.0, + 'G' => 12.0, + 'H' => 11.0, + 'I' => 12.0, + 'J' => 12.0, + 'K' => 12.0, + 'L' => 11.0, + 'M' => 12.0, + 'N' => 12.0, + ]; + foreach ($colWidths as $col => $width) { + $sheet->getColumnDimension($col)->setWidth($width); + $sheet->getColumnDimension($col)->setAutoSize(false); + } + + $r = 1; + if ($lgName !== '') { + $sheet->setCellValue("A{$r}", $lgName); + $sheet->getStyle("A{$r}")->getFont()->setSize(10)->getColor()->setRGB('666666'); + $r++; + } + + $sheet->setCellValue("A{$r}", $title); + $sheet->mergeCells("A{$r}:{$lastCol}{$r}"); + $sheet->getStyle("A{$r}")->getFont()->setBold(true)->setSize($bodyFontSize); + $r++; + + foreach ($metaLines as $line) { + $sheet->setCellValue("A{$r}", $line); + $sheet->mergeCells("A{$r}:{$lastCol}{$r}"); + $sheet->getStyle("A{$r}")->getFont()->setSize(10)->getColor()->setRGB('555555'); + $r++; + } + + $r++; + + $h1 = $r; + $h2 = $r + 1; + + $sheet->setCellValue("A{$h1}", '일자'); + $sheet->mergeCells("A{$h1}:A{$h2}"); + $sheet->setCellValue("B{$h1}", '품목'); + $sheet->mergeCells("B{$h1}:B{$h2}"); + $sheet->setCellValue("C{$h1}", '전일'); + $sheet->mergeCells("C{$h1}:C{$h2}"); + $sheet->setCellValue("D{$h1}", '입고'); + $sheet->mergeCells("D{$h1}:G{$h1}"); + $sheet->setCellValue("H{$h1}", '출고'); + $sheet->mergeCells("H{$h1}:M{$h1}"); + $sheet->setCellValue("N{$h1}", '잔량'); + $sheet->mergeCells("N{$h1}:N{$h2}"); + + $subHeaders = ['입고', '반품', '기타', '입계', '판매', '일반', '무료', '반품', '기타', '출계']; + foreach ($subHeaders as $i => $label) { + $col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(4 + $i); + $sheet->setCellValue("{$col}{$h2}", $label); + } + + $headerStyle = [ + 'font' => ['bold' => true, 'size' => $bodyFontSize], + 'alignment' => [ + 'horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER, + 'vertical' => \PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER, + 'wrapText' => false, + ], + 'fill' => [ + 'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, + 'startColor' => ['rgb' => 'E9ECEF'], + ], + 'borders' => [ + 'bottom' => ['borderStyle' => \PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN], + ], + ]; + $sheet->getStyle("A{$h1}:{$lastCol}{$h2}")->applyFromArray($headerStyle); + + $dataRow = $h2 + 1; + foreach ($reportRows as $row) { + $rowType = (string) ($row['row_type'] ?? 'data'); + if (! in_array($rowType, ['data', 'subtotal', 'grand'], true)) { + continue; + } + + $sheet->fromArray([ + (string) ($row['date'] ?? ''), + (string) ($row['item_name'] ?? ''), + (int) ($row['prev_stock'] ?? 0), + (int) ($row['recv_in'] ?? 0), + (int) ($row['recv_return'] ?? 0), + (int) ($row['recv_misc'] ?? 0), + (int) ($row['recv_total'] ?? 0), + (int) ($row['out_sale'] ?? 0), + (int) ($row['out_issue_gen'] ?? 0), + (int) ($row['out_issue_free'] ?? 0), + (int) ($row['out_return'] ?? 0), + (int) ($row['out_misc'] ?? 0), + (int) ($row['out_total'] ?? 0), + (int) ($row['balance'] ?? 0), + ], null, "A{$dataRow}", true); + + $sheet->getStyle("C{$dataRow}:{$lastCol}{$dataRow}") + ->getNumberFormat() + ->setFormatCode('#,##0'); + $sheet->getStyle("C{$dataRow}:{$lastCol}{$dataRow}") + ->getAlignment() + ->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_RIGHT); + $sheet->getStyle("A{$dataRow}:B{$dataRow}") + ->getAlignment() + ->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_LEFT) + ->setWrapText(false); + $sheet->getStyle("A{$dataRow}:{$lastCol}{$dataRow}") + ->getFont() + ->setSize($bodyFontSize); + + if (in_array($rowType, ['subtotal', 'grand'], true)) { + $sheet->getStyle("A{$dataRow}:{$lastCol}{$dataRow}")->applyFromArray([ + 'font' => ['bold' => true, 'size' => $bodyFontSize], + 'fill' => [ + 'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, + 'startColor' => ['rgb' => 'FFF8E1'], + ], + ]); + } + + $dataRow++; + } + + if ($dataRow > $h2 + 1) { + $sheet->getStyle('A' . ($h2 + 1) . ':' . $lastCol . ($dataRow - 1)) + ->getBorders() + ->getAllBorders() + ->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_HAIR); + } + + $sheet->getPageSetup()->setOrientation(\PhpOffice\PhpSpreadsheet\Worksheet\PageSetup::ORIENTATION_PORTRAIT); + $sheet->getPageSetup()->setPaperSize(\PhpOffice\PhpSpreadsheet\Worksheet\PageSetup::PAPERSIZE_A4); + $sheet->getPageSetup()->setFitToWidth(1); + $sheet->getPageSetup()->setFitToHeight(0); + + return $spreadsheet; + } +} + +if (! function_exists('export_bag_flow_report_excel')) { + /** + * 기간별 봉투 수불 (/bag/flow) — 인쇄와 동일한 헤더·2단 표 (xlsx, PhpSpreadsheet) + * + * @param list> $reportRows + * @param list $metaLines + */ + function export_bag_flow_report_excel( + string $filename, + string $lgName, + string $title, + array $metaLines, + array $reportRows + ): void { + $baseName = preg_replace('/\.[^.]+$/u', '', $filename); + $baseName = preg_replace('/[^\p{L}\p{N}_\-]+/u', '_', $baseName) ?? 'bag_flow'; + $baseName = trim($baseName, '_') !== '' ? trim($baseName, '_') : 'bag_flow'; + $filename = $baseName . '.xlsx'; + + $spreadsheet = bag_flow_report_build_spreadsheet($lgName, $title, $metaLines, $reportRows); + $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); + + ob_start(); + try { + $writer->save('php://output'); + } catch (\Throwable $e) { + ob_end_clean(); + $spreadsheet->disconnectWorksheets(); + unset($spreadsheet); + throw $e; + } + $spreadsheet->disconnectWorksheets(); + unset($spreadsheet); + $output = ob_get_clean(); + if ($output === false) { + $output = ''; + } + + $response = service('response'); + $response->setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $asciiName = preg_replace('/[^\x20-\x7E]+/', '_', $filename) ?? 'bag_flow.xlsx'; + $response->setHeader( + 'Content-Disposition', + 'attachment; filename="' . $asciiName . '"; filename*=UTF-8\'\'' . rawurlencode($filename) + ); + $response->setHeader('Pragma', 'no-cache'); + $response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + $response->setBody($output); + $response->send(); + exit; + } +} + +if (! function_exists('export_xlsx')) { + /** + * Office Open XML(.xlsx) 브라우저 다운로드 (PhpSpreadsheet) + * + * @param string $filename 저장 파일명(확장자는 .xlsx로 정규화) + * @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자) + * @param string[] $headers 컬럼 헤더 + * @param array $rows 데이터 행(각 행은 배열) + */ + function export_xlsx(string $filename, string $sheetName, array $headers, array $rows): void + { + $filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xlsx'; + + $safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName); + $safeSheet = function_exists('mb_substr') + ? mb_substr($safeSheet, 0, 31, 'UTF-8') + : substr($safeSheet, 0, 31); + if ($safeSheet === '') { + $safeSheet = 'Sheet1'; + } + + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle($safeSheet); + + $data = [array_map(static fn ($v): string => (string) ($v ?? ''), array_values($headers))]; + foreach ($rows as $row) { + $data[] = array_map(static fn ($v): string => (string) ($v ?? ''), array_values((array) $row)); + } + $sheet->fromArray($data, null, 'A1', true); + + $headerCount = max(1, count($headers)); + $rowCount = max(1, count($data)); + $lastCol = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($headerCount); + $fullRange = 'A1:' . $lastCol . $rowCount; + + // 헤더/데이터 모두 좌측 정렬(요구사항) + $sheet->getStyle($fullRange)->getAlignment()->setHorizontal( + \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_LEFT + ); + + // 가독성을 위해 기본 열 너비를 넓게 지정 + for ($i = 1; $i <= $headerCount; $i++) { + $col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i); + $sheet->getColumnDimension($col)->setWidth(22); + } + + $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); + ob_start(); + try { + $writer->save('php://output'); + } catch (\Throwable $e) { + ob_end_clean(); + $spreadsheet->disconnectWorksheets(); + unset($spreadsheet); + throw $e; + } + $spreadsheet->disconnectWorksheets(); + unset($spreadsheet); + $output = ob_get_clean(); + if ($output === false) { + $output = ''; + } + + $response = service('response'); + $response->setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); + $response->setHeader('Pragma', 'no-cache'); + $response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + $response->setBody($output); + $response->send(); + exit; + } +} diff --git a/app/Libraries/BagAnalyticsReportBuilder.php b/app/Libraries/BagAnalyticsReportBuilder.php new file mode 100644 index 0000000..41afcf7 --- /dev/null +++ b/app/Libraries/BagAnalyticsReportBuilder.php @@ -0,0 +1,659 @@ + */ + private array $bagNames = []; + + /** + * @return array, cross_year: bool}> + */ + public static function seasonCatalog(): array + { + return [ + 'spring' => ['label' => '봄', 'months_label' => '3~5월', 'months' => [3, 4, 5], 'cross_year' => false], + 'summer' => ['label' => '여름', 'months_label' => '6~8월', 'months' => [6, 7, 8], 'cross_year' => false], + 'autumn' => ['label' => '가을', 'months_label' => '9~11월', 'months' => [9, 10, 11], 'cross_year' => false], + 'winter' => ['label' => '겨울', 'months_label' => '전년12·1~2월', 'months' => [12, 1, 2], 'cross_year' => true], + ]; + } + + public static function normalizeSeason(string $season): string + { + $raw = trim($season); + $aliases = [ + '봄' => 'spring', + '여름' => 'summer', + '가을' => 'autumn', + '겨울' => 'winter', + ]; + $key = $aliases[$raw] ?? strtolower($raw); + $catalog = self::seasonCatalog(); + + return isset($catalog[$key]) ? $key : 'spring'; + } + + public function __construct(?\CodeIgniter\Database\BaseConnection $db = null) + { + $this->db = $db ?? \Config\Database::connect(); + } + + /** + * @return array{ + * gugunOptions: list, + * agencies: list, + * lgName: string, + * gugunLabel: string + * } + */ + public function loadFilterOptions(int $lgIdx): array + { + $lgRow = model(\App\Models\LocalGovernmentModel::class)->find($lgIdx); + $lgName = $lgRow ? trim((string) ($lgRow->lg_name ?? '')) : ''; + + $gugunRows = $this->db->query(" + SELECT DISTINCT ds_gugun_code AS code + FROM designated_shop + WHERE ds_lg_idx = ? AND ds_gugun_code != '' + ORDER BY ds_gugun_code + ", [$lgIdx])->getResultArray(); + + $gugunOptions = [['code' => '', 'name' => '전체']]; + foreach ($gugunRows as $row) { + $code = trim((string) ($row['code'] ?? '')); + if ($code === '') { + continue; + } + $gugunOptions[] = ['code' => $code, 'name' => $this->gugunLabel($lgIdx, $code)]; + } + + $agencies = model(\App\Models\SalesAgencyModel::class) + ->where('sa_lg_idx', $lgIdx) + ->orderForDisplay() + ->findAll(); + + return [ + 'gugunOptions' => $gugunOptions, + 'agencies' => $agencies, + 'lgName' => $lgName, + 'gugunLabel' => '', + ]; + } + + /** + * @return array{ + * rows: list>, + * months: list, + * prevYear: int, + * year: int + * } + */ + public function buildYearOverYear( + int $lgIdx, + int $year, + string $gugunCode, + int $dsIdx, + bool $queried + ): array { + $prevYear = $year - 1; + $months = range(1, 12); + if (! $queried) { + return ['rows' => [], 'months' => $months, 'prevYear' => $prevYear, 'year' => $year]; + } + + $this->loadBagNames($lgIdx); + $agg = $this->aggregateMonthlyByBag($lgIdx, $prevYear, $year, $gugunCode, $dsIdx); + + $rows = []; + $codesFromAgg = array_map(static fn ($c): string => (string) $c, array_keys($agg)); + foreach ($this->bagCodesForReport($lgIdx, $codesFromAgg) as $code) { + $code = (string) $code; + $name = $this->resolveBagName($code); + $rows[] = $this->yoyBlock($code, (string) $name, '수량', $agg, $prevYear, $year, $months, false); + $rows[] = $this->yoyBlock($code, (string) $name, '금액', $agg, $prevYear, $year, $months, true); + } + + return ['rows' => $rows, 'months' => $months, 'prevYear' => $prevYear, 'year' => $year]; + } + + /** + * @return array{rows: list>, meta: array} + */ + public function buildMonthlyTrend( + int $lgIdx, + string $baseYm, + string $trendBasis, + float $deviationMin, + string $gugunCode, + int $saIdx, + bool $queried + ): array { + $empty = ['rows' => [], 'meta' => ['shopCount' => 0, 'monthSalesShops' => 0]]; + if (! $queried || ! preg_match('/^(\d{4})-(\d{2})$/', $baseYm, $m)) { + return $empty; + } + + $year = (int) $m[1]; + $month = (int) $m[2]; + $shops = $this->loadShops($lgIdx, $gugunCode, $saIdx); + if ($shops === []) { + return $empty; + } + + $monthlyByShop = $this->monthlyNetByShop($lgIdx, $year, $month, $gugunCode, $saIdx); + $avgByShop = $this->averageNetByShop($lgIdx, $year - 1, $gugunCode, $trendBasis, $month, $saIdx); + $prevYearSameMonth = $trendBasis === 'year_avg' + ? $this->monthlyNetByShop($lgIdx, $year - 1, $month, $gugunCode, $saIdx) + : []; + + $rows = []; + $monthSalesShops = 0; + foreach ($shops as $shop) { + $sid = (int) ($shop['ds_idx'] ?? 0); + $monthly = (float) ($monthlyByShop[$sid] ?? 0.0); + $avg = (float) ($avgByShop[$sid] ?? 0.0); + if ($trendBasis === 'year_avg' && $avg <= 0) { + $avg = (float) ($prevYearSameMonth[$sid] ?? 0.0); + } + if ($monthly > 0) { + $monthSalesShops++; + } + $diff = $monthly - $avg; + $pct = $avg > 0 ? round(($diff / $avg) * 100, 2) : ($monthly > 0 ? 100.0 : 0.0); + // 편차 N% 이상 = |편차(%)| ≥ N (증가·감소 모두) + if ($deviationMin > 0 && abs($pct) < $deviationMin) { + continue; + } + $rows[] = [ + 'agency_name' => (string) ($shop['agency_name'] ?? ''), + 'shop_no' => (string) ($shop['ds_shop_no'] ?? ''), + 'shop_name' => (string) ($shop['ds_name'] ?? ''), + 'rep_name' => (string) ($shop['ds_rep_name'] ?? ''), + 'prev_avg' => (int) round($avg), + 'monthly_qty' => (int) round($monthly), + 'avg_diff' => (int) round($diff), + 'deviation_pct'=> $pct, + 'designated_at'=> (string) ($shop['ds_designated_at'] ?? ''), + ]; + } + + usort($rows, static fn (array $a, array $b): int => strcmp((string) $a['shop_no'], (string) $b['shop_no'])); + + return [ + 'rows' => $rows, + 'meta' => [ + 'shopCount' => count($shops), + 'monthSalesShops' => $monthSalesShops, + ], + ]; + } + + /** + * @return list> + */ + public function buildSeasonalTrend( + int $lgIdx, + int $baseYear, + string $season, + float $deviationMin, + string $gugunCode, + bool $queried + ): array { + if (! $queried) { + return []; + } + + $seasonKey = self::normalizeSeason($season); + $seasonDef = self::seasonCatalog()[$seasonKey]; + $months = $seasonDef['months']; + + $saIdx = 0; + $shops = $this->loadShops($lgIdx, $gugunCode, $saIdx); + if ($shops === []) { + return []; + } + + $crossYear = (bool) ($seasonDef['cross_year'] ?? false); + $currentByShop = $this->seasonalNetByShop($lgIdx, $baseYear, $months, $gugunCode, $saIdx, $crossYear); + $prevByShop = $this->seasonalNetByShop($lgIdx, $baseYear - 1, $months, $gugunCode, $saIdx, $crossYear); + + $rows = []; + foreach ($shops as $shop) { + $sid = (int) ($shop['ds_idx'] ?? 0); + $curr = (float) ($currentByShop[$sid] ?? 0.0); + $prev = (float) ($prevByShop[$sid] ?? 0.0); + $diff = $curr - $prev; + $pct = $prev > 0 ? round(($diff / $prev) * 100, 2) : ($curr > 0 ? 100.0 : 0.0); + if ($deviationMin > 0 && abs($pct) < $deviationMin) { + continue; + } + $rows[] = [ + 'agency_name' => (string) ($shop['agency_name'] ?? ''), + 'shop_name' => (string) ($shop['ds_name'] ?? ''), + 'shop_no' => (string) ($shop['ds_shop_no'] ?? ''), + 'rep_name' => (string) ($shop['ds_rep_name'] ?? ''), + 'prev_season_avg'=> (int) round($prev), + 'base_season_avg'=> (int) round($curr), + 'avg_diff' => (int) round($diff), + 'deviation_pct' => $pct, + 'designated_at' => (string) ($shop['ds_designated_at'] ?? ''), + ]; + } + + usort($rows, static fn (array $a, array $b): int => strcmp((string) $a['shop_no'], (string) $b['shop_no'])); + + return $rows; + } + + private function gugunLabel(int $lgIdx, string $code): string + { + static $cache = []; + $key = $lgIdx . ':' . $code; + if (isset($cache[$key])) { + return $cache[$key]; + } + $row = $this->db->table('code_detail cd') + ->select('cd.cd_name') + ->join('code_kind ck', 'ck.ck_idx = cd.cd_ck_idx', 'inner') + ->where('ck.ck_code', 'G') + ->where('cd.cd_lg_idx', $lgIdx) + ->where('cd.cd_code', $code) + ->get() + ->getRowArray(); + $cache[$key] = trim((string) ($row['cd_name'] ?? $code)); + + return $cache[$key]; + } + + private function resolveBagName(string $code): string + { + if (isset($this->bagNames[$code])) { + return (string) $this->bagNames[$code]; + } + if (ctype_digit($code) && isset($this->bagNames[(int) $code])) { + return (string) $this->bagNames[(int) $code]; + } + + return $code; + } + + private function loadBagNames(int $lgIdx): void + { + if ($this->bagNames !== []) { + return; + } + $kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first(); + if (! $kindO) { + return; + } + foreach (model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) as $d) { + $code = trim((string) ($d->cd_code ?? '')); + if ($code !== '') { + $this->bagNames[$code] = trim((string) ($d->cd_name ?? $code)); + } + } + } + + /** + * @param list $codesFromAgg + * @return list + */ + private function bagCodesForReport(int $lgIdx, array $codesFromAgg): array + { + $this->loadBagNames($lgIdx); + $codes = array_keys($this->bagNames); + if ($codesFromAgg !== []) { + $merged = array_merge($codes, $codesFromAgg); + $codes = []; + foreach ($merged as $c) { + $codes[] = (string) $c; + } + $codes = array_values(array_unique($codes)); + sort($codes, SORT_STRING); + } else { + $codes = array_map(static fn ($c): string => (string) $c, $codes); + } + + return $codes; + } + + /** + * @return array>> + */ + private function aggregateMonthlyByBag( + int $lgIdx, + int $fromYear, + int $toYear, + string $gugunCode, + int $dsIdx + ): array { + $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 + 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) BETWEEN ? AND ? + "; + $params = [$lgIdx, $fromYear, $toYear]; + if ($gugunCode !== '') { + $sql .= ' AND ds.ds_gugun_code = ?'; + $params[] = $gugunCode; + } + if ($dsIdx > 0) { + $sql .= ' AND bs.bs_ds_idx = ?'; + $params[] = $dsIdx; + } + $sql .= ' GROUP BY bs.bs_bag_code, YEAR(bs.bs_sale_date), MONTH(bs.bs_sale_date)'; + + $agg = []; + foreach ($this->db->query($sql, $params)->getResultArray() as $row) { + $code = (string) ($row['bag_code'] ?? ''); + $y = (int) ($row['y'] ?? 0); + $m = (int) ($row['m'] ?? 0); + if ($code === '' || $y <= 0 || $m <= 0) { + continue; + } + $agg[$code][$y][$m] = [ + 'qty' => (float) ($row['net_qty'] ?? 0), + 'amt' => (float) ($row['net_amt'] ?? 0), + ]; + } + + return $agg; + } + + /** + * @param array>> $agg + * @param list $months + * @return array + */ + private function yoyBlock( + string $code, + string $name, + string $section, + array $agg, + int $prevYear, + int $year, + array $months, + bool $useAmount + ): array { + $key = $useAmount ? 'amt' : 'qty'; + $lines = []; + foreach ([$prevYear => (string) $prevYear . '년', $year => (string) $year . '년', 0 => '증감'] as $y => $label) { + $monthVals = []; + $total = 0.0; + foreach ($months as $mo) { + $v = 0.0; + if ($y === 0) { + $p = (float) ($agg[$code][$prevYear][$mo][$key] ?? 0); + $c = (float) ($agg[$code][$year][$mo][$key] ?? 0); + $v = $c - $p; + } else { + $v = (float) ($agg[$code][$y][$mo][$key] ?? 0); + } + $monthVals[$mo] = (int) round($v); + $total += $v; + } + $lines[] = ['label' => $label, 'months' => $monthVals, 'total' => (int) round($total)]; + } + + return [ + 'bag_code' => $code, + 'bag_name' => $name, + 'section' => $section, + 'lines' => $lines, + ]; + } + + /** + * @return list> + */ + /** + * @return array 구·군코드 → 대행소명 + */ + private function agencyNameByGugun(int $lgIdx): array + { + $best = []; + foreach ($this->db->query(" + SELECT TRIM(bo.bo_gugun_code) AS code, bo.bo_agency_idx AS sa_idx, COUNT(*) AS cnt + FROM bag_order bo + WHERE bo.bo_lg_idx = ? + AND bo.bo_status = 'normal' + AND bo.bo_agency_idx IS NOT NULL + AND TRIM(bo.bo_gugun_code) != '' + GROUP BY TRIM(bo.bo_gugun_code), bo.bo_agency_idx + ", [$lgIdx])->getResultArray() as $row) { + $code = (string) ($row['code'] ?? ''); + $cnt = (int) ($row['cnt'] ?? 0); + if ($code === '') { + continue; + } + if (! isset($best[$code]) || $cnt > $best[$code]['cnt']) { + $best[$code] = ['cnt' => $cnt, 'sa_idx' => (int) ($row['sa_idx'] ?? 0)]; + } + } + + $names = []; + foreach ($best as $code => $info) { + $sa = model(\App\Models\SalesAgencyModel::class)->find($info['sa_idx']); + $names[$code] = $sa ? trim((string) ($sa->sa_name ?? '')) : ''; + } + + return $names; + } + + private function loadShops(int $lgIdx, string $gugunCode, int $saIdx = 0): array + { + $hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop'); + $agencyByGugun = $this->agencyNameByGugun($lgIdx); + + $sql = ' + SELECT ds.ds_idx, ds.ds_shop_no, ds.ds_name, ds.ds_rep_name, ds.ds_designated_at, + ds.ds_gugun_code'; + if ($hasDsSa) { + $sql .= ', ds.ds_sa_idx'; + } + $sql .= ' + FROM designated_shop ds + WHERE ds.ds_lg_idx = ? AND ds.ds_state = 1 + '; + $params = [$lgIdx]; + + if ($gugunCode !== '') { + $sql .= ' AND ds.ds_gugun_code = ?'; + $params[] = $gugunCode; + } + if ($saIdx > 0 && $hasDsSa) { + $sql .= ' AND ds.ds_sa_idx = ?'; + $params[] = $saIdx; + } + $sql .= ' ORDER BY ds.ds_shop_no ASC, ds.ds_idx ASC'; + + $rows = $this->db->query($sql, $params)->getResultArray(); + $saNames = []; + if ($hasDsSa) { + foreach (model(\App\Models\SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->findAll() as $sa) { + $saNames[(int) ($sa->sa_idx ?? 0)] = trim((string) ($sa->sa_name ?? '')); + } + } + + foreach ($rows as &$row) { + $name = ''; + if ($hasDsSa) { + $saidx = (int) ($row['ds_sa_idx'] ?? 0); + $name = $saNames[$saidx] ?? ''; + } + if ($name === '') { + $code = trim((string) ($row['ds_gugun_code'] ?? '')); + $name = $agencyByGugun[$code] ?? ''; + } + $row['agency_name'] = $name; + } + unset($row); + + return $rows; + } + + /** + * @return array + */ + private function monthlyNetByShop(int $lgIdx, int $year, int $month, string $gugunCode, int $saIdx = 0): array + { + $hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop'); + $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 + 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) = ? + AND bs.bs_ds_idx IS NOT NULL + "; + $params = [$lgIdx, $year, $month]; + if ($gugunCode !== '') { + $sql .= ' AND ds.ds_gugun_code = ?'; + $params[] = $gugunCode; + } + if ($saIdx > 0 && $hasDsSa) { + $sql .= ' AND ds.ds_sa_idx = ?'; + $params[] = $saIdx; + } + $sql .= ' GROUP BY bs.bs_ds_idx'; + + $map = []; + foreach ($this->db->query($sql, $params)->getResultArray() as $row) { + $map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['net_qty'] ?? 0); + } + + return $map; + } + + /** + * @return array + */ + private function averageNetByShop( + int $lgIdx, + int $year, + string $gugunCode, + string $trendBasis, + int $refMonth, + int $saIdx = 0 + ): array { + if ($trendBasis === 'month') { + return $this->monthlyNetByShop($lgIdx, $year, $refMonth, $gugunCode, $saIdx); + } + + $hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop'); + $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 + 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 bs.bs_ds_idx IS NOT NULL + "; + $params = [$lgIdx, $year]; + if ($gugunCode !== '') { + $sql .= ' AND ds.ds_gugun_code = ?'; + $params[] = $gugunCode; + } + if ($saIdx > 0 && $hasDsSa) { + $sql .= ' AND ds.ds_sa_idx = ?'; + $params[] = $saIdx; + } + $sql .= ' GROUP BY bs.bs_ds_idx'; + + $map = []; + foreach ($this->db->query($sql, $params)->getResultArray() as $row) { + $map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['avg_qty'] ?? 0); + } + + return $map; + } + + /** + * @param list $months + * @return array + */ + private function seasonalNetByShop( + int $lgIdx, + int $year, + array $months, + string $gugunCode, + int $saIdx = 0, + bool $crossYearWinter = false + ): array { + if ($months === []) { + return []; + } + + $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"; + $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 + FROM bag_sale bs + INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx + WHERE bs.bs_lg_idx = ? + AND bs.bs_ds_idx IS NOT NULL + AND ( + (YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) = 12) + OR (YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) IN (1, 2)) + ) + "; + $params = [$divisor, $lgIdx, $year - 1, $year]; + } else { + $placeholders = implode(',', array_fill(0, count($months), '?')); + $sql = " + SELECT bs.bs_ds_idx AS ds_idx, + SUM({$qtyExpr}) / ? 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) = ? + AND MONTH(bs.bs_sale_date) IN ({$placeholders}) + AND bs.bs_ds_idx IS NOT NULL + "; + $params = array_merge([$divisor], [$lgIdx, $year], $months); + } + + if ($gugunCode !== '') { + $sql .= ' AND ds.ds_gugun_code = ?'; + $params[] = $gugunCode; + } + if ($saIdx > 0 && $hasDsSa) { + $sql .= ' AND ds.ds_sa_idx = ?'; + $params[] = $saIdx; + } + $sql .= ' GROUP BY bs.bs_ds_idx'; + + $map = []; + foreach ($this->db->query($sql, $params)->getResultArray() as $row) { + $map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['avg_qty'] ?? 0); + } + + return $map; + } +} diff --git a/app/Libraries/BagFlowReportBuilder.php b/app/Libraries/BagFlowReportBuilder.php new file mode 100644 index 0000000..d3c8849 --- /dev/null +++ b/app/Libraries/BagFlowReportBuilder.php @@ -0,0 +1,463 @@ +db = $db ?? \Config\Database::connect(); + } + + private static function bagCodeKey(mixed $code): string + { + return (string) $code; + } + + /** + * @return array{ + * rows: list>, + * bagKindLabels: array, + * queried: bool + * } + */ + public function build( + int $lgIdx, + string $startDate, + string $endDate, + string $aggMode, + string $bagCodeFilter, + string $bagKindFilter, + int $saIdx, + bool $queried + ): array { + $bagKindLabels = $this->loadBagKindLabels(); + $products = $this->loadProducts($lgIdx, $bagCodeFilter, $bagKindFilter); + if ($products === [] || ! $queried) { + return ['rows' => [], 'bagKindLabels' => $bagKindLabels, 'queried' => $queried]; + } + + $codes = array_keys($products); + $dayBefore = date('Y-m-d', strtotime($startDate . ' -1 day')); + $openingRaw = $this->aggregateMovements($lgIdx, $codes, $saIdx, null, $dayBefore); + $opening = $this->collapseOpeningBalances($openingRaw); + $periodMoves = $this->aggregateMovements($lgIdx, $codes, $saIdx, $startDate, $endDate); + + if ($aggMode === 'daily') { + $rows = $this->buildDailyRows($products, $opening, $periodMoves, $startDate, $endDate, $bagKindLabels); + } else { + $rows = $this->buildPeriodRows($products, $opening, $periodMoves, $startDate, $endDate, $bagKindLabels); + } + + return ['rows' => $rows, 'bagKindLabels' => $bagKindLabels, 'queried' => true]; + } + + /** + * @return array code => name + */ + private function loadProducts(int $lgIdx, string $bagCodeFilter, string $bagKindFilter): array + { + $kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first(); + if (! $kindO) { + return []; + } + $details = model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx); + $products = []; + foreach ($details as $d) { + $code = (string) ($d->cd_code ?? ''); + if ($code === '') { + continue; + } + if ($bagCodeFilter !== '' && $code !== $bagCodeFilter) { + continue; + } + if ($bagKindFilter !== '' && ! str_starts_with($code, $bagKindFilter)) { + continue; + } + $products[self::bagCodeKey($code)] = (string) ($d->cd_name ?? $code); + } + + return $products; + } + + /** + * @return array + */ + private function loadBagKindLabels(): array + { + $kindE = model(\App\Models\CodeKindModel::class)->where('ck_code', 'E')->first(); + if (! $kindE) { + return []; + } + $labels = []; + foreach (model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindE->ck_idx, true, null) as $d) { + $labels[(string) $d->cd_code] = (string) $d->cd_name; + } + + return $labels; + } + + /** + * @param list $codes + * @return array>> bag_code => date => metrics + */ + private function aggregateMovements( + int $lgIdx, + array $codes, + int $saIdx, + ?string $fromDate, + ?string $toDate + ): array { + if ($codes === []) { + return []; + } + + $buckets = []; + $ensure = static function (string $code, string $date) use (&$buckets): array { + if (! isset($buckets[$code][$date])) { + $buckets[$code][$date] = self::emptyMetrics(); + } + + return $buckets[$code][$date]; + }; + + $hasMisc = $this->db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() > 0; + $hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop'); + $codePlaceholders = implode(',', array_fill(0, count($codes), '?')); + + // 입고(발주 입고) + $sql = " + SELECT br_bag_code AS bag_code, br_receive_date AS mv_date, SUM(br_qty_sheet) AS qty + FROM bag_receiving + WHERE br_lg_idx = ? AND br_bag_code IN ({$codePlaceholders}) + "; + $params = array_merge([$lgIdx], $codes); + if ($fromDate !== null) { + $sql .= ' AND br_receive_date >= ?'; + $params[] = $fromDate; + } + if ($toDate !== null) { + $sql .= ' AND br_receive_date <= ?'; + $params[] = $toDate; + } + $sql .= ' GROUP BY br_bag_code, br_receive_date'; + foreach ($this->db->query($sql, $params)->getResult() as $row) { + $code = (string) $row->bag_code; + $date = (string) $row->mv_date; + $m = $ensure($code, $date); + $m['recv_in'] += (int) $row->qty; + $buckets[$code][$date] = $m; + } + + // 판매·반품(반품=입고) + $sql = " + SELECT bs.bs_bag_code AS bag_code, bs.bs_sale_date AS mv_date, bs.bs_type AS mv_type, + SUM(ABS(bs.bs_qty)) AS qty + FROM bag_sale bs + "; + if ($saIdx > 0 && $hasDsSa) { + $sql .= ' INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_sa_idx = ?'; + } + $sql .= " WHERE bs.bs_lg_idx = ? AND bs.bs_bag_code IN ({$codePlaceholders})"; + $params = $saIdx > 0 && $hasDsSa ? [$saIdx, $lgIdx] : [$lgIdx]; + $params = array_merge($params, $codes); + if ($fromDate !== null) { + $sql .= ' AND bs.bs_sale_date >= ?'; + $params[] = $fromDate; + } + if ($toDate !== null) { + $sql .= ' AND bs.bs_sale_date <= ?'; + $params[] = $toDate; + } + $sql .= ' GROUP BY bs.bs_bag_code, bs.bs_sale_date, bs.bs_type'; + foreach ($this->db->query($sql, $params)->getResult() as $row) { + $code = (string) $row->bag_code; + $date = (string) $row->mv_date; + $qty = (int) $row->qty; + $m = $ensure($code, $date); + $type = (string) $row->mv_type; + if ($type === 'return') { + $m['recv_return'] += $qty; + } else { + $m['out_sale'] += $qty; + } + $buckets[$code][$date] = $m; + } + + // 불출 + $sql = " + SELECT bi2_bag_code AS bag_code, bi2_issue_date AS mv_date, bi2_issue_type AS issue_type, + SUM(bi2_qty) AS qty + FROM bag_issue + WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_bag_code IN ({$codePlaceholders}) + "; + $params = array_merge([$lgIdx], $codes); + if ($fromDate !== null) { + $sql .= ' AND bi2_issue_date >= ?'; + $params[] = $fromDate; + } + if ($toDate !== null) { + $sql .= ' AND bi2_issue_date <= ?'; + $params[] = $toDate; + } + $sql .= ' GROUP BY bi2_bag_code, bi2_issue_date, bi2_issue_type'; + foreach ($this->db->query($sql, $params)->getResult() as $row) { + $code = (string) $row->bag_code; + $date = (string) $row->mv_date; + $qty = (int) $row->qty; + $m = $ensure($code, $date); + $issueType = (string) $row->issue_type; + if (str_contains($issueType, '무료')) { + $m['out_issue_free'] += $qty; + } else { + $m['out_issue_gen'] += $qty; + } + $buckets[$code][$date] = $m; + } + + if ($hasMisc) { + $sql = " + SELECT bmf_bag_code AS bag_code, bmf_date AS mv_date, bmf_type AS mv_type, + SUM(bmf_qty) AS qty + FROM bag_misc_flow + WHERE bmf_lg_idx = ? AND bmf_bag_code IN ({$codePlaceholders}) + "; + $params = array_merge([$lgIdx], $codes); + if ($fromDate !== null) { + $sql .= ' AND bmf_date >= ?'; + $params[] = $fromDate; + } + if ($toDate !== null) { + $sql .= ' AND bmf_date <= ?'; + $params[] = $toDate; + } + $sql .= ' GROUP BY bmf_bag_code, bmf_date, bmf_type'; + foreach ($this->db->query($sql, $params)->getResult() as $row) { + $code = (string) $row->bag_code; + $date = (string) $row->mv_date; + $qty = (int) $row->qty; + $m = $ensure($code, $date); + if ((string) $row->mv_type === 'in') { + $m['recv_misc'] += $qty; + } else { + $m['out_misc'] += $qty; + } + $buckets[$code][$date] = $m; + } + } + + $normalized = []; + foreach ($buckets as $code => $byDate) { + $key = self::bagCodeKey($code); + foreach ($byDate as $date => $m) { + $normalized[$key][$date] = self::finalizeMetrics($m); + } + } + + return $normalized; + } + + /** + * @param array $products + * @param array> $opening date key '_open' + * @param array>> $periodMoves + * @param array $bagKindLabels + * @return list> + */ + private function buildPeriodRows( + array $products, + array $opening, + array $periodMoves, + string $startDate, + string $endDate, + array $bagKindLabels + ): array { + $periodKey = $startDate . '~' . $endDate; + $grouped = []; + foreach ($products as $codeKey => $name) { + $code = self::bagCodeKey($codeKey); + $kind = strlen($code) >= 2 ? substr($code, 0, 2) : ''; + $grouped[$kind][] = ['code' => $code, 'name' => $name]; + } + ksort($grouped); + + $rows = []; + $grand = self::emptyMetrics(); + $grand['row_type'] = 'grand'; + $grand['date'] = ''; + $grand['item_name'] = '총계'; + + foreach ($grouped as $kind => $items) { + $sub = self::emptyMetrics(); + $sub['row_type'] = 'subtotal'; + $sub['date'] = ''; + $sub['item_name'] = ($bagKindLabels[$kind] ?? '기타') . ' 소계'; + + foreach ($items as $item) { + $code = self::bagCodeKey($item['code']); + $m = self::emptyMetrics(); + foreach ($periodMoves[$code] ?? [] as $dayMetrics) { + $m = self::mergeMetrics($m, $dayMetrics); + } + $m = self::finalizeMetrics($m); + $m['prev_stock'] = (int) ($opening[$code] ?? 0); + $m['balance'] = $m['prev_stock'] + $m['recv_total'] - $m['out_total']; + $m['row_type'] = 'data'; + $m['date'] = $periodKey; + $m['item_name'] = $item['name']; + $m['bag_code'] = $code; + $m['bag_kind'] = $kind; + $rows[] = $m; + $sub = self::mergeMetrics($sub, $m); + } + + $sub = self::finalizeMetrics($sub); + $sub['balance'] = $sub['prev_stock'] + $sub['recv_total'] - $sub['out_total']; + $rows[] = $sub; + $grand = self::mergeMetrics($grand, $sub); + } + + $grand = self::finalizeMetrics($grand); + $grand['balance'] = $grand['prev_stock'] + $grand['recv_total'] - $grand['out_total']; + $rows[] = $grand; + + return $rows; + } + + /** + * @param array>> $openingRaw + * @return array bag_code => 전일(기간 전) 재고 + */ + private function collapseOpeningBalances(array $openingRaw): array + { + $out = []; + foreach ($openingRaw as $code => $byDate) { + $net = self::emptyMetrics(); + foreach ($byDate as $m) { + $net = self::mergeMetrics($net, $m); + } + $net = self::finalizeMetrics($net); + $out[self::bagCodeKey($code)] = $net['recv_total'] - $net['out_total']; + } + + return $out; + } + + /** + * @param array $products + * @param array>> $opening + * @param array>> $periodMoves + * @param array $bagKindLabels + * @return list> + */ + private function buildDailyRows( + array $products, + array $opening, + array $periodMoves, + string $startDate, + string $endDate, + array $bagKindLabels + ): array { + $dates = []; + $cursor = strtotime($startDate); + $endTs = strtotime($endDate); + while ($cursor <= $endTs) { + $dates[] = date('Y-m-d', $cursor); + $cursor = strtotime('+1 day', $cursor); + } + + $rows = []; + foreach ($products as $codeKey => $name) { + $code = self::bagCodeKey($codeKey); + $kind = strlen($code) >= 2 ? substr($code, 0, 2) : ''; + $running = (int) ($opening[$code] ?? 0); + foreach ($dates as $date) { + $dayM = $periodMoves[$code][$date] ?? self::emptyMetrics(); + $dayM = self::finalizeMetrics($dayM); + $prev = $running; + $running = $prev + $dayM['recv_total'] - $dayM['out_total']; + $dayM['prev_stock'] = $prev; + $dayM['balance'] = $running; + $dayM['row_type'] = 'data'; + $dayM['date'] = $date; + $dayM['item_name'] = $name; + $dayM['bag_code'] = $code; + $dayM['bag_kind'] = $kind; + if ($this->rowHasActivity($dayM)) { + $rows[] = $dayM; + } + } + } + + return $rows; + } + + /** + * @param array $m + */ + private function rowHasActivity(array $m): bool + { + foreach (['recv_in', 'recv_return', 'recv_misc', 'out_sale', 'out_issue_gen', 'out_issue_free', 'out_return', 'out_misc'] as $k) { + if ((int) ($m[$k] ?? 0) !== 0) { + return true; + } + } + + return (int) ($m['prev_stock'] ?? 0) !== 0; + } + + /** + * @return array + */ + private static function emptyMetrics(): array + { + return [ + 'prev_stock' => 0, + 'recv_in' => 0, + 'recv_return' => 0, + 'recv_misc' => 0, + 'recv_total' => 0, + 'out_sale' => 0, + 'out_issue_gen' => 0, + 'out_issue_free' => 0, + 'out_return' => 0, + 'out_misc' => 0, + 'out_total' => 0, + 'balance' => 0, + ]; + } + + /** + * @param array $m + * @return array + */ + private static function finalizeMetrics(array $m): array + { + $m['recv_total'] = (int) $m['recv_in'] + (int) $m['recv_return'] + (int) $m['recv_misc']; + $m['out_total'] = (int) $m['out_sale'] + (int) $m['out_issue_gen'] + (int) $m['out_issue_free'] + + (int) $m['out_return'] + (int) $m['out_misc']; + + return $m; + } + + /** + * @param array $a + * @param array $b + * @return array + */ + private static function mergeMetrics(array $a, array $b): array + { + foreach (self::emptyMetrics() as $k => $_) { + $a[$k] = (int) ($a[$k] ?? 0) + (int) ($b[$k] ?? 0); + } + + return $a; + } +} diff --git a/app/Libraries/BagLotFlowBuilder.php b/app/Libraries/BagLotFlowBuilder.php new file mode 100644 index 0000000..7b7fa7b --- /dev/null +++ b/app/Libraries/BagLotFlowBuilder.php @@ -0,0 +1,673 @@ +db = $db ?? \Config\Database::connect(); + } + + /** + * @return array{ + * ok: bool, + * message: string, + * barcode: string, + * unit: string, + * bag_code: string, + * bag_name: string, + * lot_no: string, + * box_code: string, + * pack_code: string, + * qty_box: int, + * qty_pack: int, + * qty_sheet: int, + * rows: list + * } + */ + public function buildByBarcode(int $lgIdx, string $barcode, bool $queried): array + { + $empty = $this->emptyResult($barcode); + if (! $queried || trim($barcode) === '') { + return $empty; + } + + $resolved = $this->resolveBarcode($lgIdx, trim($barcode)); + if (! $resolved['ok']) { + return array_merge($empty, [ + 'message' => (string) ($resolved['message'] ?? '등록되지 않은 바코드입니다.'), + ]); + } + + $rows = $this->collectFlowRows($lgIdx, $resolved); + usort($rows, static fn (array $a, array $b): int => ($a['sort_ts'] ?? 0) <=> ($b['sort_ts'] ?? 0)); + + return array_merge($empty, [ + 'ok' => true, + 'message' => '', + 'barcode' => (string) ($resolved['barcode'] ?? $barcode), + 'unit' => (string) ($resolved['unit'] ?? ''), + 'bag_code' => (string) ($resolved['bag_code'] ?? ''), + 'bag_name' => (string) ($resolved['bag_name'] ?? ''), + 'lot_no' => (string) ($resolved['lot_no'] ?? ''), + 'box_code' => (string) ($resolved['box_code'] ?? ''), + 'pack_code' => (string) ($resolved['pack_code'] ?? ''), + 'qty_box' => (int) ($resolved['qty_box'] ?? 0), + 'qty_pack' => (int) ($resolved['qty_pack'] ?? 0), + 'qty_sheet' => (int) ($resolved['qty_sheet'] ?? 0), + 'rows' => $rows, + ]); + } + + /** + * @return array + */ + public function buildByLotNo(int $lgIdx, string $lotNo, bool $queried): array + { + $empty = $this->emptyResult(''); + if (! $queried || trim($lotNo) === '') { + return $empty; + } + + $lotNo = trim($lotNo); + if (! $this->db->tableExists('bag_receiving_pack_code')) { + return array_merge($empty, ['message' => '바코드(팩) 데이터가 없습니다.']); + } + + $packRows = $this->db->table('bag_receiving_pack_code') + ->select('brpc_pack_code, brpc_box_code, brpc_bag_code, brpc_bag_name, brpc_lot_no') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_lot_no', $lotNo) + ->limit(500) + ->get() + ->getResultArray(); + + if ($packRows === []) { + $order = $this->db->table('bag_order') + ->where('bo_lg_idx', $lgIdx) + ->where('bo_lot_no', $lotNo) + ->orderBy('bo_version', 'DESC') + ->get() + ->getRowArray(); + if (! $order) { + return array_merge($empty, ['message' => '해당 LOT·바코드를 찾을 수 없습니다.', 'lot_no' => $lotNo]); + } + + return $this->buildLotFromOrderOnly($lgIdx, $lotNo, $order); + } + + $codes = []; + $bagCode = ''; + $bagName = ''; + foreach ($packRows as $p) { + $codes[] = (string) ($p['brpc_pack_code'] ?? ''); + $box = (string) ($p['brpc_box_code'] ?? ''); + if ($box !== '') { + $codes[] = $box; + } + if ($bagCode === '') { + $bagCode = (string) ($p['brpc_bag_code'] ?? ''); + $bagName = (string) ($p['brpc_bag_name'] ?? ''); + } + } + $codes = array_values(array_unique(array_filter($codes, static fn (string $c): bool => $c !== ''))); + + $rows = []; + foreach ($this->loadReceivingEventsForLot($lgIdx, $lotNo) as $ev) { + $rows[] = $ev; + } + foreach ($this->loadScanEventsForCodes($lgIdx, $codes) as $ev) { + $rows[] = $ev; + } + foreach ($this->loadReturnEventsForCodes($lgIdx, $codes) as $ev) { + $rows[] = $ev; + } + + usort($rows, static fn (array $a, array $b): int => ($a['sort_ts'] ?? 0) <=> ($b['sort_ts'] ?? 0)); + if (count($rows) > 500) { + $rows = array_slice($rows, -500); + } + + return array_merge($empty, [ + 'ok' => true, + 'lot_no' => $lotNo, + 'bag_code' => $bagCode, + 'bag_name' => $bagName, + 'barcode' => $lotNo, + 'rows' => $rows, + ]); + } + + /** + * @return array + */ + private function emptyResult(string $barcode): array + { + return [ + 'ok' => false, + 'message' => '', + 'barcode' => $barcode, + 'unit' => '', + 'bag_code' => '', + 'bag_name' => '', + 'lot_no' => '', + 'box_code' => '', + 'pack_code' => '', + 'qty_box' => 0, + 'qty_pack' => 0, + 'qty_sheet' => 0, + 'rows' => [], + ]; + } + + /** + * @return array{ok: bool, message?: string, 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} + */ + private function resolveBarcode(int $lgIdx, string $barcode): array + { + if (! $this->db->tableExists('bag_receiving_pack_code')) { + return ['ok' => false, 'message' => '바코드(팩) 데이터가 없습니다.']; + } + + $pack = $this->db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_pack_code', $barcode) + ->get() + ->getRowArray(); + if ($pack) { + return $this->resolvedFromPackRow($barcode, '팩', $pack, 0, 1, (int) ($pack['brpc_sheet_qty'] ?? 0)); + } + + $boxRows = $this->db->table('bag_receiving_pack_code') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_box_code', $barcode) + ->get() + ->getResultArray(); + if ($boxRows !== []) { + $first = $boxRows[0]; + $sheetQty = 0; + foreach ($boxRows as $row) { + $sheetQty += (int) ($row['brpc_sheet_qty'] ?? 0); + } + + return $this->resolvedFromPackRow($barcode, '박스', $first, 1, count($boxRows), $sheetQty); + } + + $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) + ->get() + ->getResultArray(); + foreach ($sheetRows as $row) { + $start = (string) ($row['brpc_sheet_start_code'] ?? ''); + $end = (string) ($row['brpc_sheet_end_code'] ?? ''); + if ($this->barcodeInRange($barcode, $start, $end)) { + return $this->resolvedFromPackRow($barcode, '낱장', $row, 0, 0, 1); + } + } + + return ['ok' => false, 'message' => '등록되지 않은 바코드입니다.']; + } + + /** + * @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} + */ + private function resolvedFromPackRow(string $barcode, string $unit, array $pack, int $qtyBox, int $qtyPack, int $qtySheet): array + { + return [ + 'ok' => true, + 'barcode' => $barcode, + 'unit' => $unit, + 'bag_code' => (string) ($pack['brpc_bag_code'] ?? ''), + 'bag_name' => (string) ($pack['brpc_bag_name'] ?? ''), + 'lot_no' => (string) ($pack['brpc_lot_no'] ?? ''), + 'box_code' => (string) ($pack['brpc_box_code'] ?? ''), + 'pack_code' => (string) ($pack['brpc_pack_code'] ?? ''), + 'pack_ids' => [(int) ($pack['brpc_idx'] ?? 0)], + 'qty_box' => $qtyBox, + 'qty_pack' => $qtyPack, + 'qty_sheet' => $qtySheet, + ]; + } + + /** + * @param array $resolved + * @return list + */ + private function collectFlowRows(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']; + } + + $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); + } + } + + if ($brIdx > 0) { + 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; + } + + /** + * @return list + */ + private function loadReceivingEventsByBrIdx(int $lgIdx, int $brIdx): array + { + $sql = " + SELECT r.br_receive_date, r.br_sender_name, r.br_regdate, + o.bo_order_date, c.cp_name, sa.sa_name + FROM bag_receiving r + LEFT JOIN bag_order o ON o.bo_idx = r.br_bo_idx + LEFT JOIN company c ON c.cp_idx = o.bo_company_idx + LEFT JOIN sales_agency sa ON sa.sa_idx = o.bo_agency_idx + WHERE r.br_lg_idx = ? AND r.br_idx = ? + LIMIT 20 + "; + $rows = []; + foreach ($this->db->query($sql, [$lgIdx, $brIdx])->getResultArray() as $r) { + $rows[] = $this->makeEvent( + (string) ($r['br_receive_date'] ?? ''), + (string) ($r['br_regdate'] ?? ''), + $this->pickSource($r, '입고처', 'sa_name', 'cp_name', 'br_sender_name'), + '입고' + ); + } + + return $rows; + } + + /** + * @return list + */ + private function loadReceivingEventsForLot(int $lgIdx, string $lotNo): array + { + $sql = " + SELECT r.br_receive_date, r.br_sender_name, r.br_regdate, r.br_bag_name, + c.cp_name, sa.sa_name + FROM bag_receiving r + INNER JOIN bag_order o ON o.bo_idx = r.br_bo_idx AND o.bo_lot_no = ? + LEFT JOIN company c ON c.cp_idx = o.bo_company_idx + LEFT JOIN sales_agency sa ON sa.sa_idx = o.bo_agency_idx + WHERE r.br_lg_idx = ? + ORDER BY r.br_receive_date ASC, r.br_idx ASC + LIMIT 200 + "; + $rows = []; + foreach ($this->db->query($sql, [$lotNo, $lgIdx])->getResultArray() as $r) { + $label = trim((string) ($r['br_bag_name'] ?? '')); + $rows[] = $this->makeEvent( + (string) ($r['br_receive_date'] ?? ''), + (string) ($r['br_regdate'] ?? ''), + $this->pickSource($r, '입고처', 'sa_name', 'cp_name', 'br_sender_name') . ($label !== '' ? ' · ' . $label : ''), + '입고' + ); + } + + return $rows; + } + + /** + * @param list $codes + * @return list + */ + private function loadScanEventsForCodes(int $lgIdx, array $codes): array + { + if ($codes === [] || ! $this->db->tableExists('bag_sale_scan_code')) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($codes), '?')); + $params = array_merge([$lgIdx], $codes); + $sql = " + SELECT b.bssc_regdate, b.bssc_state, b.bssc_code, d.ds_name, d.ds_shop_no + FROM bag_sale_scan_code b + LEFT JOIN designated_shop d ON d.ds_idx = b.bssc_ds_idx + WHERE b.bssc_lg_idx = ? AND b.bssc_code IN ({$placeholders}) + ORDER BY b.bssc_regdate ASC + LIMIT 200 + "; + $rows = []; + foreach ($this->db->query($sql, $params)->getResultArray() as $r) { + $state = strtolower((string) ($r['bssc_state'] ?? '')); + $type = $state === 'in_stock' ? '반품입고' : '출고'; + $shop = trim((string) ($r['ds_name'] ?? '')); + if ($shop === '') { + $shop = trim((string) ($r['ds_shop_no'] ?? '')); + } + if ($shop === '') { + $shop = '지정판매소'; + } + $rows[] = $this->makeEvent( + $this->dateOnly((string) ($r['bssc_regdate'] ?? '')), + (string) ($r['bssc_regdate'] ?? ''), + $shop, + $type + ); + } + + return $rows; + } + + /** + * @param list $codes + * @return list + */ + private function loadReturnEventsForCodes(int $lgIdx, array $codes): array + { + if ($codes === [] || ! $this->db->tableExists('bag_return_scan_code')) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($codes), '?')); + $params = array_merge([$lgIdx], $codes); + $sql = " + SELECT r.brsc_return_date, r.brsc_regdate, r.brsc_code, d.ds_name, d.ds_shop_no + FROM bag_return_scan_code r + LEFT JOIN designated_shop d ON d.ds_idx = r.brsc_ds_idx + WHERE r.brsc_lg_idx = ? AND r.brsc_code IN ({$placeholders}) + ORDER BY r.brsc_return_date ASC + LIMIT 200 + "; + $rows = []; + foreach ($this->db->query($sql, $params)->getResultArray() as $r) { + $shop = trim((string) ($r['ds_name'] ?? '')); + if ($shop === '') { + $shop = trim((string) ($r['ds_shop_no'] ?? '')); + } + if ($shop === '') { + $shop = '지정판매소'; + } + $rows[] = $this->makeEvent( + (string) ($r['brsc_return_date'] ?? ''), + (string) ($r['brsc_regdate'] ?? ''), + $shop, + '반품' + ); + } + + return $rows; + } + + /** + * @return list + */ + private function loadOrderEventsForLot(int $lgIdx, string $lotNo): array + { + $sql = " + SELECT o.bo_order_date, o.bo_regdate, c.cp_name, sa.sa_name + FROM bag_order o + LEFT JOIN company c ON c.cp_idx = o.bo_company_idx + LEFT JOIN sales_agency sa ON sa.sa_idx = o.bo_agency_idx + WHERE o.bo_lg_idx = ? AND o.bo_lot_no = ? AND o.bo_status = 'normal' + ORDER BY o.bo_version DESC + LIMIT 1 + "; + $r = $this->db->query($sql, [$lgIdx, $lotNo])->getRowArray(); + if (! $r) { + return []; + } + + return [ + $this->makeEvent( + (string) ($r['bo_order_date'] ?? ''), + (string) ($r['bo_regdate'] ?? ''), + $this->pickSource($r, '제작·발주', 'cp_name', 'sa_name'), + '발주' + ), + ]; + } + + /** + * @param array $order + * @return array + */ + private function buildLotFromOrderOnly(int $lgIdx, string $lotNo, array $order): array + { + $rows = $this->loadOrderEventsForLot($lgIdx, $lotNo); + $boIdx = (int) ($order['bo_idx'] ?? 0); + if ($boIdx > 0) { + $sql = " + SELECT r.br_receive_date, r.br_sender_name, r.br_regdate, r.br_bag_name, + c.cp_name, sa.sa_name + FROM bag_receiving r + LEFT JOIN bag_order o ON o.bo_idx = r.br_bo_idx + LEFT JOIN company c ON c.cp_idx = o.bo_company_idx + LEFT JOIN sales_agency sa ON sa.sa_idx = o.bo_agency_idx + WHERE r.br_bo_idx = ? + ORDER BY r.br_receive_date ASC + "; + foreach ($this->db->query($sql, [$boIdx])->getResultArray() as $r) { + $rows[] = $this->makeEvent( + (string) ($r['br_receive_date'] ?? ''), + (string) ($r['br_regdate'] ?? ''), + $this->pickSource($r, '입고처', 'sa_name', 'cp_name', 'br_sender_name'), + '입고' + ); + } + } + + usort($rows, static fn (array $a, array $b): int => ($a['sort_ts'] ?? 0) <=> ($b['sort_ts'] ?? 0)); + + return array_merge($this->emptyResult($lotNo), [ + 'ok' => true, + 'lot_no' => $lotNo, + 'barcode' => $lotNo, + 'rows' => $rows, + ]); + } + + /** + * @param array $row + */ + private function pickSource(array $row, string $default, string ...$keys): string + { + foreach ($keys as $key) { + $v = trim((string) ($row[$key] ?? '')); + if ($v !== '') { + return $v; + } + } + + return $default; + } + + /** + * @return array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int} + */ + private function makeEvent(string $dateYmd, string $sortDatetime, string $counterparty, string $flowType): array + { + $ts = strtotime($sortDatetime !== '' ? $sortDatetime : $dateYmd); + + return [ + 'flow_date' => $dateYmd !== '' ? $dateYmd : ($ts ? date('Y-m-d', $ts) : ''), + 'counterparty' => $counterparty, + 'flow_type' => $flowType, + 'sort_ts' => $ts !== false ? $ts : 0, + ]; + } + + private function dateOnly(string $datetime): string + { + if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $datetime, $m) === 1) { + return $m[1]; + } + + $ts = strtotime($datetime); + + return $ts ? date('Y-m-d', $ts) : $datetime; + } + + /** + * LOT 수불 조회 화면 테스트용 — 등록된 바코드·LOT 샘플 + * + * @return list + */ + public function loadTestSamples(int $lgIdx, int $limit = 80): array + { + $samples = []; + $seen = []; + + $push = static function (array &$samples, array &$seen, string $kind, string $code, string $bagName, string $lotNo, string $state, string $hint) use ($limit): void { + $code = trim($code); + if ($code === '' || isset($seen[$code]) || count($samples) >= $limit) { + return; + } + $seen[$code] = true; + $samples[] = [ + 'kind' => $kind, + 'code' => $code, + 'bag_name' => $bagName, + 'lot_no' => $lotNo, + 'state' => $state, + 'hint' => $hint, + ]; + }; + + if ($this->db->tableExists('bag_receiving_pack_code')) { + foreach ($this->db->table('bag_receiving_pack_code') + ->select('brpc_pack_code, brpc_box_code, brpc_sheet_start_code, brpc_bag_name, brpc_lot_no, brpc_state') + ->where('brpc_lg_idx', $lgIdx) + ->orderBy('brpc_idx', 'DESC') + ->limit(40) + ->get() + ->getResultArray() as $row) { + $state = $this->packStateLabel((string) ($row['brpc_state'] ?? '')); + $bagName = (string) ($row['brpc_bag_name'] ?? ''); + $lotNo = (string) ($row['brpc_lot_no'] ?? ''); + $push($samples, $seen, '팩', (string) ($row['brpc_pack_code'] ?? ''), $bagName, $lotNo, $state, '입고 팩 코드'); + } + + $boxRows = $this->db->query(" + SELECT brpc_box_code, + MAX(brpc_bag_name) AS brpc_bag_name, + MAX(brpc_lot_no) AS brpc_lot_no, + MAX(brpc_state) AS brpc_state + FROM bag_receiving_pack_code + WHERE brpc_lg_idx = ? AND brpc_box_code != '' + GROUP BY brpc_box_code + ORDER BY MAX(brpc_idx) DESC + LIMIT 15 + ", [$lgIdx])->getResultArray(); + foreach ($boxRows as $row) { + $push($samples, $seen, '박스', (string) ($row['brpc_box_code'] ?? ''), (string) ($row['brpc_bag_name'] ?? ''), (string) ($row['brpc_lot_no'] ?? ''), $this->packStateLabel((string) ($row['brpc_state'] ?? '')), '박스 단위 조회'); + } + + $sheetRows = $this->db->table('bag_receiving_pack_code') + ->select('brpc_sheet_start_code, brpc_bag_name, brpc_lot_no, brpc_state') + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_sheet_start_code !=', '') + ->orderBy('brpc_idx', 'DESC') + ->limit(15) + ->get() + ->getResultArray(); + foreach ($sheetRows as $row) { + $push($samples, $seen, '낱장', (string) ($row['brpc_sheet_start_code'] ?? ''), (string) ($row['brpc_bag_name'] ?? ''), (string) ($row['brpc_lot_no'] ?? ''), $this->packStateLabel((string) ($row['brpc_state'] ?? '')), '낱장 시작 코드'); + } + + $lotRows = $this->db->query(" + SELECT DISTINCT brpc_lot_no + FROM bag_receiving_pack_code + WHERE brpc_lg_idx = ? AND brpc_lot_no != '' + ORDER BY brpc_lot_no DESC + LIMIT 10 + ", [$lgIdx])->getResultArray(); + foreach ($lotRows as $row) { + $lot = (string) ($row['brpc_lot_no'] ?? ''); + $push($samples, $seen, 'LOT', $lot, '(LOT 전체)', $lot, '—', 'lot_no 파라미터·입력 동일'); + } + } + + if ($this->db->tableExists('bag_sale_scan_code')) { + foreach ($this->db->table('bag_sale_scan_code b') + ->select('b.bssc_code, b.bssc_bag_name, b.bssc_unit, b.bssc_state, b.bssc_regdate') + ->where('b.bssc_lg_idx', $lgIdx) + ->orderBy('b.bssc_regdate', 'DESC') + ->limit(20) + ->get() + ->getResultArray() as $row) { + $state = strtolower((string) ($row['bssc_state'] ?? '')) === 'sold' ? '판매' : '반품재고'; + $push($samples, $seen, '스캔', (string) ($row['bssc_code'] ?? ''), (string) ($row['bssc_bag_name'] ?? ''), '', $state, '판매·반품 스캔 이력'); + } + } + + return $samples; + } + + private function packStateLabel(string $state): string + { + return match (strtolower($state)) { + 'in_stock' => '재고', + 'sold' => '판매', + default => $state !== '' ? $state : '—', + }; + } + + private function barcodeInRange(string $code, string $start, string $end): bool + { + if ($start === '' || $end === '') { + return false; + } + + $extract = static function (string $v): array { + if (preg_match('/^(.*?)(\d+)$/', $v, $m) === 1) { + return [(string) $m[1], (int) $m[2], strlen((string) $m[2])]; + } + + return ['', -1, 0]; + }; + [$cp, $cn, $cl] = $extract($code); + [$sp, $sn, $sl] = $extract($start); + [$ep, $en, $el] = $extract($end); + if ($cn >= 0 && $sn >= 0 && $en >= 0 && $cp === $sp && $sp === $ep && $cl === $sl && $sl === $el) { + return $cn >= $sn && $cn <= $en; + } + + return strcmp($code, $start) >= 0 && strcmp($code, $end) <= 0; + } +} diff --git a/app/Libraries/BagSupplyPlanBuilder.php b/app/Libraries/BagSupplyPlanBuilder.php new file mode 100644 index 0000000..81c5eb2 --- /dev/null +++ b/app/Libraries/BagSupplyPlanBuilder.php @@ -0,0 +1,494 @@ +db = $db ?? \Config\Database::connect(); + } + + /** + * @return array{ + * rows: list>, + * barcodeCodes: array, + * queried: bool + * } + */ + public function build( + int $lgIdx, + string $refDate, + int $leadDays, + string $stockScope, + string $salesScope, + bool $queried + ): array { + $barcodeCodes = $this->loadBarcodeCodes($lgIdx); + $products = $this->loadProducts($lgIdx); + if ($products === [] || ! $queried) { + return ['rows' => [], 'barcodeCodes' => $barcodeCodes, 'queried' => $queried]; + } + + $inventory = $this->loadInventoryMap($lgIdx); + $pendingIn = $this->loadPendingInbound($lgIdx); + $lastOrders = $this->loadLastOrders($lgIdx); + $monthlySales = $this->loadMonthlyAverageSales($lgIdx, $refDate, $salesScope, $barcodeCodes); + $movementsSince = $this->loadMovementsSinceOrders($lgIdx, $lastOrders, $refDate); + + $rows = []; + foreach ($products as $code => $name) { + $isBarcode = isset($barcodeCodes[$code]); + $rawStock = (int) ($inventory[$code] ?? 0); + $currentStock = $this->scopedStock($rawStock, $stockScope, $isBarcode); + $pendingQty = $this->scopedPending((int) ($pendingIn[$code] ?? 0), $stockScope, $isBarcode); + $totalStock = $currentStock + $pendingQty; + $monthlyFloat = (float) ($monthlySales[$code] ?? 0.0); + $monthlyAvg = (int) round($monthlyFloat); + + $depletionDays = $this->calcDepletionDays($totalStock, $monthlyFloat); + $scheduleDate = $this->calcScheduleDate($refDate, $depletionDays, $leadDays); + $orderQty = $this->calcOrderQty($refDate, $scheduleDate, $depletionDays, $leadDays, $monthlyAvg, $totalStock); + + $last = $lastOrders[$code] ?? null; + $orderDate = $last ? (string) ($last['order_date'] ?? '') : ''; + $lastQty = $last ? (int) ($last['qty_sheet'] ?? 0) : 0; + $stockAtOrder = 0; + if ($orderDate !== '' && $lastQty > 0) { + $mv = $movementsSince[$code] ?? ['sale' => 0, 'recv' => 0, 'issue' => 0]; + $stockAtOrder = max(0, $rawStock + $mv['sale'] - $mv['recv'] - $mv['issue']); + } + + $rows[] = [ + 'bag_code' => $code, + 'bag_name' => $name, + 'is_barcode' => $isBarcode, + 'last_order_date' => $orderDate, + 'last_order_qty' => $lastQty, + 'stock_at_order' => $stockAtOrder, + 'current_stock' => $currentStock, + 'pending_inbound' => $pendingQty, + 'total_stock' => $totalStock, + 'monthly_avg_sales' => $monthlyAvg, + 'depletion_days' => $depletionDays, + 'schedule_date' => $scheduleDate, + 'schedule_overdue' => $scheduleDate !== '' && $scheduleDate <= $refDate && $depletionDays < 99999, + 'order_qty' => $orderQty, + ]; + } + + usort($rows, static fn (array $a, array $b): int => strcmp((string) $a['bag_code'], (string) $b['bag_code'])); + + return ['rows' => $rows, 'barcodeCodes' => $barcodeCodes, 'queried' => true]; + } + + /** + * @return array + */ + private function loadBarcodeCodes(int $lgIdx): array + { + if (! $this->db->tableExists('bag_receiving_pack_code')) { + return []; + } + $set = []; + foreach ($this->db->table('bag_receiving_pack_code') + ->select('brpc_bag_code') + ->distinct() + ->where('brpc_lg_idx', $lgIdx) + ->where('brpc_bag_code !=', '') + ->get() + ->getResultArray() as $row) { + $code = trim((string) ($row['brpc_bag_code'] ?? '')); + if ($code !== '') { + $set[$code] = true; + } + } + + return $set; + } + + /** + * @return array code => name + */ + private function loadProducts(int $lgIdx): array + { + $kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first(); + $products = []; + if ($kindO) { + foreach (model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) as $d) { + $code = trim((string) ($d->cd_code ?? '')); + if ($code !== '') { + $products[$code] = trim((string) ($d->cd_name ?? $code)); + } + } + } + + foreach ($this->db->table('bag_inventory') + ->select('bi_bag_code, bi_bag_name') + ->where('bi_lg_idx', $lgIdx) + ->get() + ->getResultArray() as $row) { + $code = trim((string) ($row['bi_bag_code'] ?? '')); + if ($code === '') { + continue; + } + if (! isset($products[$code])) { + $products[$code] = trim((string) ($row['bi_bag_name'] ?? $code)); + } + } + + return $products; + } + + /** + * @return array + */ + private function loadInventoryMap(int $lgIdx): array + { + $map = []; + foreach (model(\App\Models\BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->findAll() as $inv) { + $code = trim((string) ($inv->bi_bag_code ?? '')); + if ($code !== '') { + $map[$code] = (int) ($inv->bi_qty ?? 0); + } + } + + return $map; + } + + /** + * @return array + */ + private function loadPendingInbound(int $lgIdx): array + { + $map = []; + $sql = " + SELECT boi.boi_bag_code AS bag_code, + SUM(GREATEST(0, CAST(boi.boi_qty_sheet AS SIGNED) - IFNULL(r.recv_qty, 0))) AS pending_qty + FROM bag_order_item boi + INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx + LEFT JOIN ( + SELECT br_bo_idx, br_bag_code, SUM(br_qty_sheet) AS recv_qty + FROM bag_receiving + GROUP BY br_bo_idx, br_bag_code + ) r ON r.br_bo_idx = bo.bo_idx AND r.br_bag_code = boi.boi_bag_code + WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal' + GROUP BY boi.boi_bag_code + "; + foreach ($this->db->query($sql, [$lgIdx])->getResult() as $row) { + $qty = (int) ($row->pending_qty ?? 0); + if ($qty > 0) { + $map[(string) $row->bag_code] = $qty; + } + } + + return $map; + } + + /** + * @return array + */ + private function loadLastOrders(int $lgIdx): array + { + $map = []; + $supportsWindow = $this->db->DBDriver === 'MySQLi'; + if ($supportsWindow) { + $sql = " + SELECT bag_code, order_date, qty_sheet, bag_name + FROM ( + SELECT boi.boi_bag_code AS bag_code, bo.bo_order_date AS order_date, + boi.boi_qty_sheet AS qty_sheet, boi.boi_bag_name AS bag_name, + ROW_NUMBER() OVER ( + PARTITION BY boi.boi_bag_code + ORDER BY bo.bo_order_date DESC, bo.bo_idx DESC + ) AS rn + FROM bag_order_item boi + INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx + WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal' + ) t + WHERE t.rn = 1 + "; + foreach ($this->db->query($sql, [$lgIdx])->getResultArray() as $row) { + $code = trim((string) ($row['bag_code'] ?? '')); + if ($code === '') { + continue; + } + $map[$code] = [ + 'order_date' => (string) ($row['order_date'] ?? ''), + 'qty_sheet' => (int) ($row['qty_sheet'] ?? 0), + 'bag_name' => (string) ($row['bag_name'] ?? ''), + ]; + } + + return $map; + } + + $sql = " + SELECT boi.boi_bag_code AS bag_code, MAX(bo.bo_order_date) AS order_date + FROM bag_order_item boi + INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx + WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal' + GROUP BY boi.boi_bag_code + "; + foreach ($this->db->query($sql, [$lgIdx])->getResultArray() as $row) { + $code = trim((string) ($row['bag_code'] ?? '')); + $orderDate = (string) ($row['order_date'] ?? ''); + if ($code === '' || $orderDate === '') { + continue; + } + $item = $this->db->query(" + SELECT boi.boi_qty_sheet AS qty_sheet, boi.boi_bag_name AS bag_name + FROM bag_order_item boi + INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx + WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal' + AND boi.boi_bag_code = ? AND bo.bo_order_date = ? + ORDER BY bo.bo_idx DESC + LIMIT 1 + ", [$lgIdx, $code, $orderDate])->getRowArray(); + if ($item) { + $map[$code] = [ + 'order_date' => $orderDate, + 'qty_sheet' => (int) ($item['qty_sheet'] ?? 0), + 'bag_name' => (string) ($item['bag_name'] ?? ''), + ]; + } + } + + return $map; + } + + /** + * @param array $lastOrders + * @return array + */ + private function loadMovementsSinceOrders(int $lgIdx, array $lastOrders, string $refDate): array + { + $byCode = []; + $minDate = $refDate; + foreach ($lastOrders as $code => $info) { + $d = (string) ($info['order_date'] ?? ''); + if ($d === '') { + continue; + } + $byCode[$code] = $d; + if ($d < $minDate) { + $minDate = $d; + } + } + if ($byCode === []) { + return []; + } + + $codes = array_keys($byCode); + $placeholders = implode(',', array_fill(0, count($codes), '?')); + $params = array_merge([$lgIdx], $codes, [$minDate, $refDate]); + $out = []; + foreach ($codes as $code) { + $out[$code] = ['sale' => 0, 'recv' => 0, 'issue' => 0]; + } + + $sql = " + SELECT bs_bag_code AS bag_code, bs_sale_date AS mv_date, + SUM(CASE WHEN bs_type IN ('sale','cancel') THEN ABS(bs_qty) ELSE 0 END) AS sale_qty + FROM bag_sale + WHERE bs_lg_idx = ? AND bs_bag_code IN ({$placeholders}) + AND bs_sale_date >= ? AND bs_sale_date <= ? + GROUP BY bs_bag_code, bs_sale_date + "; + foreach ($this->db->query($sql, $params)->getResult() as $row) { + $code = (string) $row->bag_code; + $orderDate = $byCode[$code] ?? ''; + if ($orderDate === '' || (string) $row->mv_date < $orderDate) { + continue; + } + $out[$code]['sale'] += (int) $row->sale_qty; + } + + $sql = " + SELECT br_bag_code AS bag_code, br_receive_date AS mv_date, SUM(br_qty_sheet) AS recv_qty + FROM bag_receiving + WHERE br_lg_idx = ? AND br_bag_code IN ({$placeholders}) + AND br_receive_date >= ? AND br_receive_date <= ? + GROUP BY br_bag_code, br_receive_date + "; + foreach ($this->db->query($sql, $params)->getResult() as $row) { + $code = (string) $row->bag_code; + $orderDate = $byCode[$code] ?? ''; + if ($orderDate === '' || (string) $row->mv_date < $orderDate) { + continue; + } + $out[$code]['recv'] += (int) $row->recv_qty; + } + + $sql = " + SELECT bi2_bag_code AS bag_code, bi2_issue_date AS mv_date, SUM(bi2_qty) AS issue_qty + FROM bag_issue + WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_bag_code IN ({$placeholders}) + AND bi2_issue_date >= ? AND bi2_issue_date <= ? + GROUP BY bi2_bag_code, bi2_issue_date + "; + foreach ($this->db->query($sql, $params)->getResult() as $row) { + $code = (string) $row->bag_code; + $orderDate = $byCode[$code] ?? ''; + if ($orderDate === '' || (string) $row->mv_date < $orderDate) { + continue; + } + $out[$code]['issue'] += (int) $row->issue_qty; + } + + return $out; + } + + /** + * @param array $barcodeCodes + * @return array + */ + private function loadMonthlyAverageSales( + int $lgIdx, + string $refDate, + string $salesScope, + array $barcodeCodes + ): array { + $fromDate = date('Y-m-d', strtotime($refDate . ' -' . self::AVG_SALES_MONTHS . ' months')); + $legacyNet = []; + $barcodeNet = []; + + foreach ($this->db->query(" + SELECT bs_bag_code AS bag_code, + SUM(CASE WHEN bs_type = 'sale' THEN ABS(bs_qty) + WHEN bs_type IN ('return','cancel') THEN -ABS(bs_qty) + ELSE 0 END) AS net_qty + FROM bag_sale + WHERE bs_lg_idx = ? AND bs_sale_date > ? AND bs_sale_date <= ? + GROUP BY bs_bag_code + ", [$lgIdx, $fromDate, $refDate])->getResult() as $row) { + $code = (string) $row->bag_code; + $legacyNet[$code] = (float) ($row->net_qty ?? 0) / self::AVG_SALES_MONTHS; + } + + if ($this->db->tableExists('bag_sale_scan_code')) { + foreach ($this->db->query(" + SELECT bssc_bag_code AS bag_code, SUM(bssc_qty) AS net_qty + FROM bag_sale_scan_code + WHERE bssc_lg_idx = ? AND bssc_state = 'sold' + AND DATE(bssc_regdate) > ? AND DATE(bssc_regdate) <= ? + GROUP BY bssc_bag_code + ", [$lgIdx, $fromDate, $refDate])->getResult() as $row) { + $code = (string) $row->bag_code; + $barcodeNet[$code] = (float) ($row->net_qty ?? 0) / self::AVG_SALES_MONTHS; + } + } + + $merged = []; + $allCodes = array_unique(array_merge(array_keys($legacyNet), array_keys($barcodeNet))); + foreach ($allCodes as $code) { + $isBarcode = isset($barcodeCodes[$code]); + $legacy = $legacyNet[$code] ?? 0.0; + $scan = $barcodeNet[$code] ?? 0.0; + $merged[$code] = match ($salesScope) { + 'legacy' => $isBarcode ? 0.0 : $legacy, + 'barcode' => $isBarcode ? ($scan > 0 ? $scan : $legacy) : 0.0, + default => $isBarcode && $scan > 0 ? $scan : $legacy, + }; + } + + return $merged; + } + + private function scopedStock(int $qty, string $scope, bool $isBarcode): int + { + return match ($scope) { + 'legacy' => $isBarcode ? 0 : $qty, + 'barcode' => $isBarcode ? $qty : 0, + default => $qty, + }; + } + + private function scopedPending(int $qty, string $scope, bool $isBarcode): int + { + return $this->scopedStock($qty, $scope, $isBarcode); + } + + private function calcDepletionDays(int $totalStock, float $monthlyAvg): int + { + if ($monthlyAvg <= 0.0) { + return 0; + } + + return (int) round(($totalStock / $monthlyAvg) * 30); + } + + private function calcScheduleDate(string $refDate, int $depletionDays, int $leadDays): string + { + if ($depletionDays <= 0) { + return ''; + } + if ($depletionDays >= 99999) { + return ''; + } + + $offset = $depletionDays - $leadDays; + if ($offset < 0) { + return $refDate; + } + + $base = \DateTimeImmutable::createFromFormat('Y-m-d', $refDate); + if ($base === false) { + return ''; + } + + try { + $scheduled = $base->modify('+' . $offset . ' days'); + } catch (\Exception) { + return ''; + } + + $year = (int) $scheduled->format('Y'); + $refYear = (int) $base->format('Y'); + if ($year < $refYear - 1 || $year > $refYear + 120) { + return ''; + } + + return $scheduled->format('Y-m-d'); + } + + private function calcOrderQty( + string $refDate, + string $scheduleDate, + int $depletionDays, + int $leadDays, + int $monthlyAvg, + int $totalStock + ): int { + if ($monthlyAvg <= 0) { + return 0; + } + $urgent = $scheduleDate !== '' && $scheduleDate <= $refDate; + $lowStock = $depletionDays > 0 && $depletionDays <= $leadDays && $scheduleDate !== ''; + if (! $urgent && ! $lowStock) { + return 0; + } + + $target = (int) round($monthlyAvg * ($urgent ? self::URGENT_REPLENISH_MONTHS : max(2, (int) ceil($leadDays / 30.0)))); + + return max(0, $target - $totalStock); + } +} diff --git a/app/Libraries/Blockchain/SqlLedger.php b/app/Libraries/Blockchain/SqlLedger.php new file mode 100644 index 0000000..ca233ee --- /dev/null +++ b/app/Libraries/Blockchain/SqlLedger.php @@ -0,0 +1,106 @@ +ledgerModel = model(BlockchainLedgerModel::class); + } + + /** + * @param array $payload + * @return array{index:int,hash:string,previous_hash:string} + */ + public function appendBlock(string $txType, array $payload, ?string $entityUuid, int $entityVersion, ?int $actorIdx, ?int $lgIdx): array + { + $latest = $this->ledgerModel->orderBy('bl_idx', 'DESC')->first(); + // 원장이 비어 있으면 $latest 가 null — $latest->bl_hash 는 PHP 8에서 치명 오류(? 는 ?? 와 달리 속성 접근 자체가 먼저 평가됨) + $previousHash = ($latest === null || ! isset($latest->bl_hash) || (string) $latest->bl_hash === '') + ? str_repeat('0', 64) + : (string) $latest->bl_hash; + $now = date('Y-m-d H:i:s'); + + $normalizedPayload = $this->normalizeArray($payload); + $payloadJson = json_encode($normalizedPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'; + + $hashInput = implode('|', [ + $now, + $txType, + $entityUuid ?? '', + (string) $entityVersion, + $payloadJson, + $previousHash, + '0', + ]); + $currentHash = hash('sha256', $hashInput); + + $this->ledgerModel->insert([ + 'bl_created_at' => $now, + 'bl_tx_type' => $txType, + 'bl_entity_uuid' => $entityUuid, + 'bl_entity_version' => $entityVersion, + 'bl_payload' => $payloadJson, + 'bl_previous_hash' => $previousHash, + 'bl_hash' => $currentHash, + 'bl_nonce' => 0, + 'bl_actor_idx' => $actorIdx, + 'bl_lg_idx' => $lgIdx, + ]); + + return [ + 'index' => (int) $this->ledgerModel->getInsertID(), + 'hash' => $currentHash, + 'previous_hash' => $previousHash, + ]; + } + + /** + * @param array $data + * @return array + */ + private function normalizeArray(array $data): array + { + ksort($data); + foreach ($data as $key => $value) { + if (is_array($value)) { + if ($this->isAssoc($value)) { + /** @var array $assoc */ + $assoc = $value; + $data[$key] = $this->normalizeArray($assoc); + } else { + $normalizedList = []; + foreach ($value as $item) { + if (is_array($item) && $this->isAssoc($item)) { + /** @var array $assoc */ + $assoc = $item; + $normalizedList[] = $this->normalizeArray($assoc); + } else { + $normalizedList[] = $item; + } + } + $data[$key] = $normalizedList; + } + } + } + + return $data; + } + + /** + * @param array $array + */ + private function isAssoc(array $array): bool + { + if ($array === []) { + return false; + } + + return array_keys($array) !== range(0, count($array) - 1); + } +} diff --git a/app/Models/BagIssueItemCodeModel.php b/app/Models/BagIssueItemCodeModel.php new file mode 100644 index 0000000..60eb0a3 --- /dev/null +++ b/app/Models/BagIssueItemCodeModel.php @@ -0,0 +1,23 @@ +where('cd_state', 1); } - return $this->orderBy('cd_sort', 'ASC')->orderBy('cd_idx', 'ASC')->findAll(); + // 동일 정렬값일 때는 코드값 기준으로 안정적으로 정렬한다. + return $this->orderBy('cd_sort', 'ASC') + ->orderBy('cd_code', 'ASC') + ->orderBy('cd_idx', 'ASC') + ->findAll(); } /** diff --git a/app/Models/DesignatedShopModel.php b/app/Models/DesignatedShopModel.php index a43c1a6..f731e80 100644 --- a/app/Models/DesignatedShopModel.php +++ b/app/Models/DesignatedShopModel.php @@ -12,6 +12,7 @@ class DesignatedShopModel extends Model protected $useTimestamps = false; protected $allowedFields = [ 'ds_lg_idx', + 'ds_sa_idx', 'ds_mb_idx', 'ds_shop_no', 'ds_name', diff --git a/app/Models/MenuModel.php b/app/Models/MenuModel.php index 486114e..1950f8f 100644 --- a/app/Models/MenuModel.php +++ b/app/Models/MenuModel.php @@ -252,4 +252,39 @@ class MenuModel extends Model } } + /** + * 재고 관리 하위 메뉴는 "재고 현황", "실사 선별 조회"만 유지. + */ + public function pruneInventoryManagementMenus(int $mtIdx, int $lgIdx): void + { + if ($mtIdx <= 0 || $lgIdx <= 0) { + return; + } + + $parentRows = $this->where('mt_idx', $mtIdx) + ->where('lg_idx', $lgIdx) + ->where('mm_pidx', 0) + ->groupStart() + ->where('mm_name', '재고 관리') + ->orWhere('mm_name', '재고관리') + ->groupEnd() + ->findAll(); + if ($parentRows === []) { + return; + } + $parentIds = array_values(array_filter(array_map( + static fn ($row): int => (int) ($row->mm_idx ?? 0), + $parentRows + ))); + if ($parentIds === []) { + return; + } + + $this->where('mt_idx', $mtIdx) + ->where('lg_idx', $lgIdx) + ->whereIn('mm_pidx', $parentIds) + ->whereNotIn('mm_link', ['bag/inventory', 'bag/inventory/inspection-select']) + ->delete(); + } + } diff --git a/app/Models/PackagingUnitModel.php b/app/Models/PackagingUnitModel.php index ea190b1..dc7317d 100644 --- a/app/Models/PackagingUnitModel.php +++ b/app/Models/PackagingUnitModel.php @@ -16,4 +16,29 @@ class PackagingUnitModel extends Model 'pu_start_date', 'pu_end_date', 'pu_state', 'pu_regdate', 'pu_moddate', 'pu_reg_mb_idx', ]; + + /** + * 동일 봉투코드에 행이 여러 개여도 최신 등록 1건만 사용. + * + * @return array + */ + public function latestActiveMapByBagCode(int $lgIdx): array + { + $rows = $this->where('pu_lg_idx', $lgIdx) + ->where('pu_state', 1) + ->orderBy('pu_regdate', 'DESC') + ->orderBy('pu_idx', 'DESC') + ->findAll(); + + $map = []; + foreach ($rows as $row) { + $code = (string) ($row->pu_bag_code ?? ''); + if ($code === '' || isset($map[$code])) { + continue; + } + $map[$code] = $row; + } + + return $map; + } } diff --git a/app/Models/ShopOrderModel.php b/app/Models/ShopOrderModel.php index f4ebfd0..cfbd7e3 100644 --- a/app/Models/ShopOrderModel.php +++ b/app/Models/ShopOrderModel.php @@ -12,7 +12,7 @@ class ShopOrderModel extends Model protected $useTimestamps = false; protected $allowedFields = [ 'so_lg_idx', 'so_ds_idx', 'so_ds_name', 'so_order_date', 'so_delivery_date', - 'so_payment_type', 'so_paid', 'so_received', 'so_total_qty', 'so_total_amount', + 'so_payment_type', 'so_channel', 'so_paid', 'so_received', 'so_total_qty', 'so_total_amount', 'so_status', 'so_orderer_idx', 'so_regdate', ]; } diff --git a/app/Views/admin/bag_issue/index.php b/app/Views/admin/bag_issue/index.php index e57f2f7..11835bb 100644 --- a/app/Views/admin/bag_issue/index.php +++ b/app/Views/admin/bag_issue/index.php @@ -31,7 +31,6 @@ 봉투코드 봉투명 수량 - 상태 작업 @@ -47,7 +46,6 @@ bi2_bag_code) ?> bi2_bag_name) ?> bi2_qty) ?> - bi2_status) ?>
@@ -57,7 +55,7 @@ - 등록된 불출이 없습니다. + 등록된 불출이 없습니다. diff --git a/app/Views/admin/bag_order/index.php b/app/Views/admin/bag_order/index.php index 021c8c2..8907b42 100644 --- a/app/Views/admin/bag_order/index.php +++ b/app/Views/admin/bag_order/index.php @@ -21,7 +21,7 @@ return $ym; }; ?> - '봉투 발주 현황', 'printShowApproval' => false]) ?> + '봉투 발주 현황']) ?>
diff --git a/app/Views/admin/bag_price/index.php b/app/Views/admin/bag_price/index.php index e88c86a..e96b3c4 100644 --- a/app/Views/admin/bag_price/index.php +++ b/app/Views/admin/bag_price/index.php @@ -1,4 +1,4 @@ - '봉투 단가 관리', 'printShowApproval' => false]) ?> + '봉투 단가 관리']) ?> + '지정판매소 바코드', + 'printExtraLines' => [ + '구역: ' . $zoneLabel, + '출력일: ' . $printedAt, + ], +]) ?>

지정판매소 바코드

diff --git a/app/Views/admin/designated_shop/manage.php b/app/Views/admin/designated_shop/manage.php new file mode 100644 index 0000000..e20ce9f --- /dev/null +++ b/app/Views/admin/designated_shop/manage.php @@ -0,0 +1,87 @@ + + $readOnly ? '지정판매소 조회 목록' : '지정판매소 목록']) ?> +
+
+ +
+ + 엑셀저장 + + + + 지정판매소 등록 + +
+
+
+
+ + 지정판매소 검색 + + + + + + + + 초기화 + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
번호지자체판매소번호상호명대표자사업자번호가상계좌상태등록일작업
ds_idx) ?>ds_lg_idx] ?? '') ?>ds_shop_no) ?>ds_name) ?>ds_rep_name) ?>ds_biz_no) ?>ds_va_number) ?>ds_state === 1 ? '정상' : ((int) $row->ds_state === 2 ? '폐업' : '직권해지') ?>ds_regdate ?? '') ?> + 수정 +
+ + +
+
+
+
links() ?>
diff --git a/app/Views/admin/free_recipient/create.php b/app/Views/admin/free_recipient/create.php index 19028cf..e9da06d 100644 --- a/app/Views/admin/free_recipient/create.php +++ b/app/Views/admin/free_recipient/create.php @@ -1,5 +1,5 @@
- 대상자 등록 + 무료용 대상 등록
@@ -9,29 +9,19 @@
- +
-
- - -
- -
- - -
-
+ 미입력 시 계속 유효
diff --git a/app/Views/admin/free_recipient/edit.php b/app/Views/admin/free_recipient/edit.php index 7b3663c..8b0386b 100644 --- a/app/Views/admin/free_recipient/edit.php +++ b/app/Views/admin/free_recipient/edit.php @@ -1,5 +1,5 @@
- 대상자 수정 + 무료용 대상 수정
@@ -9,29 +9,19 @@
- +
-
- - -
- -
- - -
-
+ 미입력 시 계속 유효
diff --git a/app/Views/admin/free_recipient/index.php b/app/Views/admin/free_recipient/index.php index 8684b8c..dbd18f8 100644 --- a/app/Views/admin/free_recipient/index.php +++ b/app/Views/admin/free_recipient/index.php @@ -13,24 +13,36 @@ 번호 - 대상자명 - 연락처 - 주소 - 비고 - 종료일 + 동코드 + 구분 + 명칭 + 종료일자 + 비고 상태 작업 + + fr_type_code ?? ''); + $typeName = (string) (($recipientTypeOptions[$typeCode] ?? '') ?: $typeCode); + $dongCode = (string) ($row->fr_dong_code ?? ''); + $dongLabel = $dongCode !== '' ? (string) (($dongNameMap[$dongCode] ?? $dongCode) . ' (' . $dongCode . ')') : '-'; + ?> - fr_idx) ?> + + + fr_name) ?> - fr_phone) ?> - fr_addr) ?> + fr_end_date ?: '9999.99.99') ?> fr_note) ?> - fr_end_date) ?> fr_state === 1 ? '사용' : '미사용' ?> 수정 @@ -40,6 +52,7 @@ + diff --git a/app/Views/admin/layout.php b/app/Views/admin/layout.php index 6339d2c..8a0996a 100644 --- a/app/Views/admin/layout.php +++ b/app/Views/admin/layout.php @@ -63,6 +63,11 @@ tailwind.config = { } } + +
+

일계표

+

+

누계(월): · (단위: 매 / 원)

- -
-
- 당월 누계 ( ~ ) -
- +
+
- - - - + + + + + + + + + + + + + + - - - sale_qty; - $monthlySaleAmountTotal += (int) $row->sale_amount; - ?> - - - - - - + + + + + + + + + + + + + + + + + + - - + + + + - - - - - - -
봉투코드봉투명판매수량판매금액구분봉투종류일계누계(월)
수량판매금액수수료징수액수량판매금액수수료징수액
bs_bag_code) ?>bs_bag_name) ?>sale_qty) ?>sale_amount) ?>
조회된 데이터가 없습니다.
조회된 데이터가 없습니다.
합계
-
+
diff --git a/app/Views/admin/sales_report/hometax_process.php b/app/Views/admin/sales_report/hometax_process.php new file mode 100644 index 0000000..1b2566e --- /dev/null +++ b/app/Views/admin/sales_report/hometax_process.php @@ -0,0 +1,322 @@ + $headers */ +/** @var list> $displayRows */ +/** @var int $totalCount */ +/** @var float $totalSupplyAmount */ +/** @var float $totalTaxAmount */ +/** @var int $missingBizCount */ +/** @var string $lgName */ +/** @var list $printExtraLines */ +/** @var list}> $hometaxPrintPages */ +/** @var list $hometaxColMinPx */ + +helper('admin'); + +$baseParams = [ + 'start_date' => $startDate ?? '', + 'end_date' => $endDate ?? '', + 'write_date' => $writeDate ?? '', +]; +$searchParams = array_merge($baseParams, ['search' => '1']); +$exportParams = array_merge($searchParams, ['export' => '1']); + +$searchUrl = mgmt_url('reports/hometax-export?' . http_build_query($searchParams)); +$excelUrl = mgmt_url('reports/hometax-export?' . http_build_query($exportParams)); + +$totalGrand = (float) ($totalSupplyAmount ?? 0) + (float) ($totalTaxAmount ?? 0); +$colCount = max(1, count($headers ?? [])); + +/** 홈택스 28열 — 주소·상호·이메일 등 텍스트 열을 넓게 (합계 100%) */ +$hometaxColWidths = [ + '4.5%', '4%', '4%', '6%', '3%', '6.5%', '3.5%', '11%', '3%', '3%', '5.5%', + '6%', '3%', '6.5%', '3.5%', '11%', '3%', '3%', '5.5%', + '4%', '4%', '3%', '5.5%', '3%', '3%', '3.5%', '3.5%', '3.5%', +]; +$hometaxColMinPx = $hometaxColMinPx ?? []; +$hometaxPrintPages = $hometaxPrintPages ?? []; + +$hometaxWrapColIdx = [7, 15, 5, 6, 13, 14, 10, 18, 22, 27]; +$hometaxNumColIdx = [19, 20, 24, 25, 26, 27]; + +$hometaxNormalizeColWidths = static function (array $colIndices) use ($hometaxColWidths): array { + $sum = 0.0; + foreach ($colIndices as $ci) { + $sum += (float) str_replace('%', '', (string) ($hometaxColWidths[$ci] ?? '3')); + } + $normalized = []; + foreach ($colIndices as $ci) { + $pct = (float) str_replace('%', '', (string) ($hometaxColWidths[$ci] ?? '3')); + $normalized[$ci] = ($sum > 0 ? round($pct / $sum * 100, 2) : round(100 / max(1, count($colIndices)), 2)) . '%'; + } + + return $normalized; +}; + +$hometaxCellClass = static function (int $ci) use ($hometaxWrapColIdx, $hometaxNumColIdx): string { + $class = 'text-left px-1 py-1'; + if (in_array($ci, $hometaxWrapColIdx, true)) { + $class .= ' ht-wrap'; + } + if (in_array($ci, $hometaxNumColIdx, true)) { + $class .= ' ht-num'; + } + + return $class; +}; + +/** + * @param list $colIndices + */ +$hometaxRenderTable = static function ( + array $colIndices, + string $tableExtraClass, + string $tableId, + bool $forPrint +) use ( + $headers, + $displayRows, + $searched, + $colCount, + $hometaxColWidths, + $hometaxColMinPx, + $hometaxCellClass, + $hometaxNormalizeColWidths +): void { + $sliceCount = count($colIndices); + $widthsForSet = $hometaxNormalizeColWidths($colIndices); + ?> + + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
조회를 건너뛴 상태입니다. 조회를 눌러 주세요.
조회된 판매 내역이 없습니다.
+ + '홈택스 처리', + 'printExtraLines' => $printExtraLines ?? [], +]) ?> + +
+
+

홈택스 처리

+
+ + 엑셀저장 +
+
+ +
+ +
+ +
+ + ~ + +
+
+
+ + +
+
+ +
+
+
+ +
+ +
조회결과
+
+ +
+ + + +
+
총 건수
+
총 금액 (공급가액+세액)
+
사업자등록번호 없음
+
+ +
+ 인쇄·엑셀저장은 동일하게 2쪽 열 구성입니다(1쪽: 공급자·공급받는자, 2쪽: 금액·품목). 요약·결재란은 인쇄용 헤더에 포함됩니다. +
+
+ + + + diff --git a/app/Views/admin/sales_report/lot_flow.php b/app/Views/admin/sales_report/lot_flow.php index 6b1065a..398b1a3 100644 --- a/app/Views/admin/sales_report/lot_flow.php +++ b/app/Views/admin/sales_report/lot_flow.php @@ -1,99 +1,216 @@ - 'LOT 수불 조회']) ?> -
+ + 'LOT 수불 조회', + 'printExtraLines' => $printExtra, +]) ?> + +
LOT 수불 조회 - -
-
-
-
- - - -
-
- - - -
-

발주 정보

-
-
LOT번호: bo_lot_no) ?>
-
발주일: bo_order_date) ?>
-
상태: - '정상', 'cancelled' => '취소', 'deleted' => '삭제']; ?> - bo_status] ?? $order->bo_status) ?> +
+ + 종료 +
+
+
+ +
+
+ +
+ + + (바코드 스캔 = 번호 직접 입력) +
+ +
+

+ 팩·박스·낱장 바코드 또는 LOT 번호(보조: lot_no 파라미터)로 조회합니다. +

+
+ + +
+ +
+ + + +
+ 봉투번호(바코드)를 입력한 뒤 조회를 눌러 주세요. +
+ + +
+
+ [개발용 임시] 등록·조회 가능 봉투번호 샘플 + 행 클릭 → 봉투번호 입력 후 조회 · 현재 지자체 DB 기준 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
구분봉투번호(입력값)품목LOT상태비고
+ bag_receiving_pack_code 데이터가 없습니다. 입고 처리 후 표시됩니다. +
+ +
+
+
+ +
+ +
+

봉투 정보

+ +
+
품목
+
+ +
코드
+
+ + +
LOT
+
+ + +
조회단위
+
+ +
+
+
+
BOX
+
0 ? number_format($qtyBox) : '—' ?>
+
+
+
PACK
+
0 ? number_format($qtyPack) : '—' ?>
+
+
+
낱장
+
0 ? number_format($qtySheet) : '—' ?>
+
+
+ +

조회 결과 없음

+ +

조회 후 표시

+ +
+ + +
+
+ LOT 수불 현황 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
일자입출고처구분
수불 이력이 없습니다.
봉투번호 입력 후 조회
-
등록일: bo_regdate) ?>
- -

발주 품목

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

입고 내역

-
- - - - - - - - - - - - - - - - - - - - - - - - - - -
입고일봉투코드봉투명입고수량(박스)입고수량(매)납품자
br_receive_date) ?>br_bag_code) ?>br_bag_name) ?>br_qty_box) ?>br_qty_sheet) ?>br_sender_name ?? '') ?>
입고 내역이 없습니다.
-
- - -
해당 LOT 번호의 발주를 찾을 수 없습니다.
- -
LOT 번호를 입력하고 조회해 주세요.
- + diff --git a/app/Views/admin/sales_report/misc_flow.php b/app/Views/admin/sales_report/misc_flow.php index d65a488..29d0aa6 100644 --- a/app/Views/admin/sales_report/misc_flow.php +++ b/app/Views/admin/sales_report/misc_flow.php @@ -1,84 +1,369 @@ '기타 입출고']) ?> -
+bmf_qty ?? 0); +} + +$registerDate = $selectedGroup ? (string) ($selectedGroup['date'] ?? date('Y-m-d')) : date('Y-m-d'); +$registerType = $selectedGroup ? (string) ($selectedGroup['type'] ?? 'in') : 'in'; +$registerReason = $selectedGroup ? (string) ($selectedGroup['reason'] ?? '') : ''; +?> + +
기타 입출고 - +
+ + 종료 +
- -
+ +
bag_misc_flow 테이블이 생성되지 않았습니다. writable/database/bag_misc_flow_tables.sql을 실행해 주세요.
+ +
+ 선택한 지자체에 등록된 기타 입출고 데이터가 없습니다. 아래 품목 등록으로 첫 건을 넣으면 좌측 리스트에 표시됩니다. +
+ +
+ 조회 조건(수불 년월·봉투코드·구분 등)에 맞는 내역이 없습니다. 수불 년월을 「전체」로 두거나 조건을 넓혀 다시 조회해 주세요. +
- +getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ +getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
-
- - - + + = $dateYearMin; $yy--): ?> + + - - > + + + + + + + + + + + + + + - - - - - - - + + + 초기화
- -
-
- - - - - -
-
+
+ +
+
입출고 리스트
+
+ + + + + + + + + + + + + $key]); + ?> + + + + + + + + + + + +
수불일자수량구분메모
+ + 입고 + + 출고 + +
+ + 등록된 기타 입출고가 없습니다. + + 선택한 기간·조건에 해당하는 내역이 없습니다. + + 조회 결과가 없습니다. + +
+
+
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
번호구분일자봉투코드봉투명수량사유등록일
bmf_idx ?>bmf_type === 'in' ? '입고' : '출고' ?>bmf_date) ?>bmf_bag_code) ?>bmf_bag_name) ?>bmf_qty) ?>bmf_reason) ?>bmf_regdate) ?>
조회된 데이터가 없습니다.
+ +
+
+ + + + + + + +
+ + + 취소 +
+
+ + +
+
입출고 일자
+
+ +
+ 수불 일자 + +
+
+ 선택 + +
+
+ 분류 + + + +
+
+ 비고 +
+
+ +

좌측 입출고 리스트에서 건을 선택하거나, 아래에서 신규 등록해 주세요.

+ +
+
+ + +
+
입출고 봉투 코드
+
+ + + + + + + + + + + + + $line): ?> + bmf_bag_code ?? ''); + $pu = $packagingMap[$code] ?? null; + $unitLabel = '매'; + if ($pu && (int) ($pu->pu_pack_per_sheet ?? 0) > 0) { + $unitLabel = '매'; + } + ?> + + + + + + + + + + + + + + + + + + + +
No봉투 코드봉투 종류수량단위
bmf_bag_name ?? '')) ?>bmf_qty ?? 0)) ?>
봉투 코드 내역이 없습니다.
합계
+
+
+ + + +
+
품목 등록
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

동일 수불일자·입출고·비고로 등록한 품목은 좌측 리스트에서 한 건으로 묶여 표시됩니다.

+
+ +
+ + diff --git a/app/Views/admin/sales_report/period_sales.php b/app/Views/admin/sales_report/period_sales.php index 4a09c41..8ea76de 100644 --- a/app/Views/admin/sales_report/period_sales.php +++ b/app/Views/admin/sales_report/period_sales.php @@ -1,73 +1,184 @@ - '기간별 판매현황']) ?> -
+> $lines */ +/** @var string $startDate */ +/** @var string $endDate */ +/** @var int $saIdx */ +/** @var string $catFilter */ +/** @var string $mode */ +/** @var list $agencies */ +/** @var array $catLabels */ +/** @var bool $hasBsFee */ +/** @var string $lgName */ +/** @var string $agencyLabel */ +/** @var string $catLabelFilter */ +/** @var list $printExtraLines */ + +$byDaily = ($mode ?? 'daily') === 'daily'; + +$exportParams = array_filter([ + 'start_date' => $startDate ?? '', + 'end_date' => $endDate ?? '', + 'sa_idx' => (int) ($saIdx ?? 0), + 'cat' => (string) ($catFilter ?? ''), + 'mode' => $byDaily ? '' : 'period', + 'export' => '1', +], static fn ($v): bool => $v !== '' && $v !== null); +$excelUrl = mgmt_url('reports/period-sales?' . http_build_query($exportParams)); + +$fmtFee = static function (float $v) use ($hasBsFee): string { + if (! $hasBsFee) { + return '—'; + } + + return number_format((int) round($v)); +}; + +$rowClass = static function (string $kind): string { + return match ($kind) { + 'day_sub_all' => 'bg-gray-100 font-semibold', + 'day_sub_bag' => 'bg-sky-50 font-semibold text-sky-900', + 'day_sub_fs' => 'bg-violet-50 font-semibold text-violet-900', + 'foot_all' => 'bg-red-50 font-bold text-red-700', + 'foot_bag' => 'bg-blue-50 font-bold text-blue-700', + 'foot_fs' => 'bg-purple-50 font-bold text-purple-800', + default => '', + }; +}; +?> + '기간별 판매현황', + 'printExtraLines' => $printExtraLines ?? [], +]) ?> + +
기간별 판매현황 - +
+ + 엑셀저장 +
-
-
- - - - - + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
-
- - - - - - - - - - - - - - - - - sale_qty; - $grandSaleAmount += (int) $row->sale_amount; - $grandReturnQty += (int) $row->return_qty; - $grandReturnAmount += (int) $row->return_amount; - ?> - - - - - - - - - - - - - - - - - - - - - - - - - - -
봉투코드봉투명판매수량판매금액반품수량반품금액합계수량합계금액
bs_bag_code) ?>bs_bag_name) ?>sale_qty) ?>sale_amount) ?>return_qty) ?>return_amount) ?>sale_qty - (int) $row->return_qty) ?>sale_amount - (int) $row->return_amount) ?>
조회된 데이터가 없습니다.
합계
-
+
+ +
+

기간별 판매현황

+

+

집계: · (단위: 매 / 원)

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0): ?> + + + + + + + + + + + + + + + + + + + + + + + + +
일자품목판매반품
수량판매금액수수료징수액수량금액수량판매금액수수료징수액
조회된 데이터가 없습니다.
+
+
diff --git a/app/Views/admin/sales_report/returns.php b/app/Views/admin/sales_report/returns.php index 93de0f5..27b91d2 100644 --- a/app/Views/admin/sales_report/returns.php +++ b/app/Views/admin/sales_report/returns.php @@ -1,59 +1,144 @@ - '반품/파기 현황']) ?> -
+ '반품', + 'cancel' => '파기', + default => $bsType, + }; +}; + +$kindLabel = static function (object $row): string { + $name = trim((string) ($row->bs_bag_name ?? '')); + if ($name !== '') { + return $name; + } + $code = trim((string) ($row->bs_bag_code ?? '')); + + return $code !== '' ? $code : '-'; +}; + +$totalQty = 0; +foreach ($result as $row) { + $totalQty += (int) ($row->qty ?? 0); +} +?> + '반품 / 파기 현황', + 'printExtraLines' => $printExtraLines, +]) ?> + +
반품/파기 현황 - +
+ + 엑셀저장 + + 엑셀저장 + + + 종료 +
-
-
- - - - - + +
+ + + +
+ + + ~ + +
+ +
+ 입출고 구분 + + +
+ + +

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

-
- + + +
+ 조회기간과 입출고 구분을 선택한 뒤 조회 버튼을 눌러 주세요. +
+ + +
+
- - - - - - - + + + + + - - '반품', 'cancel' => '취소/파기']; - foreach ($result as $row): - $totalQty += (int) $row->qty; - $totalAmt += (int) $row->amount; - ?> + + - - - - - - - + + + + + + + + + + - - - + - - - + + +
일자판매소봉투코드봉투명구분수량금액일자반품처종류수량구분
bs_sale_date) ?>bs_ds_name) ?>bs_bag_code) ?>bs_bag_name) ?>bs_type] ?? $row->bs_type) ?>qty) ?>amount) ?>해당 자료가 없습니다.
bs_sale_date) ?>bs_ds_name ?? '')) ?>qty ?? 0)) ?>bs_type ?? ''))) ?>
조회된 데이터가 없습니다.
합계합계
+ + diff --git a/app/Views/admin/sales_report/sales_ledger.php b/app/Views/admin/sales_report/sales_ledger.php index 6918c5a..58e99d7 100644 --- a/app/Views/admin/sales_report/sales_ledger.php +++ b/app/Views/admin/sales_report/sales_ledger.php @@ -1,97 +1,258 @@ - '판매 대장']) ?> -
+ $shops */ +/** @var list $agencies */ +/** @var list> $ledgerRows */ +/** @var int $saleLineCount */ +/** @var string $startDate */ +/** @var string $endDate */ +/** @var string $mode */ +/** @var int $dsIdx */ +/** @var int $saIdx */ +/** @var list $cats */ +/** @var string $lgName */ +/** @var string $filterAgencyLabel */ +/** @var list $printSubtitleLines */ + +$printTitle = ($mode ?? 'daily') === 'daily' ? '[지정판매소] 일자별 판매대장' : '[지정판매소] 기간별 판매대장'; +$printDate = date('Y-m-d'); +$printExtraLines = $printSubtitleLines ?? []; +$catKeys = ['general', 'food', 'sticker', 'reuse', 'apt', 'public_use', 'container', 'waste']; +$catLabels = [ + 'general' => '일반용', + 'food' => '음식물', + 'sticker' => '스티커', + 'reuse' => '재사용', + 'apt' => '공동주택용', + 'public_use' => '공공용', + 'container' => '용기', + 'waste' => '폐기물', +]; + +$exportParams = [ + 'start_date' => $startDate, + 'end_date' => $endDate, + 'mode' => $mode, + 'ds_idx' => $dsIdx, + 'sa_idx' => $saIdx ?? 0, + 'export' => '1', +]; +if ($cats !== []) { + $exportParams['cat'] = $cats; +} +$excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams)); +?> + $printTitle, + 'printDate' => $printDate, + 'printExtraLines' => $printExtraLines, +]) ?> + +
- 판매 대장 - + 지정 판매소 판매 대장 +
+ + 엑셀저장 +
-
-
- - - - - - - + +
+ +
+
+ +
+ + ~ + +
+
+
+ + +
+
+ + +
+
+ 집계 방식 +
+ + +
+
+
+
+ 품목 +
+ + + + +
+
+
+ +
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
판매일판매소봉투코드봉투명구분수량금액
bs_sale_date) ?>bs_ds_name) ?>bs_bag_code) ?>bs_bag_name) ?> - '판매', 'return' => '반품']; - echo esc($typeMap[$row->bs_type] ?? $row->bs_type); - ?> - total_qty) ?>total_amount) ?>
조회된 데이터가 없습니다.
-
+
+ +
+

+

+

(단위: 매 / 원) · ~

+
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
판매소봉투코드봉투명판매수량판매금액반품수량반품금액계(수량)계(금액)
bs_ds_name) ?>bs_bag_code) ?>bs_bag_name) ?>sale_qty) ?>sale_amount) ?>return_qty) ?>return_amount) ?>sale_qty - (int) $row->return_qty) ?>sale_amount - (int) $row->return_amount) ?>
조회된 데이터가 없습니다.
-
- +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
일자지정번호판매소명대표자소재지품명판매량판매금액수수료총액
조회된 판매 데이터가 없습니다.
+
+

판매건수(상세 행):

+
+ + diff --git a/app/Views/admin/sales_report/shop_sales.php b/app/Views/admin/sales_report/shop_sales.php index da3fea7..d57e049 100644 --- a/app/Views/admin/sales_report/shop_sales.php +++ b/app/Views/admin/sales_report/shop_sales.php @@ -1,64 +1,227 @@ - '지정판매소별 판매현황']) ?> -
+ $zoneOptions */ +/** @var array $bagOptions */ +/** @var array $catLabels */ +/** @var list> $reportRows */ +/** @var list $grandMonths */ +/** @var float $grandTotal */ +/** @var string $lgName */ +/** @var string $zoneLabel */ +/** @var string $bagLabel */ +/** @var string $catLabelFilter */ +/** @var string $metricLabel */ +/** @var list $printExtraLines */ + +$isAmt = ($metric ?? 'qty') === 'amt'; +$fmtVal = static function (float $v) use ($isAmt): string { + return number_format((int) round($v)); +}; + +$exportParams = array_merge([ + 'start_date' => $startDate ?? '', + 'end_date' => $endDate ?? '', + 'metric' => ($metric ?? 'qty') === 'amt' ? 'amt' : 'qty', + 'export' => '1', +], array_filter([ + 'zone_code' => (string) ($zoneCode ?? ''), + 'bag_code' => (string) ($bagCode ?? ''), + 'cat' => (string) ($catFilter ?? ''), +], static fn ($v): bool => $v !== '' && $v !== null)); + +$excelUrl = mgmt_url('reports/shop-sales?' . http_build_query($exportParams)); +$colCount = 16; +?> + '지정 판매소별 판매현황', + 'printExtraLines' => $printExtraLines ?? [], +]) ?> + +
- 지정판매소별 판매현황 - + 지정 판매소별 판매현황 +
+ + 엑셀저장 +
+

+ + 금액은 조회기간 내 판매(sale) 건의 판매금액을 거래 월별로 합산합니다(반품·취소는 제외). + + 수량은 반품·판매취소를 연초부터 판매와 품목별 선입선출로 맞추고, 반품취소는 원복합니다. 조회에 포함되지 않은 달의 수치는 조회 구간의 첫 달에 합쳐 집계됩니다. + +

-
-
- - - - - + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ 집계 대상 + + +
+
-
- - - - - - - - - - - - - - sale_qty; - $totSaleAmt += (int) $row->sale_amount; - $totRetQty += (int) $row->return_qty; - $totRetAmt += (int) $row->return_amount; - ?> - - - - - - - - - - - - - - - - - - - - - - - - -
판매소명판매수량판매금액반품수량반품금액순판매수량순판매금액
bs_ds_name) ?>sale_qty) ?>sale_amount) ?>return_qty) ?>return_amount) ?>sale_qty - (int) $row->return_qty) ?>sale_amount - (int) $row->return_amount) ?>
조회된 데이터가 없습니다.
합계
-
+ +
+ +
+

지정 판매소별 판매현황

+

+

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
지정판매소대표자명주소합계
조회된 데이터가 없습니다.
전체 합계
+
+
+ diff --git a/app/Views/admin/sales_report/supply_demand.php b/app/Views/admin/sales_report/supply_demand.php index ee0d314..06ae667 100644 --- a/app/Views/admin/sales_report/supply_demand.php +++ b/app/Views/admin/sales_report/supply_demand.php @@ -1,134 +1,231 @@ - '봉투 수불 현황']) ?> -
+ + '쓰레기봉투 수급 계획', + 'printExtraLines' => $printExtraLines, +]) ?> + +
- 봉투 수불 현황 - + 쓰레기봉투 수급 계획 +
+ + 종료 +
-
-
- - - - - + +
+ + + +
+ + + +
+ +
+ + + ※ 제작기일 일 기준으로 발주예정일 산정 +
+ +
+ 현재고 선택 옵션 + 'ALL', 'legacy' => '기존 봉투', 'barcode' => '바코드 봉투'] as $val => $lab): ?> + + +
+ +
+ 월 평균판매량 선택 옵션 + 'ALL', 'legacy' => '기존 봉투', 'barcode' => '바코드 봉투'] as $val => $lab): ?> + + +
+ + +

+ 기존 봉투 = 입고 팩 바코드 미등록 품목(수기 재고), + 바코드 봉투 = bag_receiving_pack_code 등록 품목. + 월판매량은 최근 12개월 순판매(또는 바코드 판매 스캔)의 월평균입니다. + 소진일수 = (총재고÷월판매량)×30, 발주예정일 = 기준일+소진일수−보유일수, 과거일은 빨간색. +

-
- -
-
- 현재 재고 -
- - - - - - - - - - - - - - - - - - - - -
봉투코드봉투명재고수량
bi_bag_code) ?>bi_bag_name) ?>bi_qty) ?>
데이터가 없습니다.
-
- - -
-
- 기간 입고 -
- - - - - - - - - - - - - - - - - - - - -
봉투코드봉투명입고수량
br_bag_code) ?>br_bag_name) ?>recv_qty) ?>
데이터가 없습니다.
-
- - -
-
- 기간 판매 -
- - - - - - - - - - - - - - - - - - - - - - - - -
봉투코드봉투명판매수량반품수량순판매
bs_bag_code) ?>bs_bag_name) ?>sale_qty) ?>return_qty) ?>sale_qty - (int) $row->return_qty) ?>
데이터가 없습니다.
-
- - -
-
- 기간 불출 -
- - - - - - - - - - - - - - - - - - - - -
봉투코드봉투명불출수량
bi2_bag_code) ?>bi2_bag_name) ?>issue_qty) ?>
데이터가 없습니다.
-
+ +
+ 기준일·옵션을 선택한 뒤 조회를 눌러 주세요.
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + = 1990 && $y <= 2200) { + $schedDisplay = $m[1] . '.' . $m[2] . '.' . $m[3]; + } + } + ?> + + + + + + + + + + + + + + + +
최근 발주 내역현재고 및 예상 판매일수추가발주 예정내역
발주일자봉투종류발주량발주시재고현재고입고예정량총재고월판매량소진일수(일)발주예정일발주수량
표시할 품목이 없습니다.
0 ? number_format((int) $row['last_order_qty']) : '—' ?> 0 ? number_format((int) $row['stock_at_order']) : '—' ?> + +
+
+
+ + diff --git a/app/Views/admin/sales_report/yearly_sales.php b/app/Views/admin/sales_report/yearly_sales.php index 09e900f..9e6ba9c 100644 --- a/app/Views/admin/sales_report/yearly_sales.php +++ b/app/Views/admin/sales_report/yearly_sales.php @@ -1,62 +1,232 @@ - '년 판매 현황']) ?> -
+ $agencies */ +/** @var list $gugunOptions */ +/** @var list $colSpec */ +/** @var list>}> $itemBlocks */ +/** @var array{name: string, lines: list>} $footerBlock */ +/** @var bool $hasBsFee */ +/** @var string $lgName */ +/** @var string $gugunLabel */ +/** @var string $agencyLabel */ +/** @var list $printExtraLines */ +/** @var bool $hasYearlyData */ + +$yMax = (int) date('Y') + 1; +$yMin = 2020; + +$exportParams = array_merge([ + 'year' => (string) ($year ?? date('Y')), + 'export' => '1', +], array_filter([ + 'gugun_code' => (string) ($gugunCode ?? ''), + 'sa_idx' => (int) ($saIdx ?? 0) > 0 ? (string) (int) ($saIdx ?? 0) : '', +], static fn ($v): bool => $v !== '' && $v !== null && $v !== 0)); +$excelUrl = mgmt_url('reports/yearly-sales?' . http_build_query($exportParams)); + +$colCount = 2 + count($colSpec ?? []); +$nMetricCols = max(1, count($colSpec ?? [])); +$metricColPct = round(86 / $nMetricCols, 4); + +$fmtMeasureCell = static function (array $cell, string $measureKey, bool $hasBsFee): string { + if ($measureKey === 'fee' && ! $hasBsFee) { + return '—'; + } + if ($measureKey === 'qty') { + return number_format((int) ($cell['qty'] ?? 0)); + } + + return number_format((int) round((float) ($cell[$measureKey] ?? 0))); +}; +?> + ((int) ($year ?? date('Y'))) . '년 판매 현황', + 'printExtraLines' => $printExtraLines ?? [], +]) ?> + +
- 년 판매 현황 (월별) - + 년 판매 현황 +
+ + 엑셀저장 +
-
-
- - - + +
+ +
+ + +
+
+ + +
+
+ + +
+
-
- - - - - - - - - - - - - - - - $key; - $grandTotal[$m] += $val; - ?> - - - total; ?> - - - - - - - - - - - - - - - -
봉투코드봉투명1월2월3월4월5월6월7월8월9월10월11월12월합계
bs_bag_code) ?>bs_bag_name) ?> 0 ? number_format($val) : '-' ?>total) ?>
조회된 데이터가 없습니다.
합계 0 ? number_format($grandTotal[$m]) : '-' ?>
-
+ +
+ +
+

년 판매 현황

+

+

(단위: 매 / 원)

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + $li): ?> + + + + + + + + + 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]); ?> + + + + + + + + $li): ?> + + + + + + + + + 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]); ?> + + + + + + +
품목구분
조회된 데이터가 없습니다.
+
+
+ diff --git a/app/Views/admin/shop_order/create.php b/app/Views/admin/shop_order/create.php index a40a3f4..5a1f1d3 100644 --- a/app/Views/admin/shop_order/create.php +++ b/app/Views/admin/shop_order/create.php @@ -1,74 +1,301 @@
주문 접수
-
+
- -
- - +
+
+
+ + + + + + + +
+
+ + +
+
+
+
지정판매소 정보
+ + + + + + + +
판매소 코드-
상호-
대표자명-
연락처-
주소-
가상계좌-
+
+
+
+ + + 배달일 기본값은 접수일 다음날입니다.
-
-
- +
-
- +
+ + +
- +
- - - + + + + + + + + + - + - - + + - + + + + + + + + + + + + + + +
순번봉투수량순번품목1박스(낱장/판매가)1팩(낱장/판매가)단가주문수량금액포장(박스/팩/낱장)행삭제
- - - + 0 / 00 / 00박스=0, 팩=0, 낱장=0 +
합계00박스=0, 팩=0, 낱장=0
-
- + 취소
+ + diff --git a/app/Views/admin/shop_order/index.php b/app/Views/admin/shop_order/index.php index 729b517..c1f651a 100644 --- a/app/Views/admin/shop_order/index.php +++ b/app/Views/admin/shop_order/index.php @@ -26,6 +26,7 @@ 판매소 접수일 배달일 + 접수채널 결제 입금 수령 @@ -42,6 +43,12 @@ so_ds_name) ?> so_order_date) ?> so_delivery_date) ?> + + '전화', 'web' => '웹', 'app' => '앱', 'counter' => '창구']; + echo esc($channelMap[$row->so_channel ?? ''] ?? ($row->so_channel ?? '전화')); + ?> + so_payment_type) ?> - 등록된 주문이 없습니다. + 등록된 주문이 없습니다. diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php index 5342776..79456ce 100644 --- a/app/Views/auth/login.php +++ b/app/Views/auth/login.php @@ -5,6 +5,7 @@ 로그인 - 종량제 시스템 + + + + diff --git a/app/Views/bag/daily_inventory.php b/app/Views/bag/daily_inventory.php index eb02549..bb9e4fe 100644 --- a/app/Views/bag/daily_inventory.php +++ b/app/Views/bag/daily_inventory.php @@ -17,6 +17,7 @@ $userNav = session_user_nav_display(); 종량제 시스템 + diff --git a/app/Views/bag/dashboard_blend_lite_inner.php b/app/Views/bag/dashboard_blend_lite_inner.php new file mode 100644 index 0000000..aabbf3d --- /dev/null +++ b/app/Views/bag/dashboard_blend_lite_inner.php @@ -0,0 +1,332 @@ +get('mb_name') ?? '담당자'; +$dashHome = base_url('dashboard'); +$dashBlend = base_url('dashboard/blend'); +$dashLite = base_url('dashboard/lite'); + +// KPI: 회원승인/지정판매소 등록을 제외한 6칸. +$kpiTop = [ + ['icon' => 'fa-triangle-exclamation', 'c' => 'text-amber-700', 'bg' => 'bg-amber-50', 'v' => '3', 'l' => '재고부족', 'sub' => '품목'], + ['icon' => 'fa-cart-shopping', 'c' => 'text-sky-700', 'bg' => 'bg-sky-50', 'v' => '12', 'l' => '구매신청', 'sub' => '미처리'], + ['icon' => 'fa-truck', 'c' => 'text-emerald-700', 'bg' => 'bg-emerald-50', 'v' => '8', 'l' => '발주·입고', 'sub' => '금주'], + ['icon' => 'fa-boxes-stacked', 'c' => 'text-slate-700', 'bg' => 'bg-slate-100', 'v' => '48.2k', 'l' => '봉투재고', 'sub' => '장 합계'], + ['icon' => 'fa-file-invoice', 'c' => 'text-orange-700', 'bg' => 'bg-orange-50', 'v' => '6', 'l' => '세금계산서', 'sub' => '발행대기'], + ['icon' => 'fa-headset', 'c' => 'text-cyan-700', 'bg' => 'bg-cyan-50', 'v' => '2', 'l' => '민원·문의', 'sub' => '오늘'], +]; + +$stockRows = [ + ['일반 5L', '12,400', '안전', '3.2주'], + ['일반 10L', '8,200', '주의', '1.8주'], + ['일반 20L', '2,100', '부족', '0.6주'], + ['음식물 스티커', '15,000', '안전', '5.1주'], + ['재사용봉투', '4,300', '안전', '2.4주'], + ['특수규격 A', '890', '부족', '0.3주'], +]; + +$orderRows = [ + ['PO-2025-0218', '○○상사', '일반 5L×2박스', '발주확인', '02-26 10:20'], + ['PO-2025-0217', '△△유통', '스티커 500매', '납품중', '02-26 09:05'], + ['PO-2025-0216', '□□종량제', '20L 혼합', '입고완료', '02-25 16:40'], + ['REQ-8841', '행복마트 북구점', '5L 2,000장', '접수', '02-26 09:12'], + ['REQ-8839', '○○슈퍼', '스티커 500', '처리중', '02-26 08:45'], +]; + +$notices = [ + '2월 말 정기 재고 실사 안내 — 2/28 17:00 마감', + '봉투 단가 조정 예고 — 3/1 적용 예정 (안내문 배포 완료)', +]; +?> + + + + +
+
+ + 업무 현황 · 라이트 + · 핵심 KPI·표 + 그래프 3종 + +
+ + | + · + +
+
+ +
+
+ +
+ + +
+ +
+ +
+ +
+
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+

품목별 재고·소진예상

+ 상세 +
+
+ + + + + + + + + + + + + + + + + + + +
품목재고(장)상태소진
+ 'bg-emerald-100 text-emerald-800', + '주의' => 'bg-amber-100 text-amber-800', + '부족' => 'bg-red-100 text-red-800', + default => 'bg-gray-100 text-gray-700', + }; + ?> + +
+
+
+ +
+
+

발주 / 구매신청 진행

+ 최근 5건 +
+
+ + + + + + + + + + + + + + + + + + + + + +
문서상대내용단계시각
+
+
+
+ +
+

월별 출고 vs 구매신청 건수 (최근 12개월)

+
+
+ +
+
+

품목별 재고 (천 장)

+
+
+
+

판매소별 월 출고 TOP

+
+
+
+ +

+ 메인 /dashboard + · /dashboard/blend + · /dashboard/lite (현재) +

+
+ + +
diff --git a/app/Views/bag/designated_shop_return.php b/app/Views/bag/designated_shop_return.php new file mode 100644 index 0000000..191d6c5 --- /dev/null +++ b/app/Views/bag/designated_shop_return.php @@ -0,0 +1,333 @@ + + +
+ 지정판매소 판매 취소 +
+ +
+
+ + + +
+ + +
+ + + 과거 일자는 조회만 가능합니다. (취소 불가) + +
+ +
+
+
판매 리스트
+
+ + + + + + + + + + $s): ?> + + + + + + + + + +
번호지정판매소
조회된 판매 데이터가 없습니다.
+
+
+ +
+
판매 취소 리스트
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
품목단가판매액취소취소액
판매소를 선택해 주세요.
판매 합계0-0
총 판매액 - 총 취소액0
+
+
+
+ +
+
판매 취소 봉투 코드
+
+ + + + + + + + + + + + + +
봉투 종류봉투 코드수량단가금액
판매 취소 리스트에서 취소할 품목(취소 컬럼)을 선택해 주세요.
+
+
+ +
+

+ [개발용 임시 표] + 지정판매소 판매 취소 화면 테스트를 위해, 위에서 선택한 판매일자()에 + bssc_regdate가 해당 일자이고 상태가 판매(sold)인 스캔 코드를 표시합니다(최대 500건). + 운영에는 불필요하므로 개발이 끝나면 이 블록 전체를 제거해 주세요. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
지정판매소주문봉투 종류봉투 바코드포장수량상태등록일시
해당 판매일자에 판매(sold) 스캔 코드가 없습니다.
+
+
+
+ + + + diff --git a/app/Views/bag/designated_shop_return_cancel.php b/app/Views/bag/designated_shop_return_cancel.php new file mode 100644 index 0000000..2a52801 --- /dev/null +++ b/app/Views/bag/designated_shop_return_cancel.php @@ -0,0 +1,260 @@ + + +
+ 지정판매소 반품 취소 +
+ +
+
+ + + +
+ + +
+ + +
+ +
+
+
지정판매소 리스트
+
+ + + + + + + + + + + + + + + + + + + + + +
지정판매소 상호명대표자명반품일자
조회된 반품 데이터가 없습니다.
+
+
+ +
+
반품 리스트
+
+ + + + + + + + + + + + + + + + + + + + + +
품목단가판매액취소취소액
지정판매소를 선택해 주세요.
판매 합계0-0
+
+
+
+ +
+
반품 취소 대상 봉투 코드
+
+ + + + + + + + + + + + + +
봉투 종류봉투코드수량단가금액
반품 리스트에서 취소할 품목(취소 컬럼)을 선택해 주세요.
+
+
+
+ + + + diff --git a/app/Views/bag/designated_shop_sale.php b/app/Views/bag/designated_shop_sale.php new file mode 100644 index 0000000..9f92e74 --- /dev/null +++ b/app/Views/bag/designated_shop_sale.php @@ -0,0 +1,520 @@ +
+ 지정판매소 판매 +
+ +
+
+ + + + + +
+
+ + + + +
+
+ + +
+
+ +
+
+ +
+
+ +
+ +
+
+
지정판매소 정보
+ + + + + + +
판매소 코드-
상호-
대표자명-
대표전화-
주소-
+
+ +
+
주문 접수 리스트
+
+ + + + + + + + + + + +
번호판매소접수일배달일상태
+
+
+
+ + + +
+
+
판매 내역
+
+ + + + + + + + + + + + + + +
선택봉투 종류접수량판매량단가판매금액
주문을 선택해 주세요.
+
+
+ +
+
판매 상세 내역
+
+ + + + + + + + + + + + +
봉투 종류봉투 코드수량포장단위
바코드를 스캔해 주세요.
+
+
+
+
+ + + + diff --git a/app/Views/bag/designated_shop_sale_return.php b/app/Views/bag/designated_shop_sale_return.php new file mode 100644 index 0000000..4155537 --- /dev/null +++ b/app/Views/bag/designated_shop_sale_return.php @@ -0,0 +1,424 @@ + +
+ 지정판매소 반품 +
+ +
+
+ + + + +
+
+ + + +
+
+ + +
+ +
+
+ +
+ +
+
+
지정판매소 정보
+ + + + + + +
판매소 코드-
상호-
대표자명-
대표전화-
주소-
+
+ +
+
반품 리스트
+
+ + + + + + + + + + + + + + + + + + + + + + + +
선택봉투 종류수량단가금액제거
판매소를 선택하고 바코드를 스캔해 주세요.
합계0-0
+
+
+
+ +
+
반품 봉투 코드
+
+ + + + + + + + + + + + +
봉투 종류봉투 코드수량금액
품목을 선택해 주세요.
+
+
+ +
+

+ [개발용 임시 표] + 반품 스캔 테스트를 위해, 현재 지자체에서 bag_sale_scan_code 상태가 + 판매(sold)인 봉투 바코드 일부(최대 200건, 최근 등록순)를 표시합니다. + 운영 화면에는 포함되지 않아야 하므로 개발이 끝나면 이 블록 전체를 제거해 주세요. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
지정판매소주문봉투 종류봉투 바코드포장수량상태등록일시
판매(sold) 상태인 스캔 코드가 없습니다.
+
+
+
+ + + + diff --git a/app/Views/bag/flow.php b/app/Views/bag/flow.php index 9c24425..18b8248 100644 --- a/app/Views/bag/flow.php +++ b/app/Views/bag/flow.php @@ -1,93 +1,288 @@ -
-
- - - ~ - - - 초기화 -
- + number_format((int) $n); - - +$printExtraLines = []; +if ($queried) { + $aggLabel = $aggMode === 'daily' ? '일자별' : '기간별'; + $printExtraLines[] = '조회기간: ' . $startDate . ' ~ ' . $endDate . ' (' . $aggLabel . ')'; +} +?> +
+ '기간별 봉투 수불 현황', + 'printExtraLines' => $printExtraLines, +]) ?> + +
+
+ 기간별 봉투 수불 현황 +
+ 엑셀저장 + + 종료 +
+
+
+ +
+
+ + +
+ + + ~ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ 집계방식 + + +
+ + + 초기화 + +

전일재고 = 조회 시작일 전날 기준 품목별 재고(입고·반품·기타 − 출고 누적). 대행소 선택 시 판매만 해당 대행소 소속 판매소 기준입니다.

+
+ + +
+ 조회 조건을 설정한 뒤 조회 버튼을 눌러 주세요. +
+ + + +
+
- - - - - + + + + + + - - + + + + + + + + + + - - bi_bag_code ?? ''; - if (! isset($summary[$code])) { - $summary[$code] = ['name' => $inv->bi_bag_name ?? '', 'stock' => 0, 'recv' => 0, 'return' => 0, 'sale' => 0, 'issue' => 0]; - } - $summary[$code]['stock'] += (int)($inv->bi_qty_sheet ?? 0); - } - // 입고 - foreach ($receiving as $r) { - $code = $r->br_bag_code ?? ''; - if (! isset($summary[$code])) { - $summary[$code] = ['name' => $r->br_bag_name ?? '', 'stock' => 0, 'recv' => 0, 'return' => 0, 'sale' => 0, 'issue' => 0]; - } - $summary[$code]['recv'] += (int)($r->br_qty_sheet ?? 0); - } - // 판매/반품 - foreach ($sales as $s) { - $code = $s->bs_bag_code ?? ''; - if (! isset($summary[$code])) { - $summary[$code] = ['name' => $s->bs_bag_name ?? '', 'stock' => 0, 'recv' => 0, 'return' => 0, 'sale' => 0, 'issue' => 0]; - } - $type = $s->bs_type ?? 'sale'; - if ($type === 'return') { - $summary[$code]['return'] += (int)($s->bs_qty ?? 0); - } else { - $summary[$code]['sale'] += (int)($s->bs_qty ?? 0); - } - } - // 불출 - foreach ($issues as $iss) { - $code = $iss->bi2_bag_code ?? ''; - if (! isset($summary[$code])) { - $summary[$code] = ['name' => $iss->bi2_bag_name ?? '', 'stock' => 0, 'recv' => 0, 'return' => 0, 'sale' => 0, 'issue' => 0]; - } - if (($iss->bi2_status ?? 'normal') === 'normal') { - $summary[$code]['issue'] += (int)($iss->bi2_qty ?? 0); - } - } - ksort($summary); - ?> - - $s): $idx++; ?> - - - - - - - - + + + + 'bg-amber-50 font-semibold', + default => '', + }; + ?> + + + + + + + + + + + + + + + - - - + + + + +
봉투코드봉투명현재재고입고출고일자품목 + 전일재고전일 + 입고출고잔량
입고수량반품수량판매수량불출수량입고반품기타 + 입고계입계 + 판매 + 일반불출일반 + + 무료불출무료 + 반품기타 + 출고계출계 +
수불 데이터가 없습니다.
조회 결과가 없습니다.
+ +
+ + diff --git a/app/Views/bag/inventory.php b/app/Views/bag/inventory.php index ca3164f..3e8cc31 100644 --- a/app/Views/bag/inventory.php +++ b/app/Views/bag/inventory.php @@ -1,27 +1,122 @@ -
-
- - 재고 조정 + '재고 현황', + 'printDate' => (string) ($baseDate ?? date('Y-m-d')), + 'printExtraLines' => [ + '기준일자: ' . (string) ($baseDate ?? date('Y-m-d')), + ], +]) ?> + 0, 'gugun' => 0, 'agency' => 0]; +$agencyOptions = is_array($agencyOptions ?? null) ? $agencyOptions : []; +$subtotalByGroup = []; +foreach ($subtotals as $subtotal) { + $group = (string) ($subtotal['group'] ?? ''); + if ($group !== '') { + $subtotalByGroup[$group] = $subtotal; + } +} +?> + +
+
+
+ + + + + +
+
+ + 엑셀저장 + + 실사 선별 조회 +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
품 목 구 분봉투/스티커 종류시군구 재고대행소 재고
소계
조회 결과가 없습니다.
합계
- - - - - - - $row): ?> - - - - - - - - - - - - -
번호봉투코드봉투명현재재고(낱장)최종갱신
bi_bag_code ?? '') ?>bi_bag_name ?? '') ?>bi_qty ?? 0)) ?>bi_updated_at ?? '') ?>
재고 데이터가 없습니다.
+

+ ※ 기준일자까지 갱신된 재고를 집계합니다. 대행소 재고는 별도 재고 연계 전까지 0으로 표시됩니다. +

diff --git a/app/Views/bag/inventory_adjust.php b/app/Views/bag/inventory_adjust.php deleted file mode 100644 index 13622f6..0000000 --- a/app/Views/bag/inventory_adjust.php +++ /dev/null @@ -1,43 +0,0 @@ -
-
-

재고 수량 조정 (실사)

-
- -
- - -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - 취소 -
-
-
diff --git a/app/Views/bag/inventory_inquiry.php b/app/Views/bag/inventory_inquiry.php index 6b23226..c3d2dbb 100644 --- a/app/Views/bag/inventory_inquiry.php +++ b/app/Views/bag/inventory_inquiry.php @@ -5,6 +5,7 @@ 스마트 폐기물 관리 시스템 - 재고 관리 + - +
@@ -136,5 +142,44 @@ body { overflow: hidden; } 종량제 시스템 + diff --git a/app/Views/bag/lg_dashboard.php b/app/Views/bag/lg_dashboard.php index dc99fea..c3002b6 100644 --- a/app/Views/bag/lg_dashboard.php +++ b/app/Views/bag/lg_dashboard.php @@ -15,6 +15,7 @@ $mbName = session()->get('mb_name') ?? '담당자'; 종량제 시스템 — 업무 현황 + +