diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 49d1a74..64b1ab0 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -97,6 +97,23 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
// 재고 현황 (P3-10)
$routes->get('bag-inventory', 'Admin\BagInventory::index');
+ // 주문 접수 관리 (P4-01~03)
+ $routes->get('shop-orders', 'Admin\ShopOrder::index');
+ $routes->get('shop-orders/create', 'Admin\ShopOrder::create');
+ $routes->post('shop-orders/store', 'Admin\ShopOrder::store');
+ $routes->post('shop-orders/cancel/(:num)', 'Admin\ShopOrder::cancel/$1');
+
+ // 판매/반품 관리 (P4-04~07)
+ $routes->get('bag-sales', 'Admin\BagSale::index');
+ $routes->get('bag-sales/create', 'Admin\BagSale::create');
+ $routes->post('bag-sales/store', 'Admin\BagSale::store');
+
+ // 무료용 불출 관리 (P4-08~10)
+ $routes->get('bag-issues', 'Admin\BagIssue::index');
+ $routes->get('bag-issues/create', 'Admin\BagIssue::create');
+ $routes->post('bag-issues/store', 'Admin\BagIssue::store');
+ $routes->post('bag-issues/cancel/(:num)', 'Admin\BagIssue::cancel/$1');
+
// 포장 단위 관리 (P2-05/06)
$routes->get('packaging-units', 'Admin\PackagingUnit::index');
$routes->get('packaging-units/create', 'Admin\PackagingUnit::create');
diff --git a/app/Controllers/Admin/BagIssue.php b/app/Controllers/Admin/BagIssue.php
new file mode 100644
index 0000000..4255041
--- /dev/null
+++ b/app/Controllers/Admin/BagIssue.php
@@ -0,0 +1,122 @@
+issueModel = model(BagIssueModel::class);
+ }
+
+ public function index()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
+
+ $builder = $this->issueModel->where('bi2_lg_idx', $lgIdx);
+ $startDate = $this->request->getGet('start_date');
+ $endDate = $this->request->getGet('end_date');
+ if ($startDate) $builder->where('bi2_issue_date >=', $startDate);
+ if ($endDate) $builder->where('bi2_issue_date <=', $endDate);
+
+ $list = $builder->orderBy('bi2_issue_date', 'DESC')->orderBy('bi2_idx', 'DESC')->findAll();
+
+ return view('admin/layout', [
+ 'title' => '무료용 불출 관리',
+ 'content' => view('admin/bag_issue/index', compact('list', 'startDate', 'endDate')),
+ ]);
+ }
+
+ public function create()
+ {
+ helper('admin');
+ $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
+ $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
+
+ return view('admin/layout', [
+ 'title' => '무료용 불출 처리',
+ 'content' => view('admin/bag_issue/create', compact('bagCodes')),
+ ]);
+ }
+
+ public function store()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+
+ $rules = [
+ 'bi2_year' => 'required|is_natural_no_zero',
+ 'bi2_quarter' => 'required|in_list[1,2,3,4]',
+ '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',
+ ];
+ 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)->where('cd_ck_idx', $kindO->ck_idx)->where('cd_code', $bagCode)->first() : null;
+ $bagName = $detail ? $detail->cd_name : '';
+
+ $db = \Config\Database::connect();
+ $db->transStart();
+
+ $this->issueModel->insert([
+ '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'),
+ ]);
+
+ // 재고 감산
+ model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, -$qty);
+
+ $db->transComplete();
+
+ return redirect()->to(site_url('admin/bag-issues'))->with('success', '불출 처리되었습니다.');
+ }
+
+ public function cancel(int $id)
+ {
+ helper('admin');
+ $item = $this->issueModel->find($id);
+ if (!$item || (int) $item->bi2_lg_idx !== admin_effective_lg_idx()) {
+ return redirect()->to(site_url('admin/bag-issues'))->with('error', '불출 내역을 찾을 수 없습니다.');
+ }
+
+ $db = \Config\Database::connect();
+ $db->transStart();
+
+ $this->issueModel->update($id, ['bi2_status' => 'cancelled']);
+ // 재고 복원
+ model(BagInventoryModel::class)->adjustQty((int) $item->bi2_lg_idx, $item->bi2_bag_code, $item->bi2_bag_name, (int) $item->bi2_qty);
+
+ $db->transComplete();
+
+ return redirect()->to(site_url('admin/bag-issues'))->with('success', '불출이 취소되었습니다.');
+ }
+}
diff --git a/app/Controllers/Admin/BagSale.php b/app/Controllers/Admin/BagSale.php
new file mode 100644
index 0000000..8f2b5c2
--- /dev/null
+++ b/app/Controllers/Admin/BagSale.php
@@ -0,0 +1,114 @@
+saleModel = model(BagSaleModel::class);
+ }
+
+ public function index()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
+
+ $builder = $this->saleModel->where('bs_lg_idx', $lgIdx);
+ $startDate = $this->request->getGet('start_date');
+ $endDate = $this->request->getGet('end_date');
+ $type = $this->request->getGet('type');
+ if ($startDate) $builder->where('bs_sale_date >=', $startDate);
+ if ($endDate) $builder->where('bs_sale_date <=', $endDate);
+ if ($type) $builder->where('bs_type', $type);
+
+ $list = $builder->orderBy('bs_sale_date', 'DESC')->orderBy('bs_idx', 'DESC')->findAll();
+
+ return view('admin/layout', [
+ 'title' => '판매/반품 관리',
+ 'content' => view('admin/bag_sale/index', compact('list', 'startDate', 'endDate', 'type')),
+ ]);
+ }
+
+ public function create()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin/bag-sales'))->with('error', '지자체를 선택해 주세요.');
+
+ $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) : [];
+
+ return view('admin/layout', [
+ 'title' => '판매 등록',
+ 'content' => view('admin/bag_sale/create', compact('shops', 'bagCodes')),
+ ]);
+ }
+
+ public function store()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+
+ $rules = [
+ 'bs_ds_idx' => 'required|is_natural_no_zero',
+ 'bs_bag_code' => 'required|max_length[50]',
+ 'bs_qty' => 'required|is_natural_no_zero',
+ 'bs_sale_date' => 'required|valid_date[Y-m-d]',
+ 'bs_type' => 'required|in_list[sale,return]',
+ ];
+ if (! $this->validate($rules)) {
+ return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
+ }
+
+ $dsIdx = (int) $this->request->getPost('bs_ds_idx');
+ $bagCode = $this->request->getPost('bs_bag_code');
+ $qty = (int) $this->request->getPost('bs_qty');
+ $type = $this->request->getPost('bs_type');
+
+ $shop = model(DesignatedShopModel::class)->find($dsIdx);
+ $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
+ $detail = $kindO ? model(CodeDetailModel::class)->where('cd_ck_idx', $kindO->ck_idx)->where('cd_code', $bagCode)->first() : null;
+ $price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $bagCode)->where('bp_state', 1)->first();
+ $unitPrice = $price ? (float) $price->bp_consumer : 0;
+
+ $actualQty = ($type === 'return') ? -$qty : $qty;
+
+ $db = \Config\Database::connect();
+ $db->transStart();
+
+ $this->saleModel->insert([
+ 'bs_lg_idx' => $lgIdx,
+ 'bs_ds_idx' => $dsIdx,
+ 'bs_ds_name' => $shop ? $shop->ds_name : '',
+ 'bs_sale_date' => $this->request->getPost('bs_sale_date'),
+ 'bs_bag_code' => $bagCode,
+ 'bs_bag_name' => $detail ? $detail->cd_name : '',
+ 'bs_qty' => $actualQty,
+ 'bs_unit_price'=> $unitPrice,
+ 'bs_amount' => $unitPrice * abs($actualQty),
+ 'bs_type' => $type,
+ 'bs_regdate' => date('Y-m-d H:i:s'),
+ ]);
+
+ // 재고 감산(판매) / 가산(반품)
+ model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $detail ? $detail->cd_name : '', -$actualQty);
+
+ $db->transComplete();
+
+ $msg = ($type === 'sale') ? '판매 처리되었습니다.' : '반품 처리되었습니다.';
+ return redirect()->to(site_url('admin/bag-sales'))->with('success', $msg);
+ }
+}
diff --git a/app/Controllers/Admin/ShopOrder.php b/app/Controllers/Admin/ShopOrder.php
new file mode 100644
index 0000000..fa5eb82
--- /dev/null
+++ b/app/Controllers/Admin/ShopOrder.php
@@ -0,0 +1,153 @@
+orderModel = model(ShopOrderModel::class);
+ $this->itemModel = model(ShopOrderItemModel::class);
+ }
+
+ public function index()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
+
+ $builder = $this->orderModel->where('so_lg_idx', $lgIdx);
+ $startDate = $this->request->getGet('start_date');
+ $endDate = $this->request->getGet('end_date');
+ if ($startDate) $builder->where('so_delivery_date >=', $startDate);
+ if ($endDate) $builder->where('so_delivery_date <=', $endDate);
+
+ $list = $builder->orderBy('so_idx', 'DESC')->findAll();
+
+ return view('admin/layout', [
+ 'title' => '주문 접수 관리',
+ 'content' => view('admin/shop_order/index', compact('list', 'startDate', 'endDate')),
+ ]);
+ }
+
+ public function create()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin/shop-orders'))->with('error', '지자체를 선택해 주세요.');
+
+ $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) : [];
+
+ return view('admin/layout', [
+ 'title' => '주문 접수',
+ 'content' => view('admin/shop_order/create', compact('shops', 'bagCodes')),
+ ]);
+ }
+
+ public function store()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+
+ $rules = [
+ 'so_ds_idx' => 'required|is_natural_no_zero',
+ 'so_delivery_date'=> 'required|valid_date[Y-m-d]',
+ 'so_payment_type' => 'required|in_list[이체,가상계좌]',
+ ];
+ if (! $this->validate($rules)) {
+ return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
+ }
+
+ $db = \Config\Database::connect();
+ $db->transStart();
+
+ $dsIdx = (int) $this->request->getPost('so_ds_idx');
+ $shop = model(DesignatedShopModel::class)->find($dsIdx);
+
+ $this->orderModel->insert([
+ 'so_lg_idx' => $lgIdx,
+ 'so_ds_idx' => $dsIdx,
+ 'so_ds_name' => $shop ? $shop->ds_name : '',
+ 'so_order_date' => date('Y-m-d'),
+ 'so_delivery_date'=> $this->request->getPost('so_delivery_date'),
+ 'so_payment_type' => $this->request->getPost('so_payment_type'),
+ 'so_status' => 'normal',
+ 'so_orderer_idx' => session()->get('mb_idx'),
+ 'so_regdate' => date('Y-m-d H:i:s'),
+ ]);
+ $soIdx = (int) $this->orderModel->getInsertID();
+
+ $bagCodes = $this->request->getPost('item_bag_code') ?? [];
+ $qtys = $this->request->getPost('item_qty') ?? [];
+ $totalQty = 0; $totalAmt = 0;
+
+ foreach ($bagCodes as $i => $code) {
+ if (empty($code) || empty($qtys[$i])) continue;
+ $qty = (int) $qtys[$i];
+
+ $price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $code)->where('bp_state', 1)->first();
+ $unitPrice = $price ? (float) $price->bp_consumer : 0;
+ $amount = $unitPrice * $qty;
+
+ $unit = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $code)->where('pu_state', 1)->first();
+ $boxCount = 0; $packCount = 0; $sheetCount = $qty;
+ if ($unit && (int) $unit->pu_total_per_box > 0) {
+ $boxCount = intdiv($qty, (int) $unit->pu_total_per_box);
+ $remainder = $qty % (int) $unit->pu_total_per_box;
+ if ((int) $unit->pu_pack_per_sheet > 0) {
+ $packCount = intdiv($remainder, (int) $unit->pu_pack_per_sheet);
+ $sheetCount = $remainder % (int) $unit->pu_pack_per_sheet;
+ }
+ }
+
+ $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
+ $detail = $kindO ? model(CodeDetailModel::class)->where('cd_ck_idx', $kindO->ck_idx)->where('cd_code', $code)->first() : null;
+
+ $this->itemModel->insert([
+ 'soi_so_idx' => $soIdx,
+ 'soi_bag_code' => $code,
+ 'soi_bag_name' => $detail ? $detail->cd_name : '',
+ 'soi_unit_price' => $unitPrice,
+ 'soi_qty' => $qty,
+ 'soi_amount' => $amount,
+ 'soi_box_count' => $boxCount,
+ 'soi_pack_count' => $packCount,
+ 'soi_sheet_count'=> $sheetCount,
+ ]);
+
+ $totalQty += $qty;
+ $totalAmt += $amount;
+ }
+
+ $this->orderModel->update($soIdx, ['so_total_qty' => $totalQty, 'so_total_amount' => $totalAmt]);
+ $db->transComplete();
+
+ return redirect()->to(site_url('admin/shop-orders'))->with('success', '주문이 접수되었습니다.');
+ }
+
+ public function cancel(int $id)
+ {
+ helper('admin');
+ $order = $this->orderModel->find($id);
+ if (!$order || (int) $order->so_lg_idx !== admin_effective_lg_idx()) {
+ return redirect()->to(site_url('admin/shop-orders'))->with('error', '주문을 찾을 수 없습니다.');
+ }
+
+ $this->orderModel->update($id, ['so_status' => 'cancelled']);
+ return redirect()->to(site_url('admin/shop-orders'))->with('success', '주문이 취소되었습니다.');
+ }
+}
diff --git a/app/Models/BagIssueModel.php b/app/Models/BagIssueModel.php
new file mode 100644
index 0000000..855ecc9
--- /dev/null
+++ b/app/Models/BagIssueModel.php
@@ -0,0 +1,18 @@
+
+ 무료용 불출 처리
+
+
diff --git a/app/Views/admin/bag_issue/index.php b/app/Views/admin/bag_issue/index.php
new file mode 100644
index 0000000..023e4b7
--- /dev/null
+++ b/app/Views/admin/bag_issue/index.php
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+ | 번호 |
+ 연도 |
+ 분기 |
+ 구분 |
+ 불출일 |
+ 불출처 |
+ 봉투코드 |
+ 봉투명 |
+ 수량 |
+ 상태 |
+ 작업 |
+
+
+
+
+
+ | = esc($row->bi2_idx) ?> |
+ = esc($row->bi2_year) ?> |
+ = esc($row->bi2_quarter) ?> |
+ = esc($row->bi2_issue_type) ?> |
+ = esc($row->bi2_issue_date) ?> |
+ = esc($row->bi2_dest_name) ?> |
+ = esc($row->bi2_bag_code) ?> |
+ = esc($row->bi2_bag_name) ?> |
+ = number_format((int) $row->bi2_qty) ?> |
+ = esc($row->bi2_status) ?> |
+
+
+ |
+
+
+
+ | 등록된 불출이 없습니다. |
+
+
+
+
diff --git a/app/Views/admin/bag_sale/create.php b/app/Views/admin/bag_sale/create.php
new file mode 100644
index 0000000..164366c
--- /dev/null
+++ b/app/Views/admin/bag_sale/create.php
@@ -0,0 +1,56 @@
+
+
diff --git a/app/Views/admin/bag_sale/index.php b/app/Views/admin/bag_sale/index.php
new file mode 100644
index 0000000..83ae634
--- /dev/null
+++ b/app/Views/admin/bag_sale/index.php
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+ | 번호 |
+ 판매소 |
+ 판매일 |
+ 봉투코드 |
+ 봉투명 |
+ 수량 |
+ 단가 |
+ 금액 |
+ 구분 |
+
+
+
+
+
+ | = esc($row->bs_idx) ?> |
+ = esc($row->bs_ds_name) ?> |
+ = esc($row->bs_sale_date) ?> |
+ = esc($row->bs_bag_code) ?> |
+ = esc($row->bs_bag_name) ?> |
+ = number_format((int) $row->bs_qty) ?> |
+ = number_format((int) $row->bs_unit_price) ?> |
+ = number_format((int) $row->bs_amount) ?> |
+
+ '판매', 'return' => '반품', 'cancel' => '취소'];
+ echo esc($typeMap[$row->bs_type] ?? $row->bs_type);
+ ?>
+ |
+
+
+
+ | 등록된 판매/반품이 없습니다. |
+
+
+
+
diff --git a/app/Views/admin/shop_order/create.php b/app/Views/admin/shop_order/create.php
new file mode 100644
index 0000000..bf8e74c
--- /dev/null
+++ b/app/Views/admin/shop_order/create.php
@@ -0,0 +1,74 @@
+
+
diff --git a/app/Views/admin/shop_order/index.php b/app/Views/admin/shop_order/index.php
new file mode 100644
index 0000000..f2d4e96
--- /dev/null
+++ b/app/Views/admin/shop_order/index.php
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+ | 번호 |
+ 판매소 |
+ 접수일 |
+ 배달일 |
+ 결제 |
+ 입금 |
+ 수령 |
+ 수량 |
+ 금액 |
+ 상태 |
+ 작업 |
+
+
+
+
+
+ | = esc($row->so_idx) ?> |
+ = esc($row->so_ds_name) ?> |
+ = esc($row->so_order_date) ?> |
+ = esc($row->so_delivery_date) ?> |
+ = esc($row->so_payment_type) ?> |
+
+ '미입금', '1' => '입금'];
+ echo esc($paidMap[$row->so_paid] ?? $row->so_paid);
+ ?>
+ |
+
+ '미수령', '1' => '수령'];
+ echo esc($receivedMap[$row->so_received] ?? $row->so_received);
+ ?>
+ |
+ = number_format((int) $row->so_total_qty) ?> |
+ = number_format((int) $row->so_total_amount) ?> |
+
+ '정상', 'cancelled' => '취소'];
+ echo esc($statusMap[$row->so_status] ?? $row->so_status);
+ ?>
+ |
+
+
+ |
+
+
+
+ | 등록된 주문이 없습니다. |
+
+
+
+
diff --git a/e2e/phase4-sales.spec.js b/e2e/phase4-sales.spec.js
new file mode 100644
index 0000000..9da4cdd
--- /dev/null
+++ b/e2e/phase4-sales.spec.js
@@ -0,0 +1,61 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+const { login } = require('./helpers/auth');
+
+async function loginAsAdmin(page) {
+ await login(page, 'admin');
+ await page.locator('input[name="lg_idx"]').first().check();
+ await page.click('button[type="submit"]');
+ await page.waitForURL(url => !url.pathname.includes('select-local-government'), { timeout: 15000 });
+}
+
+test.describe('P4: 주문 접수 관리', () => {
+ test('주문 목록 접근', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/shop-orders');
+ await expect(page).toHaveURL(/\/admin\/shop-orders/);
+ });
+ test('주문 접수 폼', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/shop-orders/create');
+ await expect(page.locator('select[name="so_ds_idx"]')).toBeVisible();
+ });
+});
+
+test.describe('P4: 판매/반품 관리', () => {
+ test('판매 목록 접근', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/bag-sales');
+ await expect(page).toHaveURL(/\/admin\/bag-sales/);
+ });
+ test('판매 등록 폼', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/bag-sales/create');
+ await expect(page.locator('select[name="bs_ds_idx"]')).toBeVisible();
+ });
+});
+
+test.describe('P4: 무료용 불출 관리', () => {
+ test('불출 목록 접근', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/bag-issues');
+ await expect(page).toHaveURL(/\/admin\/bag-issues/);
+ });
+ test('불출 처리 폼', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/bag-issues/create');
+ await expect(page.locator('select[name="bi2_bag_code"]')).toBeVisible();
+ });
+});
+
+test.describe('P4: 지자체관리자 접근', () => {
+ test('주문/판매/불출 접근 가능', async ({ page }) => {
+ await login(page, 'local');
+ await page.goto('/admin/shop-orders');
+ await expect(page).toHaveURL(/\/admin\/shop-orders/);
+ await page.goto('/admin/bag-sales');
+ await expect(page).toHaveURL(/\/admin\/bag-sales/);
+ await page.goto('/admin/bag-issues');
+ await expect(page).toHaveURL(/\/admin\/bag-issues/);
+ });
+});
diff --git a/writable/database/sales_tables.sql b/writable/database/sales_tables.sql
new file mode 100644
index 0000000..7065ba8
--- /dev/null
+++ b/writable/database/sales_tables.sql
@@ -0,0 +1,81 @@
+-- ============================================
+-- 주문/판매/불출 관리 테이블 (Phase 4)
+-- ============================================
+
+-- 주문 접수 (P4-01~03)
+CREATE TABLE IF NOT EXISTS `shop_order` (
+ `so_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `so_lg_idx` INT UNSIGNED NOT NULL,
+ `so_ds_idx` INT UNSIGNED NULL COMMENT '지정판매소 FK',
+ `so_ds_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '판매소명(스냅샷)',
+ `so_order_date` DATE NOT NULL COMMENT '접수일',
+ `so_delivery_date` DATE NULL COMMENT '배달일',
+ `so_payment_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '이체/가상계좌',
+ `so_paid` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '입금여부 1=예',
+ `so_received` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '수령여부 1=예',
+ `so_total_qty` INT UNSIGNED NOT NULL DEFAULT 0,
+ `so_total_amount` DECIMAL(14,2) NOT NULL DEFAULT 0,
+ `so_status` VARCHAR(10) NOT NULL DEFAULT 'normal' COMMENT 'normal/cancelled',
+ `so_orderer_idx` INT UNSIGNED NULL,
+ `so_regdate` DATETIME NOT NULL,
+ PRIMARY KEY (`so_idx`),
+ KEY `idx_so_lg_idx` (`so_lg_idx`),
+ KEY `idx_so_ds_idx` (`so_ds_idx`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='주문 접수';
+
+-- 주문 상세
+CREATE TABLE IF NOT EXISTS `shop_order_item` (
+ `soi_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `soi_so_idx` INT UNSIGNED NOT NULL,
+ `soi_bag_code` VARCHAR(50) NOT NULL,
+ `soi_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
+ `soi_unit_price` DECIMAL(12,2) NOT NULL DEFAULT 0,
+ `soi_qty` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '접수량(낱장)',
+ `soi_amount` DECIMAL(14,2) NOT NULL DEFAULT 0,
+ `soi_box_count` INT UNSIGNED NOT NULL DEFAULT 0,
+ `soi_pack_count` INT UNSIGNED NOT NULL DEFAULT 0,
+ `soi_sheet_count` INT UNSIGNED NOT NULL DEFAULT 0,
+ PRIMARY KEY (`soi_idx`),
+ KEY `idx_soi_so_idx` (`soi_so_idx`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='주문 상세';
+
+-- 판매 (P4-04~07)
+CREATE TABLE IF NOT EXISTS `bag_sale` (
+ `bs_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `bs_lg_idx` INT UNSIGNED NOT NULL,
+ `bs_so_idx` INT UNSIGNED NULL COMMENT '주문 FK (NULL=직접판매)',
+ `bs_ds_idx` INT UNSIGNED NULL COMMENT '지정판매소 FK',
+ `bs_ds_name` VARCHAR(100) NOT NULL DEFAULT '',
+ `bs_sale_date` DATE NOT NULL,
+ `bs_bag_code` VARCHAR(50) NOT NULL,
+ `bs_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
+ `bs_qty` INT NOT NULL DEFAULT 0 COMMENT '판매수량(낱장, 음수=반품)',
+ `bs_unit_price` DECIMAL(12,2) NOT NULL DEFAULT 0,
+ `bs_amount` DECIMAL(14,2) NOT NULL DEFAULT 0,
+ `bs_type` VARCHAR(20) NOT NULL DEFAULT 'sale' COMMENT 'sale/return/cancel',
+ `bs_regdate` DATETIME NOT NULL,
+ PRIMARY KEY (`bs_idx`),
+ KEY `idx_bs_lg_idx` (`bs_lg_idx`),
+ KEY `idx_bs_ds_idx` (`bs_ds_idx`),
+ KEY `idx_bs_sale_date` (`bs_sale_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='판매/반품';
+
+-- 불출 (P4-08~10)
+CREATE TABLE IF NOT EXISTS `bag_issue` (
+ `bi2_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `bi2_lg_idx` INT UNSIGNED NOT NULL,
+ `bi2_year` YEAR NOT NULL,
+ `bi2_quarter` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '분기(1~4)',
+ `bi2_issue_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '무료용/공공용',
+ `bi2_issue_date` DATE NOT NULL,
+ `bi2_dest_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '불출처 구분(동사무소 등)',
+ `bi2_dest_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '불출처명',
+ `bi2_bag_code` VARCHAR(50) NOT NULL,
+ `bi2_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
+ `bi2_qty` INT NOT NULL DEFAULT 0 COMMENT '불출수량(낱장, 음수=취소)',
+ `bi2_status` VARCHAR(10) NOT NULL DEFAULT 'normal' COMMENT 'normal/cancelled',
+ `bi2_regdate` DATETIME NOT NULL,
+ PRIMARY KEY (`bi2_idx`),
+ KEY `idx_bi2_lg_idx` (`bi2_lg_idx`),
+ KEY `idx_bi2_date` (`bi2_issue_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='무료용 불출';