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 @@ + + 재고 현황 + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
번호봉투코드봉투명현재재고(낱장)최종갱신
bi_idx) ?>bi_bag_code) ?>bi_bag_name) ?>bi_qty) ?>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 @@ +
+
+ ← 발주 목록 + | + 발주 상세 — bo_lot_no) ?> +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UUIDbo_uuid) ?>
버전bo_version) ?>
발주일bo_order_date) ?>
제작업체
입고처
LOT번호bo_lot_no) ?>
수수료율bo_fee_rate) ?>%
상태 + '정상', 'cancelled' => '취소', 'deleted' => '삭제']; + echo esc($statusMap[$order->bo_status] ?? $order->bo_status); + ?> +
해시bo_hash) ?>
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + boi_qty_box; + $totalQtySheet += (int) $item->boi_qty_sheet; + $totalAmount += (float) $item->boi_amount; + ?> + + + + + + + + + + + + + +
봉투코드봉투명단가박스수낱장수금액
boi_bag_code) ?>boi_bag_name) ?>boi_unit_price) ?>boi_qty_box) ?>boi_qty_sheet) ?>boi_amount) ?>
등록된 품목이 없습니다.
합계
+
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번호발주일제작업체입고처품목수총수량총금액상태작업
bo_idx) ?>bo_lot_no) ?>bo_order_date) ?>bo_company_idx] ?? '') ?>bo_agency_idx] ?? '') ?>bo_idx]['count'] ?? 0)) ?>bo_idx]['qty'] ?? 0)) ?>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 @@ +
+
+ 입고 현황 + 입고 처리 +
+
+
+
+ + + + + + 초기화 +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
번호봉투코드봉투명박스수낱장수입고일구분등록일
br_idx) ?>br_bag_code) ?>br_bag_name) ?>br_qty_box) ?>br_qty_sheet) ?>br_receive_date) ?>br_type) ?>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='봉투 재고';