diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index f9c9528..49d1a74 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -81,6 +81,22 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->post('bag-prices/delete/(:num)', 'Admin\BagPrice::delete/$1');
$routes->get('bag-prices/history/(:num)', 'Admin\BagPrice::history/$1');
+ // 발주 관리 (P3-01~05)
+ $routes->get('bag-orders', 'Admin\BagOrder::index');
+ $routes->get('bag-orders/create', 'Admin\BagOrder::create');
+ $routes->post('bag-orders/store', 'Admin\BagOrder::store');
+ $routes->get('bag-orders/detail/(:num)', 'Admin\BagOrder::detail/$1');
+ $routes->post('bag-orders/cancel/(:num)', 'Admin\BagOrder::cancel/$1');
+ $routes->post('bag-orders/delete/(:num)', 'Admin\BagOrder::delete/$1');
+
+ // 입고 관리 (P3-06~09)
+ $routes->get('bag-receivings', 'Admin\BagReceiving::index');
+ $routes->get('bag-receivings/create', 'Admin\BagReceiving::create');
+ $routes->post('bag-receivings/store', 'Admin\BagReceiving::store');
+
+ // 재고 현황 (P3-10)
+ $routes->get('bag-inventory', 'Admin\BagInventory::index');
+
// 포장 단위 관리 (P2-05/06)
$routes->get('packaging-units', 'Admin\PackagingUnit::index');
$routes->get('packaging-units/create', 'Admin\PackagingUnit::create');
diff --git a/app/Controllers/Admin/BagInventory.php b/app/Controllers/Admin/BagInventory.php
new file mode 100644
index 0000000..06479b6
--- /dev/null
+++ b/app/Controllers/Admin/BagInventory.php
@@ -0,0 +1,23 @@
+to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
+
+ $list = model(BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code', 'ASC')->findAll();
+
+ return view('admin/layout', [
+ 'title' => '재고 현황',
+ 'content' => view('admin/bag_inventory/index', ['list' => $list]),
+ ]);
+ }
+}
diff --git a/app/Controllers/Admin/BagOrder.php b/app/Controllers/Admin/BagOrder.php
new file mode 100644
index 0000000..8fdb518
--- /dev/null
+++ b/app/Controllers/Admin/BagOrder.php
@@ -0,0 +1,221 @@
+orderModel = model(BagOrderModel::class);
+ $this->itemModel = model(BagOrderItemModel::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('bo_lg_idx', $lgIdx);
+
+ // 기간 필터
+ $startDate = $this->request->getGet('start_date');
+ $endDate = $this->request->getGet('end_date');
+ $status = $this->request->getGet('status');
+ if ($startDate) $builder->where('bo_order_date >=', $startDate);
+ if ($endDate) $builder->where('bo_order_date <=', $endDate);
+ if ($status) $builder->where('bo_status', $status);
+
+ $list = $builder->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->findAll();
+
+ // 발주별 품목 합계
+ $itemSummary = [];
+ foreach ($list as $order) {
+ $items = $this->itemModel->where('boi_bo_idx', $order->bo_idx)->findAll();
+ $totalQty = 0; $totalAmt = 0;
+ foreach ($items as $it) { $totalQty += (int) $it->boi_qty_sheet; $totalAmt += (float) $it->boi_amount; }
+ $itemSummary[$order->bo_idx] = ['qty' => $totalQty, 'amount' => $totalAmt, 'count' => count($items)];
+ }
+
+ // 제작업체/대행소 이름 매핑
+ $companyMap = []; $agencyMap = [];
+ foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->findAll() as $c) $companyMap[$c->cp_idx] = $c->cp_name;
+ foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->findAll() as $a) $agencyMap[$a->sa_idx] = $a->sa_name;
+
+ return view('admin/layout', [
+ 'title' => '발주 현황',
+ 'content' => view('admin/bag_order/index', compact('list', 'itemSummary', 'companyMap', 'agencyMap', 'startDate', 'endDate', 'status')),
+ ]);
+ }
+
+ public function create()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin/bag-orders'))->with('error', '지자체를 선택해 주세요.');
+
+ // 봉투 종류 + 단가 + 포장단위
+ $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
+ $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
+ $prices = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_state', 1)->findAll();
+ $units = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll();
+ $companies = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll();
+ $agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->where('sa_state', 1)->findAll();
+
+ return view('admin/layout', [
+ 'title' => '발주 등록',
+ 'content' => view('admin/bag_order/create', compact('bagCodes', 'prices', 'units', 'companies', 'agencies')),
+ ]);
+ }
+
+ public function store()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+
+ $rules = [
+ 'bo_order_date' => 'required|valid_date[Y-m-d]',
+ 'bo_company_idx' => 'permit_empty|is_natural_no_zero',
+ 'bo_agency_idx' => 'permit_empty|is_natural_no_zero',
+ ];
+ if (! $this->validate($rules)) {
+ return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
+ }
+
+ $db = \Config\Database::connect();
+ $db->transStart();
+
+ // UUID 생성
+ $uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
+ mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
+ mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000,
+ mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
+
+ // LOT 번호 생성
+ $lotNo = 'LOT-' . date('Ymd') . '-' . strtoupper(substr(md5($uuid), 0, 6));
+
+ $orderData = [
+ 'bo_uuid' => $uuid,
+ 'bo_version' => 1,
+ 'bo_lg_idx' => $lgIdx,
+ 'bo_gugun_code' => $this->request->getPost('bo_gugun_code') ?? '',
+ 'bo_dong_code' => $this->request->getPost('bo_dong_code') ?? '',
+ 'bo_company_idx' => $this->request->getPost('bo_company_idx') ?: null,
+ 'bo_agency_idx' => $this->request->getPost('bo_agency_idx') ?: null,
+ 'bo_fee_rate' => (float) ($this->request->getPost('bo_fee_rate') ?: 0),
+ 'bo_order_date' => $this->request->getPost('bo_order_date'),
+ 'bo_lot_no' => $lotNo,
+ 'bo_status' => 'normal',
+ 'bo_orderer_idx' => session()->get('mb_idx'),
+ 'bo_regdate' => date('Y-m-d H:i:s'),
+ ];
+
+ // SHA-256 해시
+ $orderData['bo_hash'] = hash('sha256', json_encode($orderData));
+
+ $this->orderModel->insert($orderData);
+ $boIdx = (int) $this->orderModel->getInsertID();
+
+ // 품목 저장
+ $bagCodes = $this->request->getPost('item_bag_code') ?? [];
+ $qtyBoxes = $this->request->getPost('item_qty_box') ?? [];
+ foreach ($bagCodes as $i => $code) {
+ if (empty($code) || empty($qtyBoxes[$i])) continue;
+ $qtyBox = (int) $qtyBoxes[$i];
+
+ // 포장단위에서 낱장 환산
+ $unit = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $code)->where('pu_state', 1)->first();
+ $totalPerBox = $unit ? (int) $unit->pu_total_per_box : 1;
+ $qtySheet = $qtyBox * $totalPerBox;
+
+ // 단가
+ $price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $code)->where('bp_state', 1)->first();
+ $unitPrice = $price ? (float) $price->bp_order_price : 0;
+
+ // 봉투명
+ $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([
+ 'boi_bo_idx' => $boIdx,
+ 'boi_bag_code' => $code,
+ 'boi_bag_name' => $detail ? $detail->cd_name : '',
+ 'boi_unit_price' => $unitPrice,
+ 'boi_qty_box' => $qtyBox,
+ 'boi_qty_sheet' => $qtySheet,
+ 'boi_amount' => $unitPrice * $qtySheet,
+ ]);
+ }
+
+ $db->transComplete();
+
+ return redirect()->to(site_url('admin/bag-orders'))->with('success', '발주가 등록되었습니다. LOT: ' . $lotNo);
+ }
+
+ public function detail(int $id)
+ {
+ helper('admin');
+ $order = $this->orderModel->find($id);
+ if (!$order || (int) $order->bo_lg_idx !== admin_effective_lg_idx()) {
+ return redirect()->to(site_url('admin/bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
+ }
+
+ $items = $this->itemModel->where('boi_bo_idx', $id)->findAll();
+
+ $companyName = '';
+ if ($order->bo_company_idx) {
+ $c = model(CompanyModel::class)->find($order->bo_company_idx);
+ $companyName = $c ? $c->cp_name : '';
+ }
+ $agencyName = '';
+ if ($order->bo_agency_idx) {
+ $a = model(SalesAgencyModel::class)->find($order->bo_agency_idx);
+ $agencyName = $a ? $a->sa_name : '';
+ }
+
+ return view('admin/layout', [
+ 'title' => '발주 상세 — ' . $order->bo_lot_no,
+ 'content' => view('admin/bag_order/detail', compact('order', 'items', 'companyName', 'agencyName')),
+ ]);
+ }
+
+ public function cancel(int $id)
+ {
+ helper('admin');
+ $order = $this->orderModel->find($id);
+ if (!$order || (int) $order->bo_lg_idx !== admin_effective_lg_idx()) {
+ return redirect()->to(site_url('admin/bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
+ }
+
+ $this->orderModel->update($id, ['bo_status' => 'cancelled', 'bo_moddate' => date('Y-m-d H:i:s')]);
+ return redirect()->to(site_url('admin/bag-orders'))->with('success', '발주가 취소되었습니다.');
+ }
+
+ public function delete(int $id)
+ {
+ helper('admin');
+ $order = $this->orderModel->find($id);
+ if (!$order || (int) $order->bo_lg_idx !== admin_effective_lg_idx()) {
+ return redirect()->to(site_url('admin/bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
+ }
+
+ $this->orderModel->update($id, ['bo_status' => 'deleted', 'bo_moddate' => date('Y-m-d H:i:s')]);
+ return redirect()->to(site_url('admin/bag-orders'))->with('success', '발주가 삭제 처리되었습니다.');
+ }
+}
diff --git a/app/Controllers/Admin/BagReceiving.php b/app/Controllers/Admin/BagReceiving.php
new file mode 100644
index 0000000..bfbc354
--- /dev/null
+++ b/app/Controllers/Admin/BagReceiving.php
@@ -0,0 +1,109 @@
+recvModel = model(BagReceivingModel::class);
+ }
+
+ public function index()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
+
+ $builder = $this->recvModel->where('br_lg_idx', $lgIdx);
+ $startDate = $this->request->getGet('start_date');
+ $endDate = $this->request->getGet('end_date');
+ if ($startDate) $builder->where('br_receive_date >=', $startDate);
+ if ($endDate) $builder->where('br_receive_date <=', $endDate);
+
+ $list = $builder->orderBy('br_receive_date', 'DESC')->orderBy('br_idx', 'DESC')->findAll();
+
+ return view('admin/layout', [
+ 'title' => '입고 현황',
+ 'content' => view('admin/bag_receiving/index', compact('list', 'startDate', 'endDate')),
+ ]);
+ }
+
+ public function create()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+ if (!$lgIdx) return redirect()->to(site_url('admin/bag-receivings'))->with('error', '지자체를 선택해 주세요.');
+
+ // 미입고 발주 목록
+ $orders = model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->where('bo_status', 'normal')->orderBy('bo_order_date', 'DESC')->findAll();
+
+ return view('admin/layout', [
+ 'title' => '입고 처리',
+ 'content' => view('admin/bag_receiving/create', compact('orders')),
+ ]);
+ }
+
+ public function store()
+ {
+ helper('admin');
+ $lgIdx = admin_effective_lg_idx();
+
+ $rules = [
+ 'br_bo_idx' => 'required|is_natural_no_zero',
+ 'br_bag_code' => 'required|max_length[50]',
+ 'br_qty_box' => 'required|is_natural_no_zero',
+ 'br_receive_date' => 'required|valid_date[Y-m-d]',
+ ];
+ if (! $this->validate($rules)) {
+ return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
+ }
+
+ $boIdx = (int) $this->request->getPost('br_bo_idx');
+ $bagCode = $this->request->getPost('br_bag_code');
+ $qtyBox = (int) $this->request->getPost('br_qty_box');
+
+ // 포장단위로 낱장 환산
+ $unit = model(\App\Models\PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $bagCode)->where('pu_state', 1)->first();
+ $totalPerBox = $unit ? (int) $unit->pu_total_per_box : 1;
+ $qtySheet = $qtyBox * $totalPerBox;
+
+ // 봉투명
+ $kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
+ $detail = $kindO ? model(\App\Models\CodeDetailModel::class)->where('cd_ck_idx', $kindO->ck_idx)->where('cd_code', $bagCode)->first() : null;
+ $bagName = $detail ? $detail->cd_name : '';
+
+ $db = \Config\Database::connect();
+ $db->transStart();
+
+ $this->recvModel->insert([
+ 'br_bo_idx' => $boIdx,
+ 'br_lg_idx' => $lgIdx,
+ 'br_bag_code' => $bagCode,
+ 'br_bag_name' => $bagName,
+ 'br_qty_box' => $qtyBox,
+ 'br_qty_sheet' => $qtySheet,
+ 'br_receive_date' => $this->request->getPost('br_receive_date'),
+ 'br_receiver_idx' => session()->get('mb_idx'),
+ 'br_sender_name' => $this->request->getPost('br_sender_name') ?? '',
+ 'br_type' => $this->request->getPost('br_type') ?? 'batch',
+ 'br_regdate' => date('Y-m-d H:i:s'),
+ ]);
+
+ // 재고 가산
+ model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, $qtySheet);
+
+ $db->transComplete();
+
+ return redirect()->to(site_url('admin/bag-receivings'))->with('success', '입고 처리되었습니다. (' . $bagName . ' ' . $qtyBox . '박스)');
+ }
+}
diff --git a/app/Models/BagInventoryModel.php b/app/Models/BagInventoryModel.php
new file mode 100644
index 0000000..7df5310
--- /dev/null
+++ b/app/Models/BagInventoryModel.php
@@ -0,0 +1,38 @@
+where('bi_lg_idx', $lgIdx)->where('bi_bag_code', $bagCode)->first();
+ if ($existing) {
+ $this->update($existing->bi_idx, [
+ 'bi_qty' => max(0, (int) $existing->bi_qty + $delta),
+ 'bi_updated_at' => date('Y-m-d H:i:s'),
+ ]);
+ } else {
+ $this->insert([
+ 'bi_lg_idx' => $lgIdx,
+ 'bi_bag_code' => $bagCode,
+ 'bi_bag_name' => $bagName,
+ 'bi_qty' => max(0, $delta),
+ 'bi_updated_at'=> date('Y-m-d H:i:s'),
+ ]);
+ }
+ }
+}
diff --git a/app/Models/BagOrderItemModel.php b/app/Models/BagOrderItemModel.php
new file mode 100644
index 0000000..9a317ab
--- /dev/null
+++ b/app/Models/BagOrderItemModel.php
@@ -0,0 +1,17 @@
+
+ 재고 현황
+
+
+
+
+
+ | 번호 |
+ 봉투코드 |
+ 봉투명 |
+ 현재재고(낱장) |
+ 최종갱신 |
+
+
+
+
+
+ | = esc($row->bi_idx) ?> |
+ = esc($row->bi_bag_code) ?> |
+ = esc($row->bi_bag_name) ?> |
+ = number_format((int) $row->bi_qty) ?> |
+ = esc($row->bi_updated_at) ?> |
+
+
+
+ | 등록된 재고가 없습니다. |
+
+
+
+
diff --git a/app/Views/admin/bag_order/create.php b/app/Views/admin/bag_order/create.php
new file mode 100644
index 0000000..eba2ef4
--- /dev/null
+++ b/app/Views/admin/bag_order/create.php
@@ -0,0 +1,83 @@
+
+
diff --git a/app/Views/admin/bag_order/detail.php b/app/Views/admin/bag_order/detail.php
new file mode 100644
index 0000000..5faf1c4
--- /dev/null
+++ b/app/Views/admin/bag_order/detail.php
@@ -0,0 +1,102 @@
+
+
+
← 발주 목록
+
|
+
발주 상세 — = esc($order->bo_lot_no) ?>
+
+
+
+
+
+
+ | UUID |
+ = esc($order->bo_uuid) ?> |
+
+
+ | 버전 |
+ = esc($order->bo_version) ?> |
+
+
+ | 발주일 |
+ = esc($order->bo_order_date) ?> |
+
+
+ | 제작업체 |
+ = esc($companyName ?? '') ?> |
+
+
+ | 입고처 |
+ = esc($agencyName ?? '') ?> |
+
+
+ | LOT번호 |
+ = esc($order->bo_lot_no) ?> |
+
+
+ | 수수료율 |
+ = esc($order->bo_fee_rate) ?>% |
+
+
+ | 상태 |
+
+ '정상', 'cancelled' => '취소', 'deleted' => '삭제'];
+ echo esc($statusMap[$order->bo_status] ?? $order->bo_status);
+ ?>
+ |
+
+
+ | 해시 |
+ = esc($order->bo_hash) ?> |
+
+
+
+
+
+
+
+
+
+ | 봉투코드 |
+ 봉투명 |
+ 단가 |
+ 박스수 |
+ 낱장수 |
+ 금액 |
+
+
+
+
+
+
+ | = esc($item->boi_bag_code) ?> |
+ = esc($item->boi_bag_name) ?> |
+ = number_format((float) $item->boi_unit_price) ?> |
+ = number_format((int) $item->boi_qty_box) ?> |
+ = number_format((int) $item->boi_qty_sheet) ?> |
+ = number_format((float) $item->boi_amount) ?> |
+
+ boi_qty_box;
+ $totalQtySheet += (int) $item->boi_qty_sheet;
+ $totalAmount += (float) $item->boi_amount;
+ ?>
+
+
+ | 등록된 품목이 없습니다. |
+
+
+
+
+ | 합계 |
+ = number_format($totalQtyBox) ?> |
+ = number_format($totalQtySheet) ?> |
+ = number_format($totalAmount) ?> |
+
+
+
+
diff --git a/app/Views/admin/bag_order/index.php b/app/Views/admin/bag_order/index.php
new file mode 100644
index 0000000..39128b1
--- /dev/null
+++ b/app/Views/admin/bag_order/index.php
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+ | 번호 |
+ LOT번호 |
+ 발주일 |
+ 제작업체 |
+ 입고처 |
+ 품목수 |
+ 총수량 |
+ 총금액 |
+ 상태 |
+ 작업 |
+
+
+
+
+
+ | = esc($row->bo_idx) ?> |
+ = esc($row->bo_lot_no) ?> |
+ = esc($row->bo_order_date) ?> |
+ = esc($companyMap[$row->bo_company_idx] ?? '') ?> |
+ = esc($agencyMap[$row->bo_agency_idx] ?? '') ?> |
+ = number_format((int) ($itemSummary[$row->bo_idx]['count'] ?? 0)) ?> |
+ = number_format((int) ($itemSummary[$row->bo_idx]['qty'] ?? 0)) ?> |
+ = number_format((int) ($itemSummary[$row->bo_idx]['amount'] ?? 0)) ?> |
+
+ '정상', 'cancelled' => '취소', 'deleted' => '삭제'];
+ echo esc($statusMap[$row->bo_status] ?? $row->bo_status);
+ ?>
+ |
+
+ 상세
+
+
+ |
+
+
+
+ | 등록된 발주가 없습니다. |
+
+
+
+
diff --git a/app/Views/admin/bag_receiving/create.php b/app/Views/admin/bag_receiving/create.php
new file mode 100644
index 0000000..e458833
--- /dev/null
+++ b/app/Views/admin/bag_receiving/create.php
@@ -0,0 +1,53 @@
+
+
diff --git a/app/Views/admin/bag_receiving/index.php b/app/Views/admin/bag_receiving/index.php
new file mode 100644
index 0000000..73040f3
--- /dev/null
+++ b/app/Views/admin/bag_receiving/index.php
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+ | 번호 |
+ 봉투코드 |
+ 봉투명 |
+ 박스수 |
+ 낱장수 |
+ 입고일 |
+ 구분 |
+ 등록일 |
+
+
+
+
+
+ | = esc($row->br_idx) ?> |
+ = esc($row->br_bag_code) ?> |
+ = esc($row->br_bag_name) ?> |
+ = number_format((int) $row->br_qty_box) ?> |
+ = number_format((int) $row->br_qty_sheet) ?> |
+ = esc($row->br_receive_date) ?> |
+ = esc($row->br_type) ?> |
+ = esc($row->br_regdate) ?> |
+
+
+
+ | 등록된 입고가 없습니다. |
+
+
+
+
diff --git a/e2e/phase3-order.spec.js b/e2e/phase3-order.spec.js
new file mode 100644
index 0000000..f8b1a1a
--- /dev/null
+++ b/e2e/phase3-order.spec.js
@@ -0,0 +1,64 @@
+// @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('P3: 발주 관리', () => {
+ test('발주 현황 접근', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/bag-orders');
+ await expect(page).toHaveURL(/\/admin\/bag-orders/);
+ });
+
+ test('발주 등록 폼', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/bag-orders/create');
+ await expect(page.locator('input[name="bo_order_date"]')).toBeVisible();
+ });
+
+ test('기간 필터 조회', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/bag-orders?start_date=2026-01-01&end_date=2026-12-31');
+ await expect(page).toHaveURL(/start_date/);
+ });
+});
+
+test.describe('P3: 입고 관리', () => {
+ test('입고 현황 접근', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/bag-receivings');
+ await expect(page).toHaveURL(/\/admin\/bag-receivings/);
+ });
+
+ test('입고 처리 폼', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/bag-receivings/create');
+ await expect(page.locator('select[name="br_bo_idx"]')).toBeVisible();
+ });
+});
+
+test.describe('P3: 재고 현황', () => {
+ test('재고 현황 접근', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/admin/bag-inventory');
+ await expect(page).toHaveURL(/\/admin\/bag-inventory/);
+ });
+});
+
+test.describe('P3: 지자체관리자 접근', () => {
+ test('발주/입고/재고 접근 가능', async ({ page }) => {
+ await login(page, 'local');
+ await page.goto('/admin/bag-orders');
+ await expect(page).toHaveURL(/\/admin\/bag-orders/);
+ await page.goto('/admin/bag-receivings');
+ await expect(page).toHaveURL(/\/admin\/bag-receivings/);
+ await page.goto('/admin/bag-inventory');
+ await expect(page).toHaveURL(/\/admin\/bag-inventory/);
+ });
+});
diff --git a/jobs.md b/jobs.md
index 7466c5f..c5b3212 100644
--- a/jobs.md
+++ b/jobs.md
@@ -39,22 +39,22 @@
| P2-04 | 지자체별 봉투 단가 조회 (기간별) | 중간 | P2-03 | **완료** |
| P2-05 | 포장 단위 관리 (박스/팩/낱장) | 높음 | P2-01 | **완료** |
| P2-06 | 포장 단위 조회 (기간별) | 중간 | P2-05 | **완료** |
-| P2-07 | 판매 대행소 관리 + 지자체 연결 | 중간 | — | 대기 |
-| P2-08 | 판매 대행소 조회 | 낮음 | P2-07 | 대기 |
-| P2-09 | 담당자 관리 (소속별 CRUD) | 중간 | — | 대기 |
-| P2-10 | 담당자 조회 / 인쇄 | 낮음 | P2-09 | 대기 |
-| P2-11 | 업체 관리 (협회/제작업체/회수업체) | 중간 | — | 대기 |
-| P2-12 | 업체 조회 / 인쇄 | 낮음 | P2-11 | 대기 |
-| P2-13 | 무료용 대상자 관리 (CRUD) | 중간 | — | 대기 |
-| P2-14 | 무료용 대상자 조회 / 인쇄 | 낮음 | P2-13 | 대기 |
+| P2-07 | 판매 대행소 관리 + 지자체 연결 | 중간 | — | **완료** |
+| P2-08 | 판매 대행소 조회 | 낮음 | P2-07 | **완료** |
+| P2-09 | 담당자 관리 (소속별 CRUD) | 중간 | — | **완료** |
+| P2-10 | 담당자 조회 / 인쇄 | 낮음 | P2-09 | **완료** |
+| P2-11 | 업체 관리 (협회/제작업체/회수업체) | 중간 | — | **완료** |
+| P2-12 | 업체 조회 / 인쇄 | 낮음 | P2-11 | **완료** |
+| P2-13 | 무료용 대상자 관리 (CRUD) | 중간 | — | **완료** |
+| P2-14 | 무료용 대상자 조회 / 인쇄 | 낮음 | P2-13 | **완료** |
| P2-15 | 지정판매소 다조건 조회 + 엑셀 + 인쇄 | 중간 | — | 대기 |
| P2-16 | 지정판매소 바코드 출력 | 낮음 | P2-15 | 대기 |
| P2-17 | 지정판매소 지도 표시 | 낮음 | — | 대기 |
| P2-18 | 지정판매소 현황 (신규/취소) | 낮음 | — | 대기 |
-| P2-19 | 지자체 수정/삭제 기능 | 중간 | — | 대기 |
-| P2-20 | PASSWORD 변경 기능 | 중간 | — | 대기 |
-| P2-21 | 로그인 5회 실패 lock | 중간 | — | 대기 |
-| P2-22 | 카카오 주소 검색 API 연동 | 중간 | — | 대기 |
+| P2-19 | 지자체 수정/삭제 기능 | 중간 | — | **완료** |
+| P2-20 | PASSWORD 변경 기능 | 중간 | — | **완료** |
+| P2-21 | 로그인 5회 실패 lock | 중간 | — | **완료** |
+| P2-22 | 카카오 주소 검색 API 연동 | 중간 | — | Phase 3+ |
### Phase 3 — 발주/입고/재고
diff --git a/writable/database/order_tables.sql b/writable/database/order_tables.sql
new file mode 100644
index 0000000..6110b0c
--- /dev/null
+++ b/writable/database/order_tables.sql
@@ -0,0 +1,73 @@
+-- ============================================
+-- 발주/입고 관리 테이블 (Phase 3)
+-- ============================================
+
+-- 발주 (P3-01~04)
+CREATE TABLE IF NOT EXISTS `bag_order` (
+ `bo_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `bo_uuid` CHAR(36) NOT NULL COMMENT 'UUID v4',
+ `bo_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '발주 버전',
+ `bo_lg_idx` INT UNSIGNED NOT NULL COMMENT '지자체 FK',
+ `bo_gugun_code` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '구/군 코드',
+ `bo_dong_code` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '동 코드',
+ `bo_company_idx` INT UNSIGNED NULL COMMENT '제작업체 FK (company)',
+ `bo_agency_idx` INT UNSIGNED NULL COMMENT '입고처(대행소) FK (sales_agency)',
+ `bo_fee_rate` DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '수수료율(%)',
+ `bo_order_date` DATE NOT NULL COMMENT '발주일',
+ `bo_lot_no` VARCHAR(50) NOT NULL DEFAULT '' COMMENT 'LOT 번호',
+ `bo_hash` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'SHA-256 해시',
+ `bo_status` VARCHAR(10) NOT NULL DEFAULT 'normal' COMMENT 'normal/cancelled/deleted',
+ `bo_orderer_idx` INT UNSIGNED NULL COMMENT '발주자 mb_idx',
+ `bo_regdate` DATETIME NOT NULL,
+ `bo_moddate` DATETIME NULL DEFAULT NULL,
+ PRIMARY KEY (`bo_idx`),
+ UNIQUE KEY `uk_bo_uuid_ver` (`bo_uuid`, `bo_version`),
+ KEY `idx_bo_lg_idx` (`bo_lg_idx`),
+ KEY `idx_bo_status` (`bo_status`),
+ KEY `idx_bo_order_date` (`bo_order_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='발주';
+
+-- 발주 상세 (봉투 종류별 수량)
+CREATE TABLE IF NOT EXISTS `bag_order_item` (
+ `boi_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `boi_bo_idx` INT UNSIGNED NOT NULL COMMENT 'bag_order FK',
+ `boi_bag_code` VARCHAR(50) NOT NULL COMMENT '봉투코드(code_detail O)',
+ `boi_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
+ `boi_unit_price` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '발주 단가',
+ `boi_qty_box` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '발주 박스 수',
+ `boi_qty_sheet` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '낱장 환산 수량',
+ `boi_amount` DECIMAL(14,2) NOT NULL DEFAULT 0 COMMENT '금액(단가*낱장수)',
+ PRIMARY KEY (`boi_idx`),
+ KEY `idx_boi_bo_idx` (`boi_bo_idx`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='발주 상세';
+
+-- 입고 (P3-06~09)
+CREATE TABLE IF NOT EXISTS `bag_receiving` (
+ `br_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `br_bo_idx` INT UNSIGNED NOT NULL COMMENT 'bag_order FK',
+ `br_lg_idx` INT UNSIGNED NOT NULL,
+ `br_bag_code` VARCHAR(50) NOT NULL,
+ `br_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
+ `br_qty_box` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '입고 박스 수',
+ `br_qty_sheet` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '입고 낱장 수',
+ `br_receive_date` DATE NOT NULL,
+ `br_receiver_idx` INT UNSIGNED NULL COMMENT '인수자 mb_idx',
+ `br_sender_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '인계자명',
+ `br_type` VARCHAR(20) NOT NULL DEFAULT 'scanner' COMMENT 'scanner/batch',
+ `br_regdate` DATETIME NOT NULL,
+ PRIMARY KEY (`br_idx`),
+ KEY `idx_br_bo_idx` (`br_bo_idx`),
+ KEY `idx_br_lg_idx` (`br_lg_idx`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='발주 입고';
+
+-- 재고 (P3-10)
+CREATE TABLE IF NOT EXISTS `bag_inventory` (
+ `bi_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `bi_lg_idx` INT UNSIGNED NOT NULL,
+ `bi_bag_code` VARCHAR(50) NOT NULL,
+ `bi_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
+ `bi_qty` INT NOT NULL DEFAULT 0 COMMENT '현재 재고(낱장)',
+ `bi_updated_at` DATETIME NOT NULL,
+ PRIMARY KEY (`bi_idx`),
+ UNIQUE KEY `uk_bi_lg_bag` (`bi_lg_idx`, `bi_bag_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='봉투 재고';