사이트·관리자 봉투 물류 기능(수불·통계·레포트·재고·발주)과 DB·메뉴·E2E를 운영 반영한다.

통계 분석(전년대비·월별·계절별), 수급계획·LOT 수불, 지정판매소·실사·메뉴 링크 등을 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
taekyoungc
2026-06-01 16:15:15 +09:00
parent 21e7b91871
commit 0f1d414f37
129 changed files with 18068 additions and 1585 deletions

View File

@@ -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');

View File

@@ -43,8 +43,9 @@ class BagInventory extends BaseController
];
}
export_csv(
'재고현황_' . date('Ymd') . '.csv',
export_xlsx(
'재고현황_' . date('Ymd') . '.xlsx',
'재고현황',
['번호', '봉투코드', '봉투명', '현재재고(낱장)', '최종갱신'],
$rows
);

View File

@@ -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<int,array{issueCode:string,qty:int}>
*/
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();

View File

@@ -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

View File

@@ -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,

View File

@@ -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("

View File

@@ -16,6 +16,19 @@ class FreeRecipient extends BaseController
$this->model = model(FreeRecipientModel::class);
}
/**
* 무료용 대상 구분(스크린샷 기준): 사람뿐 아니라 동사무소 자체도 등록 가능.
*
* @return array<string,string>
*/
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'),
]);
}

View File

@@ -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', '순서가 적용되었습니다.');

View File

@@ -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,

File diff suppressed because it is too large Load Diff

View File

@@ -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') ?? [];

File diff suppressed because it is too large Load Diff

View File

@@ -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(),
]),
]);
}
/**
* 재고 조회(수불) 화면 (목업)
*/

View File

@@ -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/')) {

View File

@@ -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[] = '<?xml version="1.0" encoding="UTF-8"?>';
$parts[] = '<?mso-application progid="Excel.Sheet"?>';
$parts[] = '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">';
$parts[] = '<Worksheet ss:Name="' . $esc($safeSheet) . '">';
$parts[] = '<Table>';
$parts[] = '<Row>';
foreach ($headers as $h) {
$parts[] = '<Cell><Data ss:Type="String">' . $esc($h) . '</Data></Cell>';
}
$parts[] = '</Row>';
foreach ($rows as $row) {
$parts[] = '<Row>';
foreach (array_values((array) $row) as $cell) {
$parts[] = '<Cell><Data ss:Type="String">' . $esc($cell) . '</Data></Cell>';
}
$parts[] = '</Row>';
}
$parts[] = '</Table>';
$parts[] = '</Worksheet>';
$parts[] = '</Workbook>';
$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<array{name: string, headers: list<string>, rows: list<list<string>>, col_widths?: list<int>}> $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[] = '<?xml version="1.0" encoding="UTF-8"?>';
$parts[] = '<?mso-application progid="Excel.Sheet"?>';
$parts[] = '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">';
$parts[] = '<Styles>';
$parts[] = '<Style ss:ID="Default"><Alignment ss:Vertical="Top" ss:WrapText="1" ss:Horizontal="Left"/><Font ss:FontName="맑은 고딕" x:CharSet="129" ss:Size="9"/></Style>';
$parts[] = '<Style ss:ID="Header"><Font ss:Bold="1" ss:Size="9" ss:FontName="맑은 고딕" x:CharSet="129"/><Interior ss:Color="#F3F4F6" ss:Pattern="Solid"/><Alignment ss:Horizontal="Left" ss:Vertical="Center" ss:WrapText="1"/><Borders><Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1"/></Borders></Style>';
$parts[] = '</Styles>';
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[] = '<Worksheet ss:Name="' . $sheetName . '">';
$parts[] = '<WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel"><PageSetup><Layout x:Orientation="Landscape"/></PageSetup></WorksheetOptions>';
$parts[] = '<Table>';
$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[] = '<Column ss:Index="' . ($i + 1) . '" ss:AutoFitWidth="0" ss:Width="' . $excelW . '"/>';
}
$parts[] = '<Row ss:StyleID="Header">';
foreach ($headers as $h) {
$parts[] = '<Cell ss:StyleID="Header"><Data ss:Type="String">' . $esc($h) . '</Data></Cell>';
}
$parts[] = '</Row>';
foreach ($rows as $row) {
$parts[] = '<Row>';
foreach (array_values((array) $row) as $cell) {
$parts[] = '<Cell ss:StyleID="Default"><Data ss:Type="String">' . $esc($cell) . '</Data></Cell>';
}
$parts[] = '</Row>';
}
$parts[] = '</Table>';
$parts[] = '</Worksheet>';
}
$parts[] = '</Workbook>';
$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<array<string, mixed>> $reportRows
* @param list<string> $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<array<string, mixed>> $reportRows
* @param list<string> $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;
}
}

View File

@@ -0,0 +1,659 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* 통계 분석 관리 (전년대비 / 월별·계절별 추이)
*/
class BagAnalyticsReportBuilder
{
private \CodeIgniter\Database\BaseConnection $db;
/** @var array<string, string> */
private array $bagNames = [];
/**
* @return array<string, array{label: string, months_label: string, months: list<int>, 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<array{code: string, name: string}>,
* agencies: list<object>,
* 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<array<string, mixed>>,
* months: list<int>,
* 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<array<string, mixed>>, meta: array<string, int>}
*/
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<array<string, mixed>>
*/
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<string> $codesFromAgg
* @return list<string>
*/
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<string, array<int, array<int, array{qty: float, amt: float}>>>
*/
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<string, array<int, array<int, array{qty: float, amt: float}>>> $agg
* @param list<int> $months
* @return array<string, mixed>
*/
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<array<string, mixed>>
*/
/**
* @return array<string, string> 구·군코드 → 대행소명
*/
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<int, float>
*/
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<int, float>
*/
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<int> $months
* @return array<int, float>
*/
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;
}
}

View File

@@ -0,0 +1,463 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* 기간별 봉투 수불 현황 집계 (bag/flow)
*/
class BagFlowReportBuilder
{
private \CodeIgniter\Database\BaseConnection $db;
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
{
$this->db = $db ?? \Config\Database::connect();
}
private static function bagCodeKey(mixed $code): string
{
return (string) $code;
}
/**
* @return array{
* rows: list<array<string, mixed>>,
* bagKindLabels: array<string, string>,
* 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<string, string> 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<string, string>
*/
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<string> $codes
* @return array<string, array<string, array<string, int>>> 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<string, string> $products
* @param array<string, array<string, int>> $opening date key '_open'
* @param array<string, array<string, array<string, int>>> $periodMoves
* @param array<string, string> $bagKindLabels
* @return list<array<string, mixed>>
*/
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<string, array<string, array<string, int>>> $openingRaw
* @return array<string, int> 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<string, string> $products
* @param array<string, array<string, array<string, int>>> $opening
* @param array<string, array<string, array<string, int>>> $periodMoves
* @param array<string, string> $bagKindLabels
* @return list<array<string, mixed>>
*/
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<string, int|float> $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<string, int>
*/
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<string, int> $m
* @return array<string, int>
*/
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<string, int> $a
* @param array<string, int> $b
* @return array<string, int>
*/
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;
}
}

View File

@@ -0,0 +1,673 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* LOT 수불 조회 (레거시 w_gd033a)
* — 바코드(팩/박스/낱장) 또는 LOT 번호로 일자·입출고처·구분 이력
*/
class BagLotFlowBuilder
{
private \CodeIgniter\Database\BaseConnection $db;
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
{
$this->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<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
* }
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<int>, 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<string, mixed> $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<int>, 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<string, mixed> $resolved
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
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<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
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<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
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<string> $codes
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
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<string> $codes
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
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<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
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<string, mixed> $order
* @return array<string, mixed>
*/
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<string, mixed> $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<array{kind: string, code: string, bag_name: string, lot_no: string, state: string, hint: string}>
*/
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;
}
}

View File

@@ -0,0 +1,494 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* 쓰레기봉투 수급 계획 (레거시 w_gm820r 유추)
*
* - 바코드 봉투: bag_receiving_pack_code 에 등록된 품목
* - 기존 봉투: 그 외 (수기·비바코드 재고)
* - 소진일수 ≈ (총재고 / 월판매량) × 30
* - 발주예정일 = 기준일 + 소진일수 적정재고보유일수(제작기일)
* - 긴급(발주예정일 ≤ 기준일): 발주수량 ≈ max(0, 월판매량×18 총재고)
*/
class BagSupplyPlanBuilder
{
private const AVG_SALES_MONTHS = 12;
/** 긴급 발주 시 목표 보유 개월 수 (레거시 화면 값 유추) */
private const URGENT_REPLENISH_MONTHS = 18;
private \CodeIgniter\Database\BaseConnection $db;
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
{
$this->db = $db ?? \Config\Database::connect();
}
/**
* @return array{
* rows: list<array<string, mixed>>,
* barcodeCodes: array<string, bool>,
* 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<string, bool>
*/
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<string, string> 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<string, int>
*/
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<string, int>
*/
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<string, array{order_date: string, qty_sheet: int, bag_name: string}>
*/
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<string, array{order_date: string, qty_sheet: int, bag_name: string}> $lastOrders
* @return array<string, array{sale: int, recv: int, issue: int}>
*/
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<string, bool> $barcodeCodes
* @return array<string, float>
*/
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);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Libraries\Blockchain;
use App\Models\BlockchainLedgerModel;
class SqlLedger
{
private BlockchainLedgerModel $ledgerModel;
public function __construct()
{
$this->ledgerModel = model(BlockchainLedgerModel::class);
}
/**
* @param array<string,mixed> $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<string,mixed> $data
* @return array<string,mixed>
*/
private function normalizeArray(array $data): array
{
ksort($data);
foreach ($data as $key => $value) {
if (is_array($value)) {
if ($this->isAssoc($value)) {
/** @var array<string,mixed> $assoc */
$assoc = $value;
$data[$key] = $this->normalizeArray($assoc);
} else {
$normalizedList = [];
foreach ($value as $item) {
if (is_array($item) && $this->isAssoc($item)) {
/** @var array<string,mixed> $assoc */
$assoc = $item;
$normalizedList[] = $this->normalizeArray($assoc);
} else {
$normalizedList[] = $item;
}
}
$data[$key] = $normalizedList;
}
}
}
return $data;
}
/**
* @param array<mixed> $array
*/
private function isAssoc(array $array): bool
{
if ($array === []) {
return false;
}
return array_keys($array) !== range(0, count($array) - 1);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class BagIssueItemCodeModel extends Model
{
protected $table = 'bag_issue_item_code';
protected $primaryKey = 'bic_idx';
protected $returnType = 'object';
protected $useTimestamps = false;
protected $allowedFields = [
'bic_lg_idx',
'bic_bi2_idx',
'bic_bag_code',
'bic_issue_code',
'bic_qty',
'bic_cancel_qty',
'bic_state',
'bic_regdate',
];
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class BlockchainLedgerModel extends Model
{
protected $table = 'blockchain_ledger';
protected $primaryKey = 'bl_idx';
protected $returnType = 'object';
protected $useTimestamps = false;
protected $allowedFields = [
'bl_created_at',
'bl_tx_type',
'bl_entity_uuid',
'bl_entity_version',
'bl_payload',
'bl_previous_hash',
'bl_hash',
'bl_nonce',
'bl_actor_idx',
'bl_lg_idx',
];
}

View File

@@ -53,7 +53,11 @@ class CodeDetailModel extends Model
$this->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();
}
/**

View File

@@ -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',

View File

@@ -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();
}
}

View File

@@ -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<string, object>
*/
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;
}
}

View File

@@ -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',
];
}

View File

@@ -31,7 +31,6 @@
<th>봉투코드</th>
<th>봉투명</th>
<th>수량</th>
<th class="w-20">상태</th>
<th class="w-24">작업</th>
</tr>
</thead>
@@ -47,7 +46,6 @@
<td class="text-center font-mono"><?= esc($row->bi2_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bi2_bag_name) ?></td>
<td><?= number_format((int) $row->bi2_qty) ?></td>
<td class="text-center"><?= esc($row->bi2_status) ?></td>
<td class="text-center">
<form action="<?= mgmt_url('bag-issues/cancel/' . (int) $row->bi2_idx) ?>" method="POST" class="inline" onsubmit="return confirm('취소하시겠습니까?');">
<?= csrf_field() ?>
@@ -57,7 +55,7 @@
</tr>
<?php endforeach; ?>
<?php if (empty($list)): ?>
<tr><td colspan="11" class="text-center text-gray-400 py-4">등록된 불출이 없습니다.</td></tr>
<tr><td colspan="10" class="text-center text-gray-400 py-4">등록된 불출이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>

View File

@@ -21,7 +21,7 @@
return $ym;
};
?>
<?= view('components/print_header', ['printTitle' => '봉투 발주 현황', 'printShowApproval' => false]) ?>
<?= view('components/print_header', ['printTitle' => '봉투 발주 현황']) ?>
<section class="no-print border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<div class="flex items-center gap-2">

View File

@@ -1,4 +1,4 @@
<?= view('components/print_header', ['printTitle' => '봉투 단가 관리', 'printShowApproval' => false]) ?>
<?= view('components/print_header', ['printTitle' => '봉투 단가 관리']) ?>
<style>
@media print {
.no-print { display: none !important; }

View File

@@ -31,6 +31,13 @@ $totalPages = count($chunks);
</style>
</head>
<body>
<?= view('components/print_header', [
'printTitle' => '지정판매소 바코드',
'printExtraLines' => [
'구역: ' . $zoneLabel,
'출력일: ' . $printedAt,
],
]) ?>
<?php if ($rows === []): ?>
<div class="page">
<h1 class="title">지정판매소 바코드</h1>

View File

@@ -0,0 +1,87 @@
<?php
$readOnly = ! empty($readOnly);
$listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
?>
<?= view('components/print_header', ['printTitle' => $readOnly ? '지정판매소 조회 목록' : '지정판매소 목록']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span>
<div class="flex items-center gap-2">
<?php if ($readOnly): ?>
<a href="<?= mgmt_url('designated-shops/export') ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<?php endif; ?>
<?php if (! $readOnly): ?>
<a href="<?= mgmt_url('designated-shops/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">지정판매소 등록</a>
<?php endif; ?>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= mgmt_url($listBasePath) ?>" class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-gray-700 mr-1">지정판매소 검색</span>
<label class="text-sm text-gray-600">상호명</label>
<input type="text" name="ds_name" value="<?= esc($dsName ?? '') ?>" placeholder="상호명" class="border border-gray-300 rounded px-2 py-1 text-sm w-36"/>
<label class="text-sm text-gray-600">구군코드</label>
<select name="ds_gugun_code" class="border border-gray-300 rounded px-2 py-1 text-sm">
<option value="">전체</option>
<?php foreach (($gugunCodes ?? []) as $gc): ?>
<option value="<?= esc($gc->ds_gugun_code) ?>" <?= ($dsGugunCode ?? '') === $gc->ds_gugun_code ? 'selected' : '' ?>><?= esc($gc->ds_gugun_code) ?></option>
<?php endforeach; ?>
</select>
<label class="text-sm text-gray-600">상태</label>
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
<option value="">전체</option>
<option value="1" <?= ($dsState ?? '') === '1' ? 'selected' : '' ?>>정상</option>
<option value="2" <?= ($dsState ?? '') === '2' ? 'selected' : '' ?>>폐업</option>
<option value="3" <?= ($dsState ?? '') === '3' ? 'selected' : '' ?>>직권해지</option>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= mgmt_url($listBasePath) ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">초기화</a>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th class="w-16">번호</th>
<th>지자체</th>
<th>판매소번호</th>
<th>상호명</th>
<th>대표자</th>
<th>사업자번호</th>
<th>가상계좌</th>
<th>상태</th>
<th>등록일</th>
<?php if (! $readOnly): ?>
<th class="w-28">작업</th>
<?php endif; ?>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($list as $row): ?>
<tr>
<td class="text-center"><?= esc($row->ds_idx) ?></td>
<td class="text-left pl-2"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
<td class="text-left pl-2"><?= esc($row->ds_shop_no) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_name) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_rep_name) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_biz_no) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_va_number) ?></td>
<td class="text-center"><?= (int) $row->ds_state === 1 ? '정상' : ((int) $row->ds_state === 2 ? '폐업' : '직권해지') ?></td>
<td class="text-left pl-2"><?= esc($row->ds_regdate ?? '') ?></td>
<?php if (! $readOnly): ?>
<td class="text-center">
<a href="<?= mgmt_url('designated-shops/edit/' . (int) $row->ds_idx) ?>" class="text-blue-600 hover:underline text-sm">수정</a>
<form action="<?= mgmt_url('designated-shops/delete/' . (int) $row->ds_idx) ?>" method="POST" class="inline ml-1" onsubmit="return confirm('이 지정판매소를 삭제하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
</form>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,5 +1,5 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">대상 등록</span>
<span class="text-sm font-bold text-gray-700">무료용 대상 등록</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('free-recipients/store') ?>" method="POST" class="space-y-4">
@@ -9,29 +9,19 @@
<label class="block text-sm font-bold text-gray-700 w-28">구분 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_type_code" required>
<option value="">선택</option>
<?php foreach ($typeCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('fr_type_code') === $cd->cd_code ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?>
<?php foreach (($recipientTypeOptions ?? []) as $typeCode => $typeName): ?>
<option value="<?= esc((string) $typeCode) ?>" <?= old('fr_type_code') === (string) $typeCode ? 'selected' : '' ?>>
<?= esc((string) $typeName) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">대상자명 <span class="text-red-500">*</span></label>
<label class="block text-sm font-bold text-gray-700 w-28">명 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_name" type="text" value="<?= esc(old('fr_name')) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">연락처</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_phone" type="text" value="<?= esc(old('fr_phone')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96" name="fr_addr" type="text" value="<?= esc(old('fr_addr')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">동코드</label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_dong_code">
@@ -52,6 +42,7 @@
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">종료일</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="fr_end_date" type="date" value="<?= esc(old('fr_end_date')) ?>"/>
<span class="text-xs text-gray-500">미입력 시 계속 유효</span>
</div>
<div class="flex gap-2 pt-2">

View File

@@ -1,5 +1,5 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">대상 수정</span>
<span class="text-sm font-bold text-gray-700">무료용 대상 수정</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('free-recipients/update/' . (int) $item->fr_idx) ?>" method="POST" class="space-y-4">
@@ -9,29 +9,19 @@
<label class="block text-sm font-bold text-gray-700 w-28">구분 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_type_code" required>
<option value="">선택</option>
<?php foreach ($typeCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('fr_type_code', $item->fr_type_code) === $cd->cd_code ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?>
<?php foreach (($recipientTypeOptions ?? []) as $typeCode => $typeName): ?>
<option value="<?= esc((string) $typeCode) ?>" <?= old('fr_type_code', $item->fr_type_code) === (string) $typeCode ? 'selected' : '' ?>>
<?= esc((string) $typeName) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">대상자명 <span class="text-red-500">*</span></label>
<label class="block text-sm font-bold text-gray-700 w-28">명 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_name" type="text" value="<?= esc(old('fr_name', $item->fr_name)) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">연락처</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_phone" type="text" value="<?= esc(old('fr_phone', $item->fr_phone)) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96" name="fr_addr" type="text" value="<?= esc(old('fr_addr', $item->fr_addr)) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">동코드</label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_dong_code">
@@ -52,6 +42,7 @@
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">종료일</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="fr_end_date" type="date" value="<?= esc(old('fr_end_date', $item->fr_end_date)) ?>"/>
<span class="text-xs text-gray-500">미입력 시 계속 유효</span>
</div>
<div class="flex flex-wrap items-center gap-2">

View File

@@ -13,24 +13,36 @@
<thead>
<tr>
<th class="w-16">번호</th>
<th>대상자명</th>
<th>연락처</th>
<th>주소</th>
<th>비고</th>
<th>종료일</th>
<th class="w-28">동코드</th>
<th class="w-40">구분</th>
<th>명칭</th>
<th class="w-28">종료일자</th>
<th class="w-48">비고</th>
<th class="w-20">상태</th>
<th class="w-36">작업</th>
</tr>
</thead>
<tbody>
<?php
$total = (int) ($totalCount ?? count($list));
$page = max(1, (int) ($currentPage ?? 1));
$size = max(1, (int) ($perPage ?? max(1, count($list))));
$rowNo = $total - (($page - 1) * $size);
?>
<?php foreach ($list as $row): ?>
<?php
$typeCode = (string) ($row->fr_type_code ?? '');
$typeName = (string) (($recipientTypeOptions[$typeCode] ?? '') ?: $typeCode);
$dongCode = (string) ($row->fr_dong_code ?? '');
$dongLabel = $dongCode !== '' ? (string) (($dongNameMap[$dongCode] ?? $dongCode) . ' (' . $dongCode . ')') : '-';
?>
<tr>
<td class="text-center"><?= esc($row->fr_idx) ?></td>
<td class="text-center"><?= esc((string) $rowNo) ?></td>
<td class="text-center"><?= esc($dongLabel) ?></td>
<td class="text-center"><?= esc($typeName) ?></td>
<td class="text-left pl-2"><?= esc($row->fr_name) ?></td>
<td class="text-center"><?= esc($row->fr_phone) ?></td>
<td class="text-left pl-2"><?= esc($row->fr_addr) ?></td>
<td class="text-center"><?= esc($row->fr_end_date ?: '9999.99.99') ?></td>
<td class="text-left pl-2"><?= esc($row->fr_note) ?></td>
<td class="text-center"><?= esc($row->fr_end_date) ?></td>
<td class="text-center"><?= (int) $row->fr_state === 1 ? '사용' : '미사용' ?></td>
<td class="text-center">
<a href="<?= mgmt_url('free-recipients/edit/' . (int) $row->fr_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">수정</a>
@@ -40,6 +52,7 @@
</form>
</td>
</tr>
<?php $rowNo--; ?>
<?php endforeach; ?>
<?php if (empty($list)): ?>
<tr>

View File

@@ -63,6 +63,11 @@ tailwind.config = {
}
}
</script>
<style data-purpose="global-font-scale">
/* 전체 텍스트 +2px 확대 (요청). 로고(.app-brand)는 16px 로 유지. */
html { font-size: 18px; }
.app-brand, .app-brand * { font-size: 16px; }
</style>
<style data-purpose="table-layout">
.data-table { width: 100%; border-collapse: collapse; font-family: 'Malgun Gothic', 'Noto Sans KR', sans-serif; }
.data-table th, .data-table td { border: 1px solid #ccc; padding: 4px 8px; white-space: nowrap; font-size: 13px; }

View File

@@ -8,11 +8,13 @@ $debugMode = (bool) ($debug_mode ?? false);
$debugInfo = is_array($debug_info ?? null) ? $debug_info : [];
helper('admin');
$adminMenusNavPath = current_nav_request_path();
// 사이트 메뉴(mt_code=site)는 업무 URL이 /bag/* — 관리 화면(admin/menus) 맥락으로 해석하면 admin/ 링크가 잘못 선택됨
$menuListResolvePath = ($mtCode === 'site') ? 'bag/dashboard' : $adminMenusNavPath;
/**
* 메뉴 관리 목록용: 저장된 mm_link → 실제 href (외부 http(s) 또는 base_url).
*/
$adminMenuListResolveHref = static function (string $rawLink) use ($adminMenusNavPath): string {
$adminMenuListResolveHref = static function (string $rawLink) use ($menuListResolvePath): string {
$rawLink = trim($rawLink);
if ($rawLink === '') {
return '';
@@ -20,7 +22,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($adminMenusNa
if (preg_match('#^https?://#i', $rawLink)) {
return $rawLink;
}
$pathSeg = menu_link_preferred_href_path($rawLink, $adminMenusNavPath);
$pathSeg = menu_link_preferred_href_path($rawLink, $menuListResolvePath);
if ($pathSeg === '') {
$pathSeg = normalize_menu_link_for_url($rawLink);
}

View File

@@ -1,106 +1,140 @@
<?= view('components/print_header', ['printTitle' => '일계표']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<?php
declare(strict_types=1);
/** @var list<array<string,mixed>> $tableRows */
/** @var string $date */
/** @var string $monthStart */
/** @var int $saIdx */
/** @var string $catFilter */
/** @var list<object> $agencies */
/** @var array<string,string> $catLabels */
/** @var bool $hasBsFee */
/** @var string $lgName */
/** @var string $agencyLabel */
/** @var string $catLabelFilter */
/** @var list<string> $printExtraLines */
$exportParams = array_filter([
'date' => $date ?? '',
'sa_idx' => (int) ($saIdx ?? 0),
'cat' => (string) ($catFilter ?? ''),
'export' => '1',
], static fn ($v): bool => $v !== '' && $v !== null);
$excelUrl = mgmt_url('reports/daily-summary?' . http_build_query($exportParams));
?>
<?= view('components/print_header', [
'printTitle' => '일계표',
'printExtraLines' => $printExtraLines ?? [],
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">일계표</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<div class="flex flex-wrap gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($excelUrl, 'attr') ?>" class="inline-flex items-center border border-green-600 text-green-700 px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/daily-summary') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">조회일</label>
<input type="date" name="date" value="<?= esc($date ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<section class="p-3 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= mgmt_url('reports/daily-summary') ?>" class="flex flex-wrap items-end gap-3 text-sm">
<div>
<label class="block text-gray-600 mb-0.5">조회일자</label>
<input type="date" name="date" value="<?= esc($date ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
</div>
<div>
<label class="block text-gray-600 mb-0.5">대행소</label>
<select name="sa_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[12rem] max-w-[20rem]">
<option value="0">전체</option>
<?php foreach ($agencies ?? [] as $agency): ?>
<?php $aid = (int) ($agency->sa_idx ?? 0); ?>
<option value="<?= esc((string) $aid) ?>" <?= (int) ($saIdx ?? 0) === $aid ? 'selected' : '' ?>>
<?= esc(trim((string) ($agency->sa_name ?? ''))) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">구분</label>
<select name="cat" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="" <?= ($catFilter ?? '') === '' ? 'selected' : '' ?>>전체</option>
<?php foreach (($catLabels ?? []) as $ck => $lab): ?>
<option value="<?= esc($ck, 'attr') ?>" <?= ($catFilter ?? '') === $ck ? 'selected' : '' ?>><?= esc($lab) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
</form>
</section>
<div class="flex gap-4 mt-2">
<!-- 당일 -->
<div class="flex-1 border border-gray-300 overflow-auto">
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5">
<span class="text-sm font-bold text-gray-700">당일 (<?= esc($date ?? '') ?>)</span>
</div>
<table class="w-full data-table">
<thead>
<tr>
<th>봉투코드</th>
<th>봉투명</th>
<th>판매수량</th>
<th>판매금액</th>
</tr>
</thead>
<tbody class="text-right">
<?php
$dailySaleQtyTotal = 0;
$dailySaleAmountTotal = 0;
?>
<?php foreach ($daily as $row): ?>
<?php
$dailySaleQtyTotal += (int) $row->sale_qty;
$dailySaleAmountTotal += (int) $row->sale_amount;
?>
<tr>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td>
<td><?= number_format((int) $row->sale_qty) ?></td>
<td><?= number_format((int) $row->sale_amount) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($daily)): ?>
<tr><td colspan="4" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot class="bg-gray-50 font-bold text-right">
<tr>
<td colspan="2" class="text-center">합계</td>
<td><?= number_format($dailySaleQtyTotal) ?></td>
<td><?= number_format($dailySaleAmountTotal) ?></td>
</tr>
</tfoot>
</table>
<section class="p-3 bg-white">
<style>
@media print {
.daily-summary-screen-title { display: none !important; }
}
</style>
<div class="mb-2 text-center daily-summary-screen-title no-print">
<h1 class="text-lg font-bold m-0">일계표</h1>
<p class="text-sm text-gray-700 m-1"><?= esc(trim(($lgName ?? '') . ' · 조회일: ' . ($date ?? '') . ' · 대행소: ' . ($agencyLabel ?? '') . ' · 구분: ' . ($catLabelFilter ?? ''))) ?></p>
<p class="text-xs text-gray-500 m-0">누계(월): <?= esc(($monthStart ?? '') . ' ~ ' . ($date ?? '')) ?> · (단위: 매 / 원)</p>
</div>
<!-- 당월 누계 -->
<div class="flex-1 border border-gray-300 overflow-auto">
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5">
<span class="text-sm font-bold text-gray-700">당월 누계 (<?= esc($monthStart ?? '') ?> ~ <?= esc($date ?? '') ?>)</span>
</div>
<table class="w-full data-table">
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table text-sm" id="daily-summary-table">
<thead>
<tr>
<th>봉투코드</th>
<th>봉투</th>
<th>판매수량</th>
<th>판매금액</th>
<th rowspan="2" class="align-middle">구분</th>
<th rowspan="2" class="align-middle">봉투종류</th>
<th colspan="4" class="text-center border-l border-gray-300">일계</th>
<th colspan="4" class="text-center border-l border-gray-300">누계(월)</th>
</tr>
<tr>
<th class="text-right border-l border-gray-300">수량</th>
<th class="text-right">판매금액</th>
<th class="text-right">수수료</th>
<th class="text-right">징수액</th>
<th class="text-right border-l border-gray-300">수량</th>
<th class="text-right">판매금액</th>
<th class="text-right">수수료</th>
<th class="text-right">징수액</th>
</tr>
</thead>
<tbody class="text-right">
<?php
$monthlySaleQtyTotal = 0;
$monthlySaleAmountTotal = 0;
?>
<?php foreach ($monthly as $row): ?>
<?php
$monthlySaleQtyTotal += (int) $row->sale_qty;
$monthlySaleAmountTotal += (int) $row->sale_amount;
?>
<tr>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td>
<td><?= number_format((int) $row->sale_qty) ?></td>
<td><?= number_format((int) $row->sale_amount) ?></td>
</tr>
<?php foreach ($tableRows ?? [] as $r): ?>
<?php
$kind = (string) ($r['kind'] ?? 'data');
$trClass = $kind === 'subtotal' ? 'bg-gray-50 font-semibold' : ($kind === 'grand' ? 'bg-amber-50 font-bold' : '');
$fmtFee = static function (float $v) use ($hasBsFee): string {
if (! $hasBsFee) {
return '—';
}
return $v != 0.0 ? number_format((int) round($v)) : '';
};
?>
<tr class="<?= esc($trClass, 'attr') ?>">
<?php if ($kind === 'grand'): ?>
<td colspan="2" class="text-center"><?= esc((string) ($r['bag_name'] ?? '합 계')) ?></td>
<?php else: ?>
<td class="text-left pl-2"><?= esc((string) ($r['cat_label'] ?? '')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($r['bag_name'] ?? '')) ?></td>
<?php endif; ?>
<td class="border-l border-gray-200 tabular-nums"><?= number_format((int) ($r['d_qty'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($r['d_amt'] ?? 0))) ?></td>
<td class="tabular-nums"><?= $fmtFee((float) ($r['d_fee'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($r['d_levy'] ?? 0))) ?></td>
<td class="border-l border-gray-200 tabular-nums"><?= number_format((int) ($r['m_qty'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($r['m_amt'] ?? 0))) ?></td>
<td class="tabular-nums"><?= $fmtFee((float) ($r['m_fee'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($r['m_levy'] ?? 0))) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($monthly)): ?>
<tr><td colspan="4" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php if (($tableRows ?? []) === []): ?>
<tr>
<td colspan="10" class="text-center text-gray-400 py-6">조회된 데이터가 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
<tfoot class="bg-gray-50 font-bold text-right">
<tr>
<td colspan="2" class="text-center">합계</td>
<td><?= number_format($monthlySaleQtyTotal) ?></td>
<td><?= number_format($monthlySaleAmountTotal) ?></td>
</tr>
</tfoot>
</table>
</div>
</div>
</section>

View File

@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
/** @var string $startDate */
/** @var string $endDate */
/** @var string $writeDate */
/** @var bool $searched */
/** @var list<string> $headers */
/** @var list<list<string>> $displayRows */
/** @var int $totalCount */
/** @var float $totalSupplyAmount */
/** @var float $totalTaxAmount */
/** @var int $missingBizCount */
/** @var string $lgName */
/** @var list<string> $printExtraLines */
/** @var list<array{label: string, sheet_name: string, cols: list<int>}> $hometaxPrintPages */
/** @var list<int> $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<int> $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);
?>
<table
class="w-full data-table text-xs <?= esc($tableExtraClass, 'attr') ?>"
id="<?= esc($tableId, 'attr') ?>"
<?= $forPrint ? 'data-hometax-print="1"' : '' ?>
>
<colgroup>
<?php foreach ($colIndices as $ci):
$wPct = $widthsForSet[$ci] ?? (string) round(100 / max(1, $sliceCount), 2) . '%';
$wPx = (int) ($hometaxColMinPx[$ci] ?? 56);
?>
<col style="width: <?= esc($wPct, 'attr') ?>;<?= $forPrint ? '' : ' min-width: ' . $wPx . 'px' ?>"/>
<?php endforeach; ?>
</colgroup>
<thead>
<tr>
<?php foreach ($colIndices as $ci): ?>
<th class="<?= esc($hometaxCellClass($ci), 'attr') ?>"><?= esc((string) ($headers[$ci] ?? '')) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody class="text-right">
<?php if (! ($searched ?? true)): ?>
<tr>
<td colspan="<?= (int) $sliceCount ?>" class="text-center text-gray-500 py-8">조회를 건너뛴 상태입니다. <strong>조회</strong>를 눌러 주세요.</td>
</tr>
<?php elseif (($displayRows ?? []) === []): ?>
<tr>
<td colspan="<?= (int) $sliceCount ?>" class="text-center text-gray-500 py-8">조회된 판매 내역이 없습니다.</td>
</tr>
<?php else: ?>
<?php foreach ($displayRows as $row): ?>
<tr>
<?php foreach ($colIndices as $ci):
$tdClass = 'tabular-nums text-left px-1 py-0.5 border-t border-gray-100 ' . $hometaxCellClass($ci);
?>
<td class="<?= esc(trim($tdClass), 'attr') ?>"><?= esc((string) ($row[$ci] ?? '')) ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<?php
};
?>
<?= view('components/print_header', [
'printTitle' => '홈택스 처리',
'printExtraLines' => $printExtraLines ?? [],
]) ?>
<section class="border-b border-gray-300 p-3 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h1 class="text-base font-bold text-gray-800">홈택스 처리</h1>
<div class="flex flex-wrap gap-2 items-center">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($excelUrl, 'attr') ?>" class="inline-flex items-center border border-green-600 text-green-700 px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div>
</div>
<form method="get" action="<?= esc(mgmt_url('reports/hometax-export'), 'attr') ?>" id="hometax-process-form" class="flex flex-wrap items-end gap-3 text-sm">
<input type="hidden" name="search" value="1"/>
<div>
<label class="block text-gray-600 mb-0.5">판매일자</label>
<div class="flex items-center gap-1">
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<span class="text-gray-500">~</span>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
</div>
</div>
<div>
<label class="block text-gray-600 mb-0.5">작성일자</label>
<input type="date" name="write_date" value="<?= esc($writeDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
</div>
<div class="pt-5">
<button type="submit" class="border border-blue-600 bg-blue-50 text-blue-800 px-4 py-1 rounded-sm text-sm font-medium hover:bg-blue-100 transition">조회</button>
</div>
</form>
</section>
<section class="p-3 bg-white border-b border-gray-200 hometax-report-section">
<style>
.hometax-print-only { display: none; }
@media print {
@page {
size: A4 landscape;
margin: 4mm 5mm;
}
.hometax-report-section {
padding: 0 !important;
border: none !important;
}
.hometax-screen-only {
display: none !important;
}
.hometax-print-only {
display: block !important;
}
.hometax-print-page {
page-break-after: always;
break-after: page;
}
.hometax-print-page:last-child {
page-break-after: auto;
break-after: auto;
}
.hometax-print-page-label {
font-size: 8pt;
font-weight: 700;
margin: 0 0 4px;
}
.hometax-print-scroll {
overflow: visible !important;
border: 1px solid #333 !important;
}
.hometax-print-table {
font-size: 8pt !important;
width: 100% !important;
table-layout: fixed !important;
}
.hometax-print-table th,
.hometax-print-table td {
padding: 3px 5px !important;
white-space: normal !important;
word-break: break-word;
overflow-wrap: break-word;
line-height: 1.3;
vertical-align: top;
}
.hometax-print-table th {
font-size: 7.5pt !important;
font-weight: 600;
}
.hometax-print-table .ht-num {
white-space: nowrap !important;
word-break: normal !important;
}
.hometax-print-table thead {
display: table-header-group;
}
.hometax-print-table tr {
page-break-inside: avoid;
break-inside: avoid;
}
}
@media screen {
#hometax-result-table {
width: max(100%, 4200px);
min-width: 4200px;
table-layout: fixed;
}
#hometax-result-table th,
#hometax-result-table td {
white-space: nowrap;
padding: 4px 6px !important;
}
#hometax-result-table .ht-wrap {
white-space: normal;
word-break: break-word;
max-width: 220px;
}
}
</style>
<div class="text-sm font-semibold text-gray-700 mb-2 no-print">조회결과</div>
<div class="hometax-screen-only hometax-scroll-wrap overflow-x-auto border border-gray-300" style="max-width: 100%;">
<?php
$hometaxRenderTable(
range(0, max(0, $colCount - 1)),
'',
'hometax-result-table',
false
);
?>
</div>
<div class="hometax-print-only" aria-hidden="true">
<?php foreach ($hometaxPrintPages as $ppi => $page):
$pageCols = $page['cols'];
if ($pageCols === []) {
continue;
}
?>
<section class="hometax-print-page">
<p class="hometax-print-page-label"><?= esc((string) ($page['label'] ?? '')) ?></p>
<div class="hometax-print-scroll">
<?php
$hometaxRenderTable(
$pageCols,
'hometax-print-table',
'hometax-print-table-' . (int) $ppi,
true
);
?>
</div>
</section>
<?php endforeach; ?>
</div>
<div class="mt-4 flex flex-wrap gap-6 text-sm border-t border-gray-200 pt-3 no-print">
<div><span class="text-gray-600">총 건수</span> <strong class="tabular-nums"><?= (int) ($totalCount ?? 0) ?></strong> 건</div>
<div><span class="text-gray-600">총 금액</span> <strong class="tabular-nums"><?= esc(number_format((int) round($totalGrand))) ?></strong> 원 <span class="text-gray-400 text-xs">(공급가액+세액)</span></div>
<div><span class="text-gray-600">사업자등록번호 없음</span> <strong class="tabular-nums text-amber-800"><?= (int) ($missingBizCount ?? 0) ?></strong> 건</div>
</div>
<div class="mt-2 text-xs text-gray-500 no-print print:hidden">
인쇄·엑셀저장은 동일하게 2쪽 열 구성입니다(1쪽: 공급자·공급받는자, 2쪽: 금액·품목). 요약·결재란은 인쇄용 헤더에 포함됩니다.
</div>
</section>
<style media="print">
.no-print { display: none !important; }
</style>
<script>
(function () {
let savedTitle = document.title;
function stamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '_' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
}
window.addEventListener('beforeprint', function () {
savedTitle = document.title;
document.title = '홈택스처리_' + stamp();
});
window.addEventListener('afterprint', function () {
document.title = savedTitle;
});
})();
</script>

View File

@@ -1,99 +1,216 @@
<?= view('components/print_header', ['printTitle' => 'LOT 수불 조회']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<?php
$barcode = (string) ($barcode ?? '');
$lotNo = (string) ($lotNo ?? '');
$result = is_array($result ?? null) ? $result : [];
$queried = (bool) ($queried ?? false);
$ok = (bool) ($result['ok'] ?? false);
$message = (string) ($result['message'] ?? '');
$rows = is_array($result['rows'] ?? null) ? $result['rows'] : [];
$unit = (string) ($result['unit'] ?? '');
$bagName = (string) ($result['bag_name'] ?? '');
$bagCode = (string) ($result['bag_code'] ?? '');
$lotLabel = (string) ($result['lot_no'] ?? $lotNo);
$qtyBox = (int) ($result['qty_box'] ?? 0);
$qtyPack = (int) ($result['qty_pack'] ?? 0);
$qtySheet = (int) ($result['qty_sheet'] ?? 0);
$testSamples = is_array($testSamples ?? null) ? $testSamples : [];
$printExtra = [];
if ($queried && $barcode !== '') {
$printExtra[] = '봉투번호(바코드): ' . $barcode;
}
if ($bagName !== '' || $bagCode !== '') {
$printExtra[] = '품목: ' . trim($bagName . ($bagCode !== '' ? ' (' . $bagCode . ')' : ''));
}
?>
<?= view('components/print_header', [
'printTitle' => 'LOT 수불 조회',
'printExtraLines' => $printExtra,
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">LOT 수불 조회</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/lot-flow') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">LOT 번호</label>
<input type="text" name="lot_no" value="<?= esc($lotNo ?? '') ?>" placeholder="LOT-YYYYMMDD-XXXXXX" class="border border-gray-300 rounded px-2 py-1 text-sm w-64"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
</section>
<?php if ($lotNo !== '' && $order): ?>
<!-- 발주 정보 -->
<div class="border border-gray-300 p-3 mt-2 bg-gray-50">
<h3 class="text-sm font-bold text-gray-700 mb-2">발주 정보</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
<div><span class="text-gray-500">LOT번호:</span> <span class="font-mono"><?= esc($order->bo_lot_no) ?></span></div>
<div><span class="text-gray-500">발주일:</span> <?= esc($order->bo_order_date) ?></div>
<div><span class="text-gray-500">상태:</span>
<?php $statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제']; ?>
<?= esc($statusMap[$order->bo_status] ?? $order->bo_status) ?>
<div class="flex flex-wrap items-center gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
<a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">종료</a>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200 no-print text-sm">
<form method="get" action="<?= mgmt_url('reports/lot-flow') ?>" class="flex flex-wrap items-end gap-x-4 gap-y-2">
<input type="hidden" name="search" value="1"/>
<div class="flex flex-wrap items-center gap-2">
<label class="font-bold text-gray-700 whitespace-nowrap">봉투번호</label>
<input type="text" name="barcode" id="lot-flow-barcode-input" value="<?= esc($barcode) ?>"
placeholder="바코드·팩·박스·낱장 코드 입력"
class="border border-gray-300 rounded px-2 py-1 w-80 font-mono text-sm" autocomplete="off"/>
<span class="text-gray-500 text-xs">(바코드 스캔 = 번호 직접 입력)</span>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</form>
<p class="text-xs text-gray-500 mt-1">
팩·박스·낱장 바코드 또는 LOT 번호(보조: <code class="text-xs">lot_no</code> 파라미터)로 조회합니다.
</p>
</section>
<?php if ($queried && ! $ok && $message !== ''): ?>
<div class="m-2 p-3 border border-amber-300 bg-amber-50 text-sm text-amber-900 no-print">
<?= esc($message) ?>
</div>
<?php endif; ?>
<?php if (! $queried): ?>
<div class="m-2 p-3 border border-blue-200 bg-blue-50 text-sm text-blue-900 no-print">
봉투번호(바코드)를 입력한 뒤 <strong>조회</strong>를 눌러 주세요.
</div>
<?php endif; ?>
<section class="mx-2 mb-2 border border-amber-400 bg-amber-50/80 rounded-sm no-print" id="lot-flow-test-samples">
<div class="px-3 py-2 border-b border-amber-300 bg-amber-100/80">
<strong class="text-amber-950 text-sm">[개발용 임시] 등록·조회 가능 봉투번호 샘플</strong>
<span class="text-xs text-amber-800 ml-2">행 클릭 → 봉투번호 입력 후 조회 · 현재 지자체 DB 기준</span>
</div>
<div class="overflow-auto max-h-48">
<table class="w-full text-xs data-table">
<thead>
<tr>
<th class="w-14 text-center">구분</th>
<th>봉투번호(입력값)</th>
<th class="w-28">품목</th>
<th class="w-24">LOT</th>
<th class="w-14 text-center">상태</th>
<th class="w-32">비고</th>
</tr>
</thead>
<tbody>
<?php if ($testSamples === []): ?>
<tr>
<td colspan="6" class="text-center text-gray-500 py-4">
<code>bag_receiving_pack_code</code> 데이터가 없습니다. 입고 처리 후 표시됩니다.
</td>
</tr>
<?php endif; ?>
<?php foreach ($testSamples as $sample): ?>
<tr class="lot-flow-sample-row cursor-pointer hover:bg-amber-100"
data-code="<?= esc((string) ($sample['code'] ?? ''), 'attr') ?>"
title="클릭하여 조회">
<td class="text-center"><?= esc((string) ($sample['kind'] ?? '')) ?></td>
<td class="font-mono text-left pl-2 break-all"><?= esc((string) ($sample['code'] ?? '')) ?></td>
<td class="text-left pl-1 truncate max-w-[7rem]" title="<?= esc((string) ($sample['bag_name'] ?? ''), 'attr') ?>">
<?= esc((string) ($sample['bag_name'] ?? '')) ?>
</td>
<td class="font-mono text-left pl-1 truncate max-w-[6rem]"><?= esc((string) ($sample['lot_no'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($sample['state'] ?? '')) ?></td>
<td class="text-gray-600 pl-1"><?= esc((string) ($sample['hint'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<div class="lot-flow-layout m-2 flex flex-col lg:flex-row gap-3 min-h-[320px]">
<!-- 좌: 품목·단위 요약 (레거시 BOX/PACK/낱장) -->
<div class="lot-flow-summary border border-gray-300 bg-gray-50 p-3 lg:w-64 shrink-0">
<h3 class="text-sm font-bold text-gray-700 mb-2">봉투 정보</h3>
<?php if ($ok): ?>
<dl class="text-sm space-y-1.5">
<div><dt class="text-gray-500 inline">품목</dt>
<dd class="font-medium"><?= esc($bagName !== '' ? $bagName : '-') ?></dd></div>
<?php if ($bagCode !== ''): ?>
<div><dt class="text-gray-500 inline">코드</dt>
<dd class="font-mono text-xs"><?= esc($bagCode) ?></dd></div>
<?php endif; ?>
<?php if ($lotLabel !== ''): ?>
<div><dt class="text-gray-500 inline">LOT</dt>
<dd class="font-mono text-xs break-all"><?= esc($lotLabel) ?></dd></div>
<?php endif; ?>
<?php if ($unit !== ''): ?>
<div><dt class="text-gray-500 inline">조회단위</dt>
<dd><?= esc($unit) ?></dd></div>
<?php endif; ?>
</dl>
<div class="grid grid-cols-3 gap-2 mt-4 text-center text-xs">
<div class="border border-gray-300 bg-white rounded p-2">
<div class="text-gray-500 font-bold">BOX</div>
<div class="text-lg font-semibold tabular-nums"><?= $qtyBox > 0 ? number_format($qtyBox) : '—' ?></div>
</div>
<div class="border border-gray-300 bg-white rounded p-2">
<div class="text-gray-500 font-bold">PACK</div>
<div class="text-lg font-semibold tabular-nums"><?= $qtyPack > 0 ? number_format($qtyPack) : '—' ?></div>
</div>
<div class="border border-gray-300 bg-white rounded p-2">
<div class="text-gray-500 font-bold">낱장</div>
<div class="text-lg font-semibold tabular-nums"><?= $qtySheet > 0 ? number_format($qtySheet) : '—' ?></div>
</div>
</div>
<?php elseif ($queried): ?>
<p class="text-sm text-gray-500">조회 결과 없음</p>
<?php else: ?>
<p class="text-sm text-gray-400">조회 후 표시</p>
<?php endif; ?>
</div>
<!-- 우: LOT 수불 현황 -->
<div class="lot-flow-table-wrap flex-1 border border-gray-300 flex flex-col min-w-0">
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5">
<span class="text-sm font-bold text-gray-700">LOT 수불 현황</span>
</div>
<div class="overflow-auto flex-1">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-28">일자</th>
<th>입출고처</th>
<th class="w-24 text-center">구분</th>
</tr>
</thead>
<tbody>
<?php if ($queried && $ok && $rows === []): ?>
<tr>
<td colspan="3" class="text-center text-gray-500 py-8">수불 이력이 없습니다.</td>
</tr>
<?php endif; ?>
<?php if (! $queried): ?>
<tr>
<td colspan="3" class="text-center text-gray-400 py-8">봉투번호 입력 후 조회</td>
</tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<tr>
<td class="text-center"><?= esc((string) ($row['flow_date'] ?? '')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['counterparty'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['flow_type'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div><span class="text-gray-500">등록일:</span> <?= esc($order->bo_regdate) ?></div>
</div>
</div>
<!-- 발주 품목 -->
<h3 class="text-sm font-bold text-gray-700 mt-3 mb-1">발주 품목</h3>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>봉투코드</th>
<th>봉투명</th>
<th>발주수량(박스)</th>
<th>발주수량(매)</th>
<th>단가</th>
<th>금액</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($items as $item): ?>
<tr>
<td class="text-center font-mono"><?= esc($item->boi_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($item->boi_bag_name) ?></td>
<td><?= number_format((int) $item->boi_qty_box) ?></td>
<td><?= number_format((int) $item->boi_qty_sheet) ?></td>
<td><?= number_format((int) $item->boi_unit_price) ?></td>
<td><?= number_format((int) $item->boi_amount) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($items)): ?>
<tr><td colspan="6" class="text-center text-gray-400 py-4">품목이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<script>
(function () {
const input = document.getElementById('lot-flow-barcode-input');
const form = input?.closest('form');
document.querySelectorAll('.lot-flow-sample-row').forEach((row) => {
row.addEventListener('click', () => {
const code = row.getAttribute('data-code') || '';
if (!input || !form || code === '') return;
input.value = code;
form.submit();
});
});
})();
</script>
<!-- 입고 내역 -->
<h3 class="text-sm font-bold text-gray-700 mt-3 mb-1">입고 내역</h3>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>입고일</th>
<th>봉투코드</th>
<th>봉투명</th>
<th>입고수량(박스)</th>
<th>입고수량(매)</th>
<th>납품자</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($receivings as $recv): ?>
<tr>
<td class="text-center"><?= esc($recv->br_receive_date) ?></td>
<td class="text-center font-mono"><?= esc($recv->br_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($recv->br_bag_name) ?></td>
<td><?= number_format((int) $recv->br_qty_box) ?></td>
<td><?= number_format((int) $recv->br_qty_sheet) ?></td>
<td class="text-left pl-2"><?= esc($recv->br_sender_name ?? '') ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($receivings)): ?>
<tr><td colspan="6" class="text-center text-gray-400 py-4">입고 내역이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php elseif ($lotNo !== '' && !$order): ?>
<div class="border border-gray-300 p-4 mt-2 text-center text-gray-400">해당 LOT 번호의 발주를 찾을 수 없습니다.</div>
<?php else: ?>
<div class="border border-gray-300 p-4 mt-2 text-center text-gray-400">LOT 번호를 입력하고 조회해 주세요.</div>
<?php endif; ?>
<style>
@media print {
.no-print { display: none !important; }
#lot-flow-test-samples { display: none !important; }
.lot-flow-layout { margin: 0; flex-direction: row; }
}
</style>

View File

@@ -1,84 +1,369 @@
<?= view('components/print_header', ['printTitle' => '기타 입출고']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<?php
$filters = is_array($filters ?? null) ? $filters : [];
$flowYear = (string) ($filters['flow_y'] ?? '');
$flowMonthNum = (string) ($filters['flow_m'] ?? '');
$dateYearMin = (int) ($dateYearMin ?? ((int) date('Y') - 12));
$dateYearMax = (int) ($dateYearMax ?? ((int) date('Y') + 2));
$bagCodeFilter = (string) ($filters['bag_code'] ?? '');
$bagKind = (string) ($filters['bag_kind'] ?? '');
$bagCancelOnly = ! empty($filters['bag_cancel']);
$selKey = (string) ($filters['sel_key'] ?? '');
$selectedGroup = is_array($selectedGroup ?? null) ? $selectedGroup : null;
$detailLines = is_array($detailLines ?? null) ? $detailLines : [];
$groupList = is_array($groupList ?? null) ? $groupList : [];
$packagingMap = is_array($packagingMap ?? null) ? $packagingMap : [];
$bagKindOptions = is_array($bagKindOptions ?? null) ? $bagKindOptions : [];
$bagCodes = is_array($bagCodes ?? null) ? $bagCodes : [];
$miscFlowListUrl = static function (array $extra = []) use ($filters): string {
$qs = array_merge($filters, $extra);
unset($qs['sel_key']);
if (isset($extra['sel_key'])) {
$qs['sel_key'] = $extra['sel_key'];
}
return mgmt_url('reports/misc-flow') . ($qs !== [] ? '?' . http_build_query($qs) : '');
};
$detailTotalQty = 0;
foreach ($detailLines as $line) {
$detailTotalQty += (int) ($line->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'] ?? '') : '';
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">기타 입출고</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<div class="flex flex-wrap items-center gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
<a href="<?= esc(work_area_home_url()) ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">종료</a>
</div>
</div>
</section>
<?php if (!($tableExists ?? false)): ?>
<div class="border border-orange-300 bg-orange-50 p-3 mt-2 text-sm text-orange-700">
<?php if (! ($tableExists ?? false)): ?>
<div class="border border-orange-300 bg-orange-50 p-3 m-2 text-sm text-orange-700 no-print">
bag_misc_flow 테이블이 생성되지 않았습니다. <code>writable/database/bag_misc_flow_tables.sql</code>을 실행해 주세요.
</div>
<?php elseif (($totalRowsForLg ?? 0) === 0): ?>
<div class="border border-blue-200 bg-blue-50 p-3 m-2 text-sm text-blue-900 no-print">
선택한 지자체에 등록된 <strong>기타 입출고</strong> 데이터가 없습니다. 아래 <strong>품목 등록</strong>으로 첫 건을 넣으면 좌측 리스트에 표시됩니다.
</div>
<?php elseif (($groupList ?? []) === [] && ($fetchedRowCount ?? 0) === 0 && (($flowYear ?? '') !== '' || ($flowMonthNum ?? '') !== '' || ($bagCodeFilter ?? '') !== '' || ($bagKind ?? '') !== '' || ! empty($filters['bag_cancel']))): ?>
<div class="border border-amber-200 bg-amber-50 p-3 m-2 text-sm text-amber-900 no-print">
조회 조건(수불 년월·봉투코드·구분 등)에 맞는 내역이 없습니다. <strong>수불 년월을 「전체」</strong>로 두거나 조건을 넓혀 다시 조회해 주세요.
</div>
<?php endif; ?>
<!-- 등록 폼 -->
<?php if (session()->getFlashdata('success')): ?>
<div class="mx-2 mt-2 border border-green-300 bg-green-50 text-green-800 text-sm px-3 py-2 no-print"><?= esc((string) session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="mx-2 mt-2 border border-red-300 bg-red-50 text-red-800 text-sm px-3 py-2 no-print"><?= esc((string) session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<!-- 조회 조건 (레거시: 수불 년월, 봉투코드, 봉투 취소, 구분, 조회) -->
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="POST" action="<?= mgmt_url('reports/misc-flow') ?>" class="flex flex-wrap items-center gap-2">
<?= csrf_field() ?>
<label class="text-sm text-gray-600">구분</label>
<select name="bmf_type" class="border border-gray-300 rounded px-2 py-1 text-sm" required>
<option value="in">입고</option>
<option value="out">출고</option>
<form method="get" action="<?= mgmt_url('reports/misc-flow') ?>" class="flex flex-wrap items-end gap-2 text-sm">
<span class="font-bold text-gray-700 whitespace-nowrap">수불 년월</span>
<select name="flow_y" class="border border-gray-300 rounded px-2 py-1 min-w-[5.5rem]" aria-label="수불 년도">
<option value="">전체</option>
<?php for ($yy = $dateYearMax; $yy >= $dateYearMin; $yy--): ?>
<option value="<?= $yy ?>" <?= $flowYear === (string) $yy ? 'selected' : '' ?>><?= $yy ?>년</option>
<?php endfor; ?>
</select>
<label class="text-sm text-gray-600">봉투</label>
<select name="bmf_bag_code" class="border border-gray-300 rounded px-2 py-1 text-sm" required>
<option value="">선택</option>
<?php foreach ($bagCodes as $bc): ?>
<option value="<?= esc($bc->cd_code) ?>"><?= esc($bc->cd_code . ' - ' . $bc->cd_name) ?></option>
<select name="flow_m" class="border border-gray-300 rounded px-2 py-1 min-w-[4.5rem]" aria-label="수불 월" <?= $flowYear === '' ? 'disabled' : '' ?>>
<option value=""><?= $flowYear === '' ? '—' : '전체' ?></option>
<?php for ($mi = 1; $mi <= 12; $mi++): ?>
<option value="<?= $mi ?>" <?= $flowMonthNum !== '' && (int) $flowMonthNum === $mi ? 'selected' : '' ?>><?= $mi ?>월</option>
<?php endfor; ?>
</select>
<label class="font-bold text-gray-700 whitespace-nowrap">봉투코드</label>
<input type="text" name="bag_code" value="<?= esc($bagCodeFilter) ?>" placeholder="코드 일부"
class="border border-gray-300 rounded px-2 py-1 w-28 font-mono"/>
<label class="inline-flex items-center gap-1 text-gray-700 whitespace-nowrap">
<input type="checkbox" name="bag_cancel" value="1" <?= $bagCancelOnly ? 'checked' : '' ?>/>
봉투 취소
</label>
<span class="text-xs text-gray-500 hidden sm:inline" title="출고 건만 조회">(출고만)</span>
<label class="font-bold text-gray-700 whitespace-nowrap">구분</label>
<select name="bag_kind" class="border border-gray-300 rounded px-2 py-1 min-w-[8rem]">
<option value="">전체</option>
<?php foreach ($bagKindOptions as $opt): ?>
<option value="<?= esc((string) $opt->cd_code) ?>" <?= $bagKind === (string) $opt->cd_code ? 'selected' : '' ?>>
<?= esc((string) $opt->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
<label class="text-sm text-gray-600">수량</label>
<input type="number" name="bmf_qty" min="1" class="border border-gray-300 rounded px-2 py-1 text-sm w-24" required/>
<label class="text-sm text-gray-600">일자</label>
<input type="date" name="bmf_date" value="<?= date('Y-m-d') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm" required/>
<label class="text-sm text-gray-600">사유</label>
<input type="text" name="bmf_reason" placeholder="입출고 사유" class="border border-gray-300 rounded px-2 py-1 text-sm w-48" required/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">등록</button>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
<a href="<?= mgmt_url('reports/misc-flow') ?>" class="text-gray-500 hover:text-gray-800 px-2 py-1">초기화</a>
</form>
</section>
<!-- 조회 필터 -->
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= mgmt_url('reports/misc-flow') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<label class="text-sm text-gray-600">~</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
</section>
<div class="grid grid-cols-1 xl:grid-cols-4 gap-2 p-2">
<!-- 입출고 리스트 -->
<section class="border border-gray-300 bg-white xl:col-span-1">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 리스트</div>
<div class="overflow-auto max-h-[520px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-24">수불일자</th>
<th class="w-16">수량</th>
<th class="w-14">구분</th>
<th>메모</th>
</tr>
</thead>
<tbody>
<?php if ($groupList !== []): ?>
<?php foreach ($groupList as $grp): ?>
<?php
$key = (string) ($grp['key'] ?? '');
$isSelected = $key !== '' && $key === $selKey;
$listUrl = $miscFlowListUrl(['sel_key' => $key]);
?>
<tr
class="<?= $isSelected ? 'bg-blue-100 font-semibold' : '' ?> cursor-pointer hover:bg-blue-50"
onclick="window.location.href='<?= esc($listUrl, 'attr') ?>'"
>
<td class="text-center <?= $isSelected ? 'border-l-4 border-blue-600' : '' ?>"><?= esc((string) ($grp['date'] ?? '')) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($grp['totalQty'] ?? 0)) ?></td>
<td class="text-center">
<?php if ((string) ($grp['type'] ?? '') === 'in'): ?>
<span class="text-blue-700">입고</span>
<?php else: ?>
<span class="text-red-700">출고</span>
<?php endif; ?>
</td>
<td class="text-left pl-2 truncate max-w-[8rem]" title="<?= esc((string) ($grp['reason'] ?? '')) ?>"><?= esc((string) ($grp['reason'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="4" class="text-center text-gray-400 py-6">
<?php if (($totalRowsForLg ?? 0) === 0): ?>
등록된 기타 입출고가 없습니다.
<?php elseif (($fetchedRowCount ?? 0) === 0): ?>
선택한 기간·조건에 해당하는 내역이 없습니다.
<?php else: ?>
조회 결과가 없습니다.
<?php endif; ?>
</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th>번호</th>
<th>구분</th>
<th>일자</th>
<th>봉투코드</th>
<th>봉투명</th>
<th>수량</th>
<th>사유</th>
<th>등록일</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($result as $row): ?>
<tr>
<td class="text-center"><?= (int) $row->bmf_idx ?></td>
<td class="text-center"><?= $row->bmf_type === 'in' ? '<span class="text-blue-600">입고</span>' : '<span class="text-red-600">출고</span>' ?></td>
<td class="text-center"><?= esc($row->bmf_date) ?></td>
<td class="text-center font-mono"><?= esc($row->bmf_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bmf_bag_name) ?></td>
<td><?= number_format((int) $row->bmf_qty) ?></td>
<td class="text-left pl-2"><?= esc($row->bmf_reason) ?></td>
<td class="text-center"><?= esc($row->bmf_regdate) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($result)): ?>
<tr><td colspan="8" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
<!-- 우측: 입출고 정보 + 봉투 코드 + 등록 -->
<div class="xl:col-span-3 space-y-2">
<form method="post" action="<?= mgmt_url('reports/misc-flow/delete') ?>" id="misc-flow-delete-form" class="no-print">
<?= csrf_field() ?>
<input type="hidden" name="flow_y" value="<?= esc($flowYear) ?>"/>
<input type="hidden" name="flow_m" value="<?= esc($flowMonthNum) ?>"/>
<input type="hidden" name="bag_code" value="<?= esc($bagCodeFilter) ?>"/>
<input type="hidden" name="bag_kind" value="<?= esc($bagKind) ?>"/>
<?php if ($bagCancelOnly): ?><input type="hidden" name="bag_cancel" value="1"/><?php endif; ?>
<input type="hidden" name="sel_key" value="<?= esc($selKey) ?>"/>
<div class="flex flex-wrap gap-2 mb-1">
<button type="submit" class="bg-red-600 text-white px-4 py-1 rounded-sm text-sm disabled:opacity-40"
<?= $selKey === '' ? 'disabled' : '' ?>
onclick="return confirm('선택한 입출고 건을 삭제할까요? 재고가 복원됩니다.');">삭제</button>
<?php
$cancelQs = $filters;
unset($cancelQs['sel_key']);
$cancelUrl = mgmt_url('reports/misc-flow') . ($cancelQs !== [] ? '?' . http_build_query($cancelQs) : '');
?>
<a href="<?= esc($cancelUrl) ?>" class="border border-gray-400 text-gray-700 px-4 py-1 rounded-sm text-sm hover:bg-gray-50 inline-block">취소</a>
</div>
</form>
<!-- 입출고 일자 (상세) -->
<section class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 일자</div>
<div class="p-2 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<?php if ($selectedGroup): ?>
<div class="flex flex-wrap items-center gap-2">
<span class="font-bold text-gray-700 whitespace-nowrap">수불 일자</span>
<span class="border border-gray-200 bg-gray-50 px-2 py-1 rounded"><?= esc((string) ($selectedGroup['date'] ?? '')) ?></span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-bold text-gray-700 whitespace-nowrap">선택</span>
<span class="border border-gray-200 bg-gray-50 px-2 py-1 rounded"><?= esc((string) ($selectedGroup['typeLabel'] ?? '')) ?></span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-bold text-gray-700 whitespace-nowrap">분류</span>
<span class="border border-gray-200 bg-gray-50 px-2 py-1 rounded min-w-[6rem]">
<?= esc($selectedBagKindLabel ?? '') !== '' ? esc($selectedBagKindLabel) : '—' ?>
</span>
</div>
<div class="md:col-span-2">
<span class="font-bold text-gray-700 block mb-1">비고</span>
<div class="border border-gray-200 bg-gray-50 rounded px-2 py-2 min-h-[4rem] whitespace-pre-wrap"><?= esc((string) ($selectedGroup['reason'] ?? '')) ?></div>
</div>
<?php else: ?>
<p class="text-gray-400 col-span-2 py-2">좌측 입출고 리스트에서 건을 선택하거나, 아래에서 신규 등록해 주세요.</p>
<?php endif; ?>
</div>
</section>
<!-- 입출고 봉투 코드 -->
<section class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 봉투 코드</div>
<div class="overflow-auto max-h-[280px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-10">No</th>
<th>봉투 코드</th>
<th>봉투 종류</th>
<th class="w-20">수량</th>
<th class="w-14">단위</th>
</tr>
</thead>
<tbody class="text-right">
<?php if ($detailLines !== []): ?>
<?php foreach ($detailLines as $idx => $line): ?>
<?php
$code = (string) ($line->bmf_bag_code ?? '');
$pu = $packagingMap[$code] ?? null;
$unitLabel = '매';
if ($pu && (int) ($pu->pu_pack_per_sheet ?? 0) > 0) {
$unitLabel = '매';
}
?>
<tr>
<td class="text-center"><?= $idx + 1 ?></td>
<td class="text-center font-mono"><?= esc($code) ?></td>
<td class="text-left pl-2"><?= esc((string) ($line->bmf_bag_name ?? '')) ?></td>
<td class="pr-2"><?= number_format((int) ($line->bmf_qty ?? 0)) ?></td>
<td class="text-center"><?= esc($unitLabel) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="5" class="text-center text-gray-400 py-6">봉투 코드 내역이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr class="bg-gray-50 font-semibold">
<td colspan="3" class="text-center">합계</td>
<td class="text-right pr-2"><?= number_format($detailTotalQty) ?></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</section>
<!-- 품목 등록 (동일 수불일자·구분·비고로 묶임) -->
<?php if ($tableExists ?? false): ?>
<section class="border border-gray-300 bg-white no-print">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">품목 등록</div>
<form method="post" action="<?= mgmt_url('reports/misc-flow') ?>" class="p-2 flex flex-wrap items-end gap-2 text-sm">
<?= csrf_field() ?>
<input type="hidden" name="flow_y" value="<?= esc($flowYear) ?>"/>
<input type="hidden" name="flow_m" value="<?= esc($flowMonthNum) ?>"/>
<input type="hidden" name="bag_code" value="<?= esc($bagCodeFilter) ?>"/>
<input type="hidden" name="bag_kind" value="<?= esc($bagKind) ?>"/>
<?php if ($bagCancelOnly): ?><input type="hidden" name="bag_cancel" value="1"/><?php endif; ?>
<input type="hidden" name="sel_key" value="<?= esc($selKey) ?>"/>
<label class="font-bold text-gray-700">수불 일자</label>
<input type="date" name="bmf_date" value="<?= esc($registerDate) ?>" class="border border-gray-300 rounded px-2 py-1" required/>
<label class="font-bold text-gray-700">선택</label>
<select name="bmf_type" class="border border-gray-300 rounded px-2 py-1 min-w-[7.5rem] w-32">
<option value="in" <?= $registerType === 'in' ? 'selected' : '' ?>>입고</option>
<option value="out" <?= $registerType === 'out' ? 'selected' : '' ?>>출고</option>
</select>
<label class="font-bold text-gray-700">분류</label>
<select name="bmf_bag_kind" id="bmf-bag-kind" class="border border-gray-300 rounded px-2 py-1 min-w-[7rem]">
<option value="">전체</option>
<?php foreach ($bagKindOptions as $opt): ?>
<option value="<?= esc((string) $opt->cd_code) ?>" <?= ($selectedBagKind ?? '') === (string) $opt->cd_code ? 'selected' : '' ?>>
<?= esc((string) $opt->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
<label class="font-bold text-gray-700">비고</label>
<input type="text" name="bmf_reason" value="<?= esc($registerReason) ?>" placeholder="입출고 메모" maxlength="200"
class="border border-gray-300 rounded px-2 py-1 w-40 max-w-[10rem] shrink-0" required/>
<label class="font-bold text-gray-700">봉투 코드</label>
<select name="bmf_bag_code" id="bmf-bag-code" class="border border-gray-300 rounded px-2 py-1 min-w-[11rem]" required>
<option value="">선택</option>
<?php foreach ($bagCodes as $bc): ?>
<?php $code = (string) $bc->cd_code; ?>
<option value="<?= esc($code) ?>" data-kind-prefix="<?= esc(substr($code, 0, 2)) ?>">
<?= esc($code . ' - ' . $bc->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
<label class="font-bold text-gray-700">수량</label>
<input type="number" name="bmf_qty" min="1" class="border border-gray-300 rounded px-2 py-1 w-24" required/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">등록</button>
</form>
<p class="px-2 pb-2 text-xs text-gray-500">동일 수불일자·입출고·비고로 등록한 품목은 좌측 리스트에서 한 건으로 묶여 표시됩니다.</p>
</section>
<?php endif; ?>
</div>
</div>
<script>
(function () {
const kindSelect = document.getElementById('bmf-bag-kind');
const bagSelect = document.getElementById('bmf-bag-code');
if (!kindSelect || !bagSelect) return;
const allOptions = Array.from(bagSelect.querySelectorAll('option[data-kind-prefix]'));
function filterBagCodes() {
const prefix = kindSelect.value;
const current = bagSelect.value;
allOptions.forEach(function (opt) {
const show = prefix === '' || opt.getAttribute('data-kind-prefix') === prefix;
opt.hidden = !show;
opt.disabled = !show;
});
const selected = bagSelect.querySelector('option[value="' + CSS.escape(current) + '"]');
if (selected && !selected.hidden) {
bagSelect.value = current;
} else {
bagSelect.value = '';
}
}
kindSelect.addEventListener('change', filterBagCodes);
filterBagCodes();
const flowYearSelect = document.querySelector('select[name="flow_y"]');
const flowMonthSelect = document.querySelector('form[method="get"] select[name="flow_m"]');
if (flowYearSelect && flowMonthSelect) {
const syncFlowMonthSelect = function () {
const hasYear = flowYearSelect.value !== '';
flowMonthSelect.disabled = !hasYear;
if (!hasYear) {
flowMonthSelect.value = '';
}
const firstOpt = flowMonthSelect.querySelector('option[value=""]');
if (firstOpt) {
firstOpt.textContent = hasYear ? '전체' : '—';
}
};
flowYearSelect.addEventListener('change', syncFlowMonthSelect);
syncFlowMonthSelect();
}
})();
</script>

View File

@@ -1,73 +1,184 @@
<?= view('components/print_header', ['printTitle' => '기간별 판매현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<?php
declare(strict_types=1);
/** @var list<array<string,mixed>> $lines */
/** @var string $startDate */
/** @var string $endDate */
/** @var int $saIdx */
/** @var string $catFilter */
/** @var string $mode */
/** @var list<object> $agencies */
/** @var array<string,string> $catLabels */
/** @var bool $hasBsFee */
/** @var string $lgName */
/** @var string $agencyLabel */
/** @var string $catLabelFilter */
/** @var list<string> $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 => '',
};
};
?>
<?= view('components/print_header', [
'printTitle' => '기간별 판매현황',
'printExtraLines' => $printExtraLines ?? [],
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">기간별 판매현황</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<div class="flex flex-wrap gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($excelUrl, 'attr') ?>" class="inline-flex items-center border border-green-600 text-green-700 px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/period-sales') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<label class="text-sm text-gray-600">~</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<section class="p-3 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= mgmt_url('reports/period-sales') ?>" class="flex flex-wrap items-end gap-3 text-sm">
<div>
<label class="block text-gray-600 mb-0.5">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
</div>
<div>
<label class="block text-gray-600 mb-0.5">종료일</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
</div>
<div>
<label class="block text-gray-600 mb-0.5">대행소</label>
<select name="sa_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[12rem] max-w-[20rem]">
<option value="0">전체</option>
<?php foreach ($agencies ?? [] as $agency): ?>
<?php $aid = (int) ($agency->sa_idx ?? 0); ?>
<option value="<?= esc((string) $aid) ?>" <?= (int) ($saIdx ?? 0) === $aid ? 'selected' : '' ?>>
<?= esc(trim((string) ($agency->sa_name ?? ''))) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">구분</label>
<select name="cat" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="" <?= ($catFilter ?? '') === '' ? 'selected' : '' ?>>전체</option>
<?php foreach (($catLabels ?? []) as $ck => $lab): ?>
<option value="<?= esc($ck, 'attr') ?>" <?= ($catFilter ?? '') === $ck ? 'selected' : '' ?>><?= esc($lab) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">집계 방식</label>
<select name="mode" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[9rem]">
<option value="daily" <?= $byDaily ? 'selected' : '' ?>>일자별</option>
<option value="period" <?= ! $byDaily ? 'selected' : '' ?>>기간별</option>
</select>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th>봉투코드</th>
<th>봉투명</th>
<th>판매수량</th>
<th>판매금액</th>
<th>반품수량</th>
<th>반품금액</th>
<th>합계수량</th>
<th>합계금액</th>
</tr>
</thead>
<tbody class="text-right">
<?php
$grandSaleQty = 0;
$grandSaleAmount = 0;
$grandReturnQty = 0;
$grandReturnAmount = 0;
?>
<?php foreach ($result as $row): ?>
<?php
$grandSaleQty += (int) $row->sale_qty;
$grandSaleAmount += (int) $row->sale_amount;
$grandReturnQty += (int) $row->return_qty;
$grandReturnAmount += (int) $row->return_amount;
?>
<tr>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td>
<td><?= number_format((int) $row->sale_qty) ?></td>
<td><?= number_format((int) $row->sale_amount) ?></td>
<td><?= number_format((int) $row->return_qty) ?></td>
<td><?= number_format((int) $row->return_amount) ?></td>
<td class="font-bold"><?= number_format((int) $row->sale_qty - (int) $row->return_qty) ?></td>
<td class="font-bold"><?= number_format((int) $row->sale_amount - (int) $row->return_amount) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($result)): ?>
<tr><td colspan="8" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot class="bg-gray-50 font-bold text-right">
<tr>
<td colspan="2" class="text-center">합계</td>
<td><?= number_format($grandSaleQty) ?></td>
<td><?= number_format($grandSaleAmount) ?></td>
<td><?= number_format($grandReturnQty) ?></td>
<td><?= number_format($grandReturnAmount) ?></td>
<td><?= number_format($grandSaleQty - $grandReturnQty) ?></td>
<td><?= number_format($grandSaleAmount - $grandReturnAmount) ?></td>
</tr>
</tfoot>
</table>
</div>
<section class="p-3 bg-white">
<style>
@media print {
.period-sales-screen-title { display: none !important; }
}
</style>
<div class="mb-2 text-center period-sales-screen-title no-print">
<h1 class="text-lg font-bold m-0">기간별 판매현황<?= $byDaily ? ' [일집계]' : ' [기간집계]' ?></h1>
<p class="text-sm text-gray-700 m-1"><?= esc(trim(($lgName ?? '') . ' · ' . ($startDate ?? '') . ' ~ ' . ($endDate ?? '') . ' · 대행소: ' . ($agencyLabel ?? '') . ' · 구분: ' . ($catLabelFilter ?? ''))) ?></p>
<p class="text-xs text-gray-500 m-0">집계: <?= $byDaily ? '일자별' : '기간별' ?> · (단위: 매 / 원)</p>
</div>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table text-sm" id="period-sales-table">
<thead>
<tr>
<?php if ($byDaily): ?>
<th rowspan="2" class="align-middle whitespace-nowrap">일자</th>
<?php endif; ?>
<th rowspan="2" class="align-middle text-left pl-2">품목</th>
<th colspan="4" class="text-center border-l border-gray-300">판매</th>
<th colspan="2" class="text-center border-l border-gray-300">반품</th>
<th colspan="4" class="text-center border-l border-gray-300">계</th>
</tr>
<tr>
<th class="text-right border-l border-gray-300">수량</th>
<th class="text-right">판매금액</th>
<th class="text-right">수수료</th>
<th class="text-right">징수액</th>
<th class="text-right border-l border-gray-300">수량</th>
<th class="text-right">금액</th>
<th class="text-right border-l border-gray-300">수량</th>
<th class="text-right">판매금액</th>
<th class="text-right">수수료</th>
<th class="text-right">징수액</th>
</tr>
</thead>
<tbody class="text-right">
<?php
$emptyColspan = $byDaily ? 12 : 11;
?>
<?php foreach ($lines ?? [] as $ln): ?>
<?php
$kind = (string) ($ln['kind'] ?? 'data');
$trCls = $rowClass($kind);
$isData = $kind === 'data';
$rs = (int) ($ln['ymd_rowspan'] ?? 0);
?>
<tr class="<?= esc($trCls, 'attr') ?>">
<?php if ($byDaily): ?>
<?php if ($isData && $rs > 0): ?>
<td rowspan="<?= $rs ?>" class="text-center align-top whitespace-nowrap tabular-nums pt-1"><?= esc((string) ($ln['ymd'] ?? '')) ?></td>
<?php elseif (str_starts_with($kind, 'foot_')): ?>
<td class="bg-inherit"></td>
<?php endif; ?>
<?php endif; ?>
<td class="text-left pl-2"><?= esc((string) ($ln['name'] ?? '')) ?></td>
<td class="border-l border-gray-200 tabular-nums"><?= number_format((int) ($ln['s_qty'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($ln['s_amt'] ?? 0))) ?></td>
<td class="tabular-nums"><?= $fmtFee((float) ($ln['s_fee'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($ln['s_levy'] ?? 0))) ?></td>
<td class="border-l border-gray-200 tabular-nums"><?= number_format((int) ($ln['r_qty'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($ln['r_amt'] ?? 0))) ?></td>
<td class="border-l border-gray-200 tabular-nums"><?= number_format((int) ($ln['t_qty'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($ln['t_amt'] ?? 0))) ?></td>
<td class="tabular-nums"><?= $fmtFee((float) ($ln['t_fee'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($ln['t_levy'] ?? 0))) ?></td>
</tr>
<?php endforeach; ?>
<?php if (($lines ?? []) === []): ?>
<tr>
<td colspan="<?= (int) $emptyColspan ?>" class="text-center text-gray-400 py-6">조회된 데이터가 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>

View File

@@ -1,59 +1,144 @@
<?= view('components/print_header', ['printTitle' => '반품/파기 현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<?php
$startDate = (string) ($startDate ?? date('Y-m-01'));
$endDate = (string) ($endDate ?? date('Y-m-d'));
$ioType = (string) ($ioType ?? 'out');
$result = is_array($result ?? null) ? $result : (array) ($result ?? []);
$queried = (bool) ($queried ?? false);
$exportQuery = (string) ($exportQuery ?? 'search=1');
$fmtKrDate = static function (string $ymd): string {
$ts = strtotime($ymd);
return $ts ? date('Y년 m월 d일', $ts) : $ymd;
};
$ioLabel = $ioType === 'in' ? '입고' : '출고';
$periodLabel = $fmtKrDate($startDate) . ' ~ ' . $fmtKrDate($endDate);
$printExtraLines = [
'조회기간: ' . $periodLabel,
'입출고 구분: ' . $ioLabel,
'(단위: 매)',
];
$typeLabel = static function (string $bsType): string {
return match ($bsType) {
'return' => '반품',
'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);
}
?>
<?= view('components/print_header', [
'printTitle' => '반품 / 파기 현황',
'printExtraLines' => $printExtraLines,
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">반품/파기 현황</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<div class="flex flex-wrap items-center gap-2">
<?php if ($queried && $exportQuery !== ''): ?>
<a href="<?= mgmt_url('reports/returns/export?' . esc($exportQuery, 'attr')) ?>"
class="bg-green-700 text-white px-3 py-1 rounded-sm text-sm hover:bg-green-800">엑셀저장</a>
<?php else: ?>
<span class="bg-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm cursor-not-allowed" title="조회 후 이용">엑셀저장</span>
<?php endif; ?>
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
<a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">종료</a>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/returns') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<label class="text-sm text-gray-600">~</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="get" action="<?= mgmt_url('reports/returns') ?>" class="flex flex-wrap items-end gap-x-4 gap-y-2 text-sm">
<input type="hidden" name="search" value="1"/>
<div class="flex flex-wrap items-center gap-2">
<label class="font-bold text-gray-700 whitespace-nowrap">조회기간</label>
<input type="date" name="start_date" value="<?= esc($startDate) ?>" class="border border-gray-300 rounded px-2 py-1" required/>
<span>~</span>
<input type="date" name="end_date" value="<?= esc($endDate) ?>" class="border border-gray-300 rounded px-2 py-1" required/>
</div>
<div class="flex flex-wrap items-center gap-3">
<span class="font-bold text-gray-700 whitespace-nowrap">입출고 구분</span>
<label class="inline-flex items-center gap-1">
<input type="radio" name="io_type" value="in" <?= $ioType === 'in' ? 'checked' : '' ?>/>
입고
</label>
<label class="inline-flex items-center gap-1">
<input type="radio" name="io_type" value="out" <?= $ioType === 'out' ? 'checked' : '' ?>/>
출고
</label>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</form>
<p class="text-xs text-gray-500 mt-1">
<strong>입고</strong> = 지정판매소 반품(재고 복귀), <strong>출고</strong> = 판매 취소·파기 처리. 조회 후 표·엑셀·인쇄에 반영됩니다.
</p>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<?php if (! $queried): ?>
<div class="m-2 p-3 border border-blue-200 bg-blue-50 text-sm text-blue-900 no-print">
조회기간과 입출고 구분을 선택한 뒤 <strong>조회</strong> 버튼을 눌러 주세요.
</div>
<?php endif; ?>
<div class="border border-gray-300 overflow-auto m-2 print:m-0">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>일자</th>
<th>판매소</th>
<th>봉투코드</th>
<th>봉투명</th>
<th>구분</th>
<th>수량</th>
<th>금액</th>
<th class="w-28">일자</th>
<th>반품처</th>
<th>종류</th>
<th class="w-24 text-right">수량</th>
<th class="w-20 text-center">구분</th>
</tr>
</thead>
<tbody class="text-right">
<?php
$totalQty = 0; $totalAmt = 0;
$typeMap = ['return' => '반품', 'cancel' => '취소/파기'];
foreach ($result as $row):
$totalQty += (int) $row->qty;
$totalAmt += (int) $row->amount;
?>
<tbody>
<?php if ($queried && $result === []): ?>
<tr>
<td class="text-center"><?= esc($row->bs_sale_date) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_ds_name) ?></td>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td>
<td class="text-center"><?= esc($typeMap[$row->bs_type] ?? $row->bs_type) ?></td>
<td><?= number_format((int) $row->qty) ?></td>
<td><?= number_format((int) $row->amount) ?></td>
<td colspan="5" class="text-center text-gray-500 py-8">해당 자료가 없습니다.</td>
</tr>
<?php endif; ?>
<?php foreach ($result as $row): ?>
<tr>
<td class="text-center"><?= esc((string) $row->bs_sale_date) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row->bs_ds_name ?? '')) ?></td>
<td class="text-left pl-2"><?= esc($kindLabel($row)) ?></td>
<td class="text-right pr-2 tabular-nums"><?= number_format((int) ($row->qty ?? 0)) ?></td>
<td class="text-center"><?= esc($typeLabel((string) ($row->bs_type ?? ''))) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($result)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php else: ?>
<?php if ($queried && $result !== []): ?>
<tr class="font-bold bg-gray-100">
<td colspan="5" class="text-center">합계</td>
<td><?= number_format($totalQty) ?></td>
<td><?= number_format($totalAmt) ?></td>
<td colspan="3" class="text-center">합계</td>
<td class="text-right pr-2 tabular-nums"><?= number_format($totalQty) ?></td>
<td></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<style>
@media print {
.no-print { display: none !important; }
}
</style>

View File

@@ -1,97 +1,258 @@
<?= view('components/print_header', ['printTitle' => '판매 대장']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<?php
declare(strict_types=1);
/** @var list<object> $shops */
/** @var list<object> $agencies */
/** @var list<array<string,mixed>> $ledgerRows */
/** @var int $saleLineCount */
/** @var string $startDate */
/** @var string $endDate */
/** @var string $mode */
/** @var int $dsIdx */
/** @var int $saIdx */
/** @var list<string> $cats */
/** @var string $lgName */
/** @var string $filterAgencyLabel */
/** @var list<string> $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));
?>
<?= view('components/print_header', [
'printTitle' => $printTitle,
'printDate' => $printDate,
'printExtraLines' => $printExtraLines,
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">판매 대장</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<span class="text-sm font-bold text-gray-700">지정 판매소 판매 대장</span>
<div class="flex flex-wrap gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($excelUrl, 'attr') ?>" class="inline-flex items-center border border-green-600 text-green-700 px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/sales-ledger') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<label class="text-sm text-gray-600">~</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<label class="text-sm text-gray-600">조회방식</label>
<select name="mode" class="border border-gray-300 rounded px-2 py-1 text-sm">
<option value="daily" <?= ($mode ?? '') === 'daily' ? 'selected' : '' ?>>일자별</option>
<option value="period" <?= ($mode ?? '') === 'period' ? 'selected' : '' ?>>기간별</option>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<section class="p-3 bg-white border-b border-gray-200 no-print">
<form method="get" action="<?= mgmt_url('reports/sales-ledger') ?>" id="sales-ledger-form" class="space-y-3 text-sm">
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="block text-gray-600 mb-0.5">조회일자</label>
<div class="flex items-center gap-1">
<input type="date" name="start_date" value="<?= esc($startDate) ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<span>~</span>
<input type="date" name="end_date" value="<?= esc($endDate) ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
</div>
</div>
<div>
<label class="block text-gray-600 mb-0.5">지정판매소</label>
<select name="ds_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[14rem] max-w-[20rem]">
<option value="0">전체</option>
<?php foreach ($shops as $s): ?>
<?php $sid = (int) ($s->ds_idx ?? 0); ?>
<option value="<?= esc((string) $sid) ?>" <?= $dsIdx === $sid ? 'selected' : '' ?>>
<?= esc(trim((string) ($s->ds_shop_no ?? '') . ' ' . (string) ($s->ds_name ?? ''))) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">대행소</label>
<select name="sa_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[12rem] max-w-[20rem]">
<option value="0">전체</option>
<?php foreach ($agencies ?? [] as $agency): ?>
<?php $aid = (int) ($agency->sa_idx ?? 0); ?>
<option value="<?= esc((string) $aid) ?>" <?= (int) ($saIdx ?? 0) === $aid ? 'selected' : '' ?>>
<?= esc(trim((string) ($agency->sa_name ?? ''))) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<span class="block text-gray-600 mb-0.5">집계 방식</span>
<div class="flex gap-3">
<label class="inline-flex items-center gap-1"><input type="radio" name="mode" value="daily" <?= $mode === 'daily' ? 'checked' : '' ?>/> 일자별</label>
<label class="inline-flex items-center gap-1"><input type="radio" name="mode" value="period" <?= $mode === 'period' ? 'checked' : '' ?>/> 기간별</label>
</div>
</div>
</div>
<fieldset class="border border-gray-200 rounded p-2">
<legend class="text-xs text-gray-600 px-1">품목</legend>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<label class="inline-flex items-center gap-1">
<input type="checkbox" name="cat[]" value="all" id="cat-all" <?= $cats === [] ? 'checked' : '' ?>/>
전체
</label>
<?php foreach ($catKeys as $ck): ?>
<label class="inline-flex items-center gap-1">
<input type="checkbox" name="cat[]" value="<?= esc($ck, 'attr') ?>" class="cat-item" <?= in_array($ck, $cats, true) ? 'checked' : '' ?>/>
<?= esc($catLabels[$ck] ?? $ck) ?>
</label>
<?php endforeach; ?>
</div>
</fieldset>
<div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
</div>
</form>
</section>
<?php if (($mode ?? 'daily') === 'daily'): ?>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th>판매일</th>
<th>판매소</th>
<th>봉투코드</th>
<th>봉투명</th>
<th>구분</th>
<th>수량</th>
<th>금액</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($result as $row): ?>
<tr>
<td class="text-center"><?= esc($row->bs_sale_date) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_ds_name) ?></td>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td>
<td class="text-center">
<?php
$typeMap = ['sale' => '판매', 'return' => '반품'];
echo esc($typeMap[$row->bs_type] ?? $row->bs_type);
?>
</td>
<td><?= number_format((int) $row->total_qty) ?></td>
<td><?= number_format((int) $row->total_amount) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($result)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<section class="p-3 bg-white sales-ledger-report-section">
<style>
@media print {
/* 일계표 등 다른 리포트와 동일: 브라우저 기본 세로 A4 (landscape 지정 안 함) */
.sales-ledger-screen-title { display: none !important; }
.sales-ledger-report-section { padding: 0 !important; }
.sales-ledger-scroll-wrap {
overflow: visible !important;
border: 1px solid #333 !important;
}
#sales-ledger-table {
font-size: 7.5pt !important;
width: 100% !important;
table-layout: fixed !important;
}
#sales-ledger-table th,
#sales-ledger-table td {
min-width: 0 !important;
padding: 3px 4px !important;
white-space: normal !important;
word-break: break-word;
overflow-wrap: anywhere;
line-height: 1.35;
vertical-align: middle;
}
#sales-ledger-table th {
font-size: 7pt !important;
padding-top: 4px !important;
padding-bottom: 4px !important;
}
/* 세로 A4 폭에 맞춘 열 비율 (긴 칸은 줄바꿈) */
#sales-ledger-table .sl-col-date { width: 9%; }
#sales-ledger-table .sl-col-designation { width: 9%; }
#sales-ledger-table .sl-col-shop { width: 10%; }
#sales-ledger-table .sl-col-rep { width: 7%; }
#sales-ledger-table .sl-col-addr { width: 18%; }
#sales-ledger-table .sl-col-product { width: 12%; }
#sales-ledger-table .sl-col-num { width: 7%; }
#sales-ledger-table.sl-period .sl-col-addr { width: 22%; }
#sales-ledger-table.sl-period .sl-col-product { width: 14%; }
}
@media screen {
#sales-ledger-table th,
#sales-ledger-table td {
padding: 4px 8px;
line-height: 1.45;
font-size: 13px;
vertical-align: middle;
}
#sales-ledger-table .sl-col-date,
#sales-ledger-table .sl-col-num { white-space: nowrap; }
#sales-ledger-table .sl-col-addr,
#sales-ledger-table .sl-col-shop,
#sales-ledger-table .sl-col-product {
white-space: normal;
word-break: break-word;
}
}
</style>
<div class="mb-2 text-center sales-ledger-screen-title no-print">
<h1 class="text-lg font-bold m-0"><?= esc($printTitle) ?></h1>
<p class="text-sm text-gray-700 m-1"><?= esc(trim(($lgName ?? '') . ' / 지정판매소: ' . ($filterShopLabel ?? '') . ' / 대행소: ' . ($filterAgencyLabel ?? '전체'))) ?></p>
<p class="text-xs text-gray-500 m-0">(단위: 매 / 원) · <?= esc($startDate) ?> ~ <?= esc($endDate) ?></p>
</div>
<?php else: ?>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th>판매소</th>
<th>봉투코드</th>
<th>봉투명</th>
<th>판매수량</th>
<th>판매금액</th>
<th>반품수량</th>
<th>반품금액</th>
<th>계(수량)</th>
<th>계(금액)</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($result as $row): ?>
<tr>
<td class="text-left pl-2"><?= esc($row->bs_ds_name) ?></td>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td>
<td><?= number_format((int) $row->sale_qty) ?></td>
<td><?= number_format((int) $row->sale_amount) ?></td>
<td><?= number_format((int) $row->return_qty) ?></td>
<td><?= number_format((int) $row->return_amount) ?></td>
<td class="font-bold"><?= number_format((int) $row->sale_qty - (int) $row->return_qty) ?></td>
<td class="font-bold"><?= number_format((int) $row->sale_amount - (int) $row->return_amount) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($result)): ?>
<tr><td colspan="9" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<div class="sales-ledger-scroll-wrap border border-gray-300 overflow-auto">
<table class="w-full data-table text-sm <?= ($mode ?? 'daily') === 'period' ? 'sl-period' : '' ?>" id="sales-ledger-table">
<thead>
<tr>
<?php if (($mode ?? 'daily') === 'daily'): ?>
<th class="sl-col-date">일자</th>
<?php endif; ?>
<th class="sl-col-designation">지정번호</th>
<th class="sl-col-shop text-left">판매소명</th>
<th class="sl-col-rep">대표자</th>
<th class="sl-col-addr text-left">소재지</th>
<th class="sl-col-product text-left">품명</th>
<th class="text-right sl-col-num">판매량</th>
<th class="text-right sl-col-num">판매금액</th>
<th class="text-right sl-col-num">수수료</th>
<th class="text-right sl-col-num">총액</th>
</tr>
</thead>
<tbody>
<?php foreach ($ledgerRows as $r): ?>
<?php
$kind = (string) ($r['kind'] ?? 'data');
$trClass = $kind === 'subtotal' ? 'bg-gray-50 font-semibold' : ($kind === 'grand' ? 'bg-amber-50 font-bold' : '');
?>
<tr class="<?= esc($trClass, 'attr') ?>">
<?php if (($mode ?? 'daily') === 'daily'): ?>
<td class="text-center sl-col-date"><?= esc((string) ($r['sale_date'] ?? '')) ?></td>
<?php endif; ?>
<td class="text-center sl-col-designation"><?= esc((string) ($r['designation_no'] ?? '')) ?></td>
<td class="text-left pl-1 sl-col-shop"><?= esc((string) ($r['shop_name'] ?? '')) ?></td>
<td class="text-center sl-col-rep"><?= esc((string) ($r['rep_name'] ?? '')) ?></td>
<td class="text-left pl-1 sl-col-addr"><?= esc((string) ($r['address'] ?? '')) ?></td>
<td class="text-left pl-1 sl-col-product"><?= esc((string) ($r['product_name'] ?? '')) ?></td>
<td class="text-right tabular-nums sl-col-num"><?= esc((string) ($r['qty'] ?? '')) ?></td>
<td class="text-right tabular-nums sl-col-num"><?= esc((string) ($r['amount'] ?? '')) ?></td>
<td class="text-right tabular-nums sl-col-num"><?= esc((string) ($r['fee'] ?? '')) ?></td>
<td class="text-right tabular-nums sl-col-num"><?= esc((string) ($r['total'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php if ($ledgerRows === []): ?>
<tr><td colspan="<?= ($mode ?? 'daily') === 'daily' ? '10' : '9' ?>" class="text-center text-gray-400 py-6">조회된 판매 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<p class="text-sm text-gray-700 mt-2 mb-0 no-print">판매건수(상세 행): <?= number_format((int) ($saleLineCount ?? 0)) ?>건</p>
</section>
<script>
(function () {
const form = document.getElementById('sales-ledger-form');
const catAll = document.getElementById('cat-all');
const items = () => Array.from(document.querySelectorAll('.cat-item'));
if (!form || !catAll) return;
catAll.addEventListener('change', () => {
if (catAll.checked) items().forEach((el) => { el.checked = false; });
});
items().forEach((el) => {
el.addEventListener('change', () => {
if (el.checked) catAll.checked = false;
if (!items().some((x) => x.checked)) catAll.checked = true;
});
});
form.addEventListener('submit', () => {
if (catAll.checked) items().forEach((el) => { el.checked = false; });
});
})();
</script>

View File

@@ -1,64 +1,227 @@
<?= view('components/print_header', ['printTitle' => '지정판매소별 판매현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<?php
declare(strict_types=1);
/** @var string $startDate */
/** @var string $endDate */
/** @var string $zoneCode */
/** @var string $bagCode */
/** @var string $catFilter */
/** @var string $metric */
/** @var list<string> $zoneOptions */
/** @var array<string, string> $bagOptions */
/** @var array<string, string> $catLabels */
/** @var list<array<string, mixed>> $reportRows */
/** @var list<float> $grandMonths */
/** @var float $grandTotal */
/** @var string $lgName */
/** @var string $zoneLabel */
/** @var string $bagLabel */
/** @var string $catLabelFilter */
/** @var string $metricLabel */
/** @var list<string> $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;
?>
<?= view('components/print_header', [
'printTitle' => '지정 판매소별 판매현황',
'printExtraLines' => $printExtraLines ?? [],
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">지정판매소별 판매현황</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<span class="text-sm font-bold text-gray-700">지정 판매소별 판매현황</span>
<div class="flex flex-wrap gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($excelUrl, 'attr') ?>" class="inline-flex items-center border border-green-600 text-green-700 px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">
<?php if ($isAmt): ?>
금액은 조회기간 내 판매(sale) 건의 판매금액을 거래 월별로 합산합니다(반품·취소는 제외).
<?php else: ?>
수량은 반품·판매취소를 연초부터 판매와 품목별 선입선출로 맞추고, 반품취소는 원복합니다. 조회에 포함되지 않은 달의 수치는 조회 구간의 첫 달에 합쳐 집계됩니다.
<?php endif; ?>
</p>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/shop-sales') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<label class="text-sm text-gray-600">~</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<section class="p-3 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= mgmt_url('reports/shop-sales') ?>" class="flex flex-wrap items-end gap-3 text-sm">
<div>
<label class="block text-gray-600 mb-0.5">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
</div>
<div>
<label class="block text-gray-600 mb-0.5">종료일</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
</div>
<div>
<label class="block text-gray-600 mb-0.5">읍면동</label>
<select name="zone_code" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem] max-w-[16rem]">
<option value="">전체</option>
<?php foreach ($zoneOptions ?? [] as $z): ?>
<option value="<?= esc($z, 'attr') ?>" <?= ($zoneCode ?? '') === $z ? 'selected' : '' ?>><?= esc($z) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">봉투 종류</label>
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem] max-w-[20rem]">
<option value="">전체</option>
<?php foreach (($bagOptions ?? []) as $bc => $bn): ?>
<option value="<?= esc((string) $bc, 'attr') ?>" <?= ($bagCode ?? '') === (string) $bc ? 'selected' : '' ?>>
<?= esc(trim((string) $bc . (($bn ?? '') !== '' ? ' · ' . $bn : ''))) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">구분</label>
<select name="cat" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[9rem]">
<option value="" <?= ($catFilter ?? '') === '' ? 'selected' : '' ?>>전체</option>
<?php foreach (($catLabels ?? []) as $ck => $lab): ?>
<option value="<?= esc($ck, 'attr') ?>" <?= ($catFilter ?? '') === $ck ? 'selected' : '' ?>><?= esc($lab) ?></option>
<?php endforeach; ?>
</select>
</div>
<fieldset class="border border-gray-200 rounded px-2 py-1">
<legend class="text-xs text-gray-600 px-1">집계 대상</legend>
<label class="inline-flex items-center gap-1 mr-3 cursor-pointer">
<input type="radio" name="metric" value="qty" <?= ! $isAmt ? 'checked' : '' ?>/>
<span>수량</span>
</label>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="metric" value="amt" <?= $isAmt ? 'checked' : '' ?>/>
<span>금액</span>
</label>
</fieldset>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th>판매소명</th>
<th>판매수량</th>
<th>판매금액</th>
<th>반품수량</th>
<th>반품금액</th>
<th>순판매수량</th>
<th>순판매금액</th>
</tr>
</thead>
<tbody class="text-right">
<?php
$totSaleQty = 0; $totSaleAmt = 0; $totRetQty = 0; $totRetAmt = 0;
foreach ($result as $row):
$totSaleQty += (int) $row->sale_qty;
$totSaleAmt += (int) $row->sale_amount;
$totRetQty += (int) $row->return_qty;
$totRetAmt += (int) $row->return_amount;
?>
<tr>
<td class="text-left pl-2"><?= esc($row->bs_ds_name) ?></td>
<td><?= number_format((int) $row->sale_qty) ?></td>
<td><?= number_format((int) $row->sale_amount) ?></td>
<td><?= number_format((int) $row->return_qty) ?></td>
<td><?= number_format((int) $row->return_amount) ?></td>
<td class="font-bold"><?= number_format((int) $row->sale_qty - (int) $row->return_qty) ?></td>
<td class="font-bold"><?= number_format((int) $row->sale_amount - (int) $row->return_amount) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($result)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php else: ?>
<tr class="font-bold bg-gray-100">
<td class="text-center">합계</td>
<td><?= number_format($totSaleQty) ?></td>
<td><?= number_format($totSaleAmt) ?></td>
<td><?= number_format($totRetQty) ?></td>
<td><?= number_format($totRetAmt) ?></td>
<td><?= number_format($totSaleQty - $totRetQty) ?></td>
<td><?= number_format($totSaleAmt - $totRetAmt) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<section class="p-3 bg-white shop-sales-report-section">
<style>
@media print {
@page {
size: A4 landscape;
margin: 5mm 6mm;
}
.shop-sales-report-section { padding: 0 !important; }
.shop-sales-scroll-wrap {
overflow: visible !important;
border: 1px solid #333 !important;
}
#shop-sales-table {
font-size: 7pt !important;
width: 100% !important;
table-layout: fixed !important;
}
#shop-sales-table th,
#shop-sales-table td {
min-width: 0 !important;
padding: 1px 2px !important;
white-space: normal !important;
word-break: break-word;
overflow-wrap: anywhere;
line-height: 1.12;
vertical-align: top;
}
#shop-sales-table th { font-size: 6.5pt !important; }
}
@media screen {
#shop-sales-table td.num-cell { white-space: nowrap; }
}
</style>
<div class="mb-2 text-center no-print">
<h1 class="text-lg font-bold m-0">지정 판매소별 판매현황</h1>
<p class="text-sm text-gray-700 m-1"><?= esc(trim(($lgName ?? '') . ' · ' . ($startDate ?? '') . ' ~ ' . ($endDate ?? '') . ' · 읍면동: ' . ($zoneLabel ?? '') . ' · 집계: ' . ($metricLabel ?? ''))) ?></p>
<p class="text-xs text-gray-500 m-0"><?= $isAmt ? '(단위: 원)' : '(단위: 매)' ?></p>
</div>
<div class="shop-sales-scroll-wrap border border-gray-300 overflow-x-auto">
<table class="w-full data-table text-xs" id="shop-sales-table">
<colgroup>
<col style="width: 14%;"/>
<col style="width: 7%;"/>
<col style="width: 16%;"/>
<col style="width: 6%;"/>
<?php for ($i = 0; $i < 12; $i++): ?>
<col style="width: <?= esc((string) round(57 / 12, 2), 'attr') ?>%;"/>
<?php endfor; ?>
</colgroup>
<thead>
<tr>
<th class="text-left pl-2">지정판매소</th>
<th>대표자명</th>
<th class="text-left pl-1">주소</th>
<th>합계</th>
<?php for ($m = 1; $m <= 12; $m++): ?>
<th><?= $m ?>월</th>
<?php endfor; ?>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($reportRows ?? [] as $rw): ?>
<tr>
<td class="text-left pl-2 font-medium"><?= esc((string) ($rw['name'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($rw['rep'] ?? '')) ?></td>
<td class="text-left pl-1"><?= esc((string) ($rw['address'] ?? '')) ?></td>
<td class="num-cell tabular-nums"><?= $fmtVal((float) ($rw['total'] ?? 0)) ?></td>
<?php foreach (($rw['months'] ?? []) as $mv): ?>
<td class="num-cell tabular-nums"><?= $fmtVal((float) $mv) ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
<?php if (($reportRows ?? []) === []): ?>
<tr>
<td colspan="<?= (int) $colCount ?>" class="text-center text-gray-400 py-6">조회된 데이터가 없습니다.</td>
</tr>
<?php else: ?>
<tr class="bg-gray-100 font-bold">
<td colspan="3" class="text-center">전체 합계</td>
<td class="num-cell tabular-nums"><?= $fmtVal((float) ($grandTotal ?? 0)) ?></td>
<?php foreach (($grandMonths ?? []) as $gm): ?>
<td class="num-cell tabular-nums"><?= $fmtVal((float) $gm) ?></td>
<?php endforeach; ?>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<script>
(function () {
const metric = <?= json_encode($isAmt ? 'amt' : 'qty', JSON_THROW_ON_ERROR) ?>;
const start = <?= json_encode((string) ($startDate ?? ''), JSON_THROW_ON_ERROR) ?>;
const end = <?= json_encode((string) ($endDate ?? ''), JSON_THROW_ON_ERROR) ?>;
let savedTitle = document.title;
function stamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '_' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
}
window.addEventListener('beforeprint', function () {
savedTitle = document.title;
document.title = '지정판매소별판매현황_' + metric + '_' + start + '_' + end + '_' + stamp();
});
window.addEventListener('afterprint', function () {
document.title = savedTitle;
});
})();
</script>

View File

@@ -1,134 +1,231 @@
<?= view('components/print_header', ['printTitle' => '봉투 수불 현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<?php
$refDate = (string) ($refDate ?? date('Y-m-d'));
$leadDays = (int) ($leadDays ?? 40);
$stockScope = (string) ($stockScope ?? 'all');
$salesScope = (string) ($salesScope ?? 'all');
$rows = is_array($rows ?? null) ? $rows : [];
$queried = (bool) ($queried ?? false);
$stockLabel = (string) ($stockLabel ?? 'ALL');
$salesLabel = (string) ($salesLabel ?? 'ALL');
$fmtKrRef = static function (string $ymd): string {
$ts = strtotime($ymd);
return $ts ? date('Y.m.d', $ts) . ' 현재' : $ymd;
};
$printExtraLines = [
$fmtKrRef($refDate),
'적정재고 보유일수(제작기일): ' . $leadDays . '일',
'현재고: ' . $stockLabel . ' · 월평균판매량: ' . $salesLabel,
'※ 제작기일 ' . $leadDays . '일 기준으로 발주예정일 산정 (레거시 화면 유추)',
];
?>
<?= view('components/print_header', [
'printTitle' => '쓰레기봉투 수급 계획',
'printExtraLines' => $printExtraLines,
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">봉투 수불 현황</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<span class="text-sm font-bold text-gray-700">쓰레기봉투 수급 계획</span>
<div class="flex flex-wrap items-center gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
<a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">종료</a>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/supply-demand') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<label class="text-sm text-gray-600">~</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<section class="p-2 bg-white border-b border-gray-200 no-print text-sm">
<form method="get" action="<?= mgmt_url('reports/supply-demand') ?>" class="flex flex-wrap items-end gap-x-4 gap-y-3">
<input type="hidden" name="search" value="1"/>
<div class="flex flex-wrap items-center gap-2">
<label class="font-bold text-gray-700 whitespace-nowrap">기준일</label>
<input type="date" name="ref_date" value="<?= esc($refDate) ?>" class="border border-gray-300 rounded px-2 py-1" required/>
<span class="text-gray-600"><?= esc($fmtKrRef($refDate)) ?></span>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="font-bold text-gray-700 whitespace-nowrap">적정재고 보유일수</label>
<input type="number" name="lead_days" value="<?= (int) $leadDays ?>" min="1" max="365"
class="border border-gray-300 rounded px-2 py-1 w-20 text-right" title="제작기일(발주예정일 산정)"/>
<span class="text-blue-700 text-xs">※ 제작기일 <?= (int) $leadDays ?>일 기준으로 발주예정일 산정</span>
</div>
<fieldset class="flex flex-wrap items-center gap-2 border-0 p-0 m-0">
<legend class="font-bold text-gray-700 whitespace-nowrap mr-1">현재고 선택 옵션</legend>
<?php foreach (['all' => 'ALL', 'legacy' => '기존 봉투', 'barcode' => '바코드 봉투'] as $val => $lab): ?>
<label class="inline-flex items-center gap-1">
<input type="radio" name="stock_scope" value="<?= esc($val) ?>" <?= $stockScope === $val ? 'checked' : '' ?>/>
<?= esc($lab) ?>
</label>
<?php endforeach; ?>
</fieldset>
<fieldset class="flex flex-wrap items-center gap-2 border-0 p-0 m-0">
<legend class="font-bold text-gray-700 whitespace-nowrap mr-1">월 평균판매량 선택 옵션</legend>
<?php foreach (['all' => 'ALL', 'legacy' => '기존 봉투', 'barcode' => '바코드 봉투'] as $val => $lab): ?>
<label class="inline-flex items-center gap-1">
<input type="radio" name="sales_scope" value="<?= esc($val) ?>" <?= $salesScope === $val ? 'checked' : '' ?>/>
<?= esc($lab) ?>
</label>
<?php endforeach; ?>
</fieldset>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</form>
<p class="text-xs text-gray-500 mt-2 max-w-4xl">
<strong>기존 봉투</strong> = 입고 팩 바코드 미등록 품목(수기 재고),
<strong>바코드 봉투</strong> = <code class="text-xs">bag_receiving_pack_code</code> 등록 품목.
월판매량은 최근 12개월 순판매(또는 바코드 판매 스캔)의 월평균입니다.
소진일수 = (총재고÷월판매량)×30, 발주예정일 = 기준일+소진일수−보유일수, 과거일은 빨간색.
</p>
</section>
<div class="grid grid-cols-2 gap-4 mt-2">
<!-- 현재 재고 -->
<div class="border border-gray-300 rounded overflow-auto">
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5">
<span class="text-sm font-bold text-gray-700">현재 재고</span>
</div>
<table class="w-full data-table">
<thead>
<tr>
<th>봉투코드</th>
<th>봉투명</th>
<th>재고수량</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($inventory as $row): ?>
<tr>
<td class="text-center font-mono"><?= esc($row->bi_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bi_bag_name) ?></td>
<td class="font-bold"><?= number_format((int) $row->bi_qty) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($inventory)): ?>
<tr><td colspan="3" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- 기간 입고 -->
<div class="border border-gray-300 rounded overflow-auto">
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5">
<span class="text-sm font-bold text-gray-700">기간 입고</span>
</div>
<table class="w-full data-table">
<thead>
<tr>
<th>봉투코드</th>
<th>봉투명</th>
<th>입고수량</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($receiving as $row): ?>
<tr>
<td class="text-center font-mono"><?= esc($row->br_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->br_bag_name) ?></td>
<td><?= number_format((int) $row->recv_qty) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($receiving)): ?>
<tr><td colspan="3" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- 기간 판매 -->
<div class="border border-gray-300 rounded overflow-auto">
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5">
<span class="text-sm font-bold text-gray-700">기간 판매</span>
</div>
<table class="w-full data-table">
<thead>
<tr>
<th>봉투코드</th>
<th>봉투명</th>
<th>판매수량</th>
<th>반품수량</th>
<th>순판매</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($sales as $row): ?>
<tr>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td>
<td><?= number_format((int) $row->sale_qty) ?></td>
<td><?= number_format((int) $row->return_qty) ?></td>
<td class="font-bold"><?= number_format((int) $row->sale_qty - (int) $row->return_qty) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($sales)): ?>
<tr><td colspan="5" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- 기간 불출 -->
<div class="border border-gray-300 rounded overflow-auto">
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5">
<span class="text-sm font-bold text-gray-700">기간 불출</span>
</div>
<table class="w-full data-table">
<thead>
<tr>
<th>봉투코드</th>
<th>봉투명</th>
<th>불출수량</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($issues as $row): ?>
<tr>
<td class="text-center font-mono"><?= esc($row->bi2_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bi2_bag_name) ?></td>
<td><?= number_format((int) $row->issue_qty) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($issues)): ?>
<tr><td colspan="3" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if (! $queried): ?>
<div class="m-2 p-3 border border-blue-200 bg-blue-50 text-sm text-blue-900 no-print">
기준일·옵션을 선택한 뒤 <strong>조회</strong>를 눌러 주세요.
</div>
<?php endif; ?>
<div class="supply-plan-print-sheet">
<div class="supply-plan-print m-2 border border-gray-300 overflow-auto print:m-0">
<table class="w-full data-table text-sm supply-plan-table">
<thead>
<tr class="bg-gray-100">
<th colspan="4" class="text-center border-b border-gray-300 sp-group-h">최근 발주 내역</th>
<th colspan="5" class="text-center border-b border-gray-300 border-l sp-group-h">현재고 및 예상 판매일수</th>
<th colspan="2" class="text-center border-b border-gray-300 border-l sp-group-h">추가발주 예정내역</th>
</tr>
<tr>
<th class="sp-col-date">발주일자</th>
<th class="sp-col-name">봉투종류</th>
<th class="sp-col-num text-right">발주량</th>
<th class="sp-col-num text-right">발주시재고</th>
<th class="sp-col-num text-right border-l">현재고</th>
<th class="sp-col-num text-right">입고예정량</th>
<th class="sp-col-num text-right">총재고</th>
<th class="sp-col-num text-right">월판매량</th>
<th class="sp-col-num text-right">소진일수(일)</th>
<th class="sp-col-date text-center border-l">발주예정일</th>
<th class="sp-col-num text-right">발주수량</th>
</tr>
</thead>
<tbody>
<?php if ($queried && $rows === []): ?>
<tr>
<td colspan="11" class="text-center text-gray-500 py-8">표시할 품목이 없습니다.</td>
</tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<?php
$depl = (int) ($row['depletion_days'] ?? 0);
$deplDisplay = $depl <= 0 ? '—' : number_format($depl);
$sched = (string) ($row['schedule_date'] ?? '');
$schedOver = (bool) ($row['schedule_overdue'] ?? false);
$schedDisplay = '—';
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $sched, $m)) {
$y = (int) $m[1];
if ($y >= 1990 && $y <= 2200) {
$schedDisplay = $m[1] . '.' . $m[2] . '.' . $m[3];
}
}
?>
<tr>
<td class="sp-col-date text-center"><?= ($row['last_order_date'] ?? '') !== '' ? esc(str_replace('-', '.', (string) $row['last_order_date'])) : '—' ?></td>
<td class="sp-col-name text-left"><?= esc((string) ($row['bag_name'] ?? $row['bag_code'] ?? '')) ?></td>
<td class="sp-col-num text-right tabular-nums"><?= (int) ($row['last_order_qty'] ?? 0) > 0 ? number_format((int) $row['last_order_qty']) : '—' ?></td>
<td class="sp-col-num text-right tabular-nums"><?= (int) ($row['stock_at_order'] ?? 0) > 0 ? number_format((int) $row['stock_at_order']) : '—' ?></td>
<td class="sp-col-num text-right tabular-nums border-l"><?= number_format((int) ($row['current_stock'] ?? 0)) ?></td>
<td class="sp-col-num text-right tabular-nums"><?= number_format((int) ($row['pending_inbound'] ?? 0)) ?></td>
<td class="sp-col-num text-right tabular-nums font-semibold"><?= number_format((int) ($row['total_stock'] ?? 0)) ?></td>
<td class="sp-col-num text-right tabular-nums"><?= number_format((int) ($row['monthly_avg_sales'] ?? 0)) ?></td>
<td class="sp-col-num text-right tabular-nums"><?= esc($deplDisplay) ?></td>
<td class="sp-col-date text-center border-l <?= $schedOver ? 'text-red-600 font-bold' : '' ?>"><?= esc($schedDisplay) ?></td>
<td class="sp-col-num text-right tabular-nums <?= (int) ($row['order_qty'] ?? 0) > 0 ? 'text-red-600 font-bold' : '' ?>">
<?= number_format((int) ($row['order_qty'] ?? 0)) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<style>
.supply-plan-table thead th { font-size: 0.75rem; line-height: 1.2; padding: 0.35rem 0.25rem; }
.supply-plan-table tbody td { padding: 0.25rem 0.35rem; }
@media screen {
.supply-plan-print { overflow-x: auto; }
.supply-plan-table { min-width: 960px; }
}
@media print {
@page {
size: A4 portrait;
margin: 10mm 8mm;
}
.no-print { display: none !important; }
.supply-plan-print-sheet {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box;
}
.supply-plan-print {
border: none !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
width: 100% !important;
max-width: 100% !important;
}
.supply-plan-table.data-table {
min-width: 0 !important;
width: 100% !important;
max-width: 100% !important;
table-layout: fixed !important;
font-size: 6px !important;
}
.supply-plan-table.data-table th,
.supply-plan-table.data-table td {
white-space: normal !important;
word-break: keep-all;
overflow-wrap: anywhere;
padding: 1px 1px !important;
line-height: 1.1;
vertical-align: middle;
}
.supply-plan-table .sp-group-h {
font-size: 5px !important;
padding: 1px !important;
}
/* 세로 A4: 날짜 2×4.5% + 품목 11% + 수치 8×10% = 100% */
.supply-plan-table .sp-col-date {
width: 4.5%;
font-size: 5px !important;
text-align: center;
}
.supply-plan-table .sp-col-name {
width: 11%;
text-align: left !important;
font-size: 5px !important;
line-height: 1.2;
}
.supply-plan-table .sp-col-num {
width: 10%;
font-size: 5px !important;
text-align: right !important;
}
}
</style>

View File

@@ -1,62 +1,232 @@
<?= view('components/print_header', ['printTitle' => '년 판매 현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<?php
declare(strict_types=1);
/** @var int $year */
/** @var string $gugunCode */
/** @var int $saIdx */
/** @var list<object> $agencies */
/** @var list<array{code: string, name: string}> $gugunOptions */
/** @var list<array{id: string, label: string}> $colSpec */
/** @var list<array{name: string, lines: list<array<string,mixed>>}> $itemBlocks */
/** @var array{name: string, lines: list<array<string,mixed>>} $footerBlock */
/** @var bool $hasBsFee */
/** @var string $lgName */
/** @var string $gugunLabel */
/** @var string $agencyLabel */
/** @var list<string> $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)));
};
?>
<?= view('components/print_header', [
'printTitle' => ((int) ($year ?? date('Y'))) . '년 판매 현황',
'printExtraLines' => $printExtraLines ?? [],
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">년 판매 현황 (월별)</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<span class="text-sm font-bold text-gray-700">년 판매 현황</span>
<div class="flex flex-wrap gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($excelUrl, 'attr') ?>" class="inline-flex items-center border border-green-600 text-green-700 px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/yearly-sales') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">연도</label>
<select name="year" class="border border-gray-300 rounded px-2 py-1 text-sm">
<?php for ($y = (int) date('Y'); $y >= 2020; $y--): ?>
<option value="<?= $y ?>" <?= (int)($year ?? date('Y')) === $y ? 'selected' : '' ?>><?= $y ?>년</option>
<?php endfor; ?>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<section class="p-3 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= mgmt_url('reports/yearly-sales') ?>" class="flex flex-wrap items-end gap-3 text-sm">
<div>
<label class="block text-gray-600 mb-0.5">조회 년도</label>
<select name="year" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[7rem]">
<?php for ($y = $yMax; $y >= $yMin; $y--): ?>
<option value="<?= $y ?>" <?= (int) ($year ?? date('Y')) === $y ? 'selected' : '' ?>><?= $y ?>년</option>
<?php endfor; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">구·군</label>
<select name="gugun_code" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem] max-w-[18rem]">
<option value="">전체</option>
<?php foreach ($gugunOptions ?? [] as $g): ?>
<?php $gc = (string) ($g['code'] ?? ''); ?>
<option value="<?= esc($gc, 'attr') ?>" <?= ($gugunCode ?? '') === $gc ? 'selected' : '' ?>><?= esc((string) ($g['name'] ?? $gc)) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">대행소</label>
<select name="sa_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[12rem] max-w-[20rem]">
<option value="0">전체</option>
<?php foreach ($agencies ?? [] as $agency): ?>
<?php $aid = (int) ($agency->sa_idx ?? 0); ?>
<option value="<?= esc((string) $aid) ?>" <?= (int) ($saIdx ?? 0) === $aid ? 'selected' : '' ?>>
<?= esc(trim((string) ($agency->sa_name ?? ''))) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th>봉투코드</th>
<th>봉투명</th>
<th>1월</th><th>2월</th><th>3월</th><th>4월</th><th>5월</th><th>6월</th>
<th>7월</th><th>8월</th><th>9월</th><th>10월</th><th>11월</th><th>12월</th>
<th class="bg-gray-100">합계</th>
</tr>
</thead>
<tbody class="text-right">
<?php
$grandTotal = array_fill(1, 13, 0); // 1~12 + 13=total
foreach ($result as $row):
?>
<tr>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td>
<?php for ($m = 1; $m <= 12; $m++):
$key = 'm' . sprintf('%02d', $m);
$val = (int) $row->$key;
$grandTotal[$m] += $val;
?>
<td><?= $val > 0 ? number_format($val) : '-' ?></td>
<?php endfor; ?>
<?php $grandTotal[13] += (int) $row->total; ?>
<td class="font-bold bg-gray-50"><?= number_format((int) $row->total) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($result)): ?>
<tr><td colspan="15" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php else: ?>
<tr class="font-bold bg-gray-100">
<td colspan="2" class="text-center">합계</td>
<?php for ($m = 1; $m <= 12; $m++): ?>
<td><?= $grandTotal[$m] > 0 ? number_format($grandTotal[$m]) : '-' ?></td>
<?php endfor; ?>
<td class="bg-gray-200"><?= number_format($grandTotal[13]) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<section class="p-3 bg-white yearly-sales-report-section">
<style>
/* 화면: 가로 스크롤. 인쇄: 가로 용지 + 작은 글자 + 셀 줄바꿈으로 한 페이지에 맞춤 */
@media print {
@page {
size: A4 landscape;
margin: 5mm 6mm;
}
.yearly-sales-report-section {
padding: 0 !important;
}
.yearly-sales-scroll-wrap {
overflow: visible !important;
border: 1px solid #333 !important;
max-width: none !important;
}
#yearly-sales-table {
font-size: 7pt !important;
width: 100% !important;
max-width: 100% !important;
table-layout: fixed !important;
}
#yearly-sales-table th,
#yearly-sales-table td {
min-width: 0 !important;
max-width: none !important;
padding: 1px 2px !important;
white-space: normal !important;
word-break: break-word;
overflow-wrap: anywhere;
line-height: 1.15;
vertical-align: top;
}
#yearly-sales-table th {
font-size: 6.5pt !important;
font-weight: 700;
}
}
@media screen {
#yearly-sales-table td.tabular-nums {
white-space: nowrap;
}
}
</style>
<div class="mb-2 text-center no-print">
<h1 class="text-lg font-bold m-0"><?= (int) ($year ?? date('Y')) ?>년 판매 현황</h1>
<p class="text-sm text-gray-700 m-1"><?= esc(trim(($lgName ?? '') . ' · 구·군: ' . ($gugunLabel ?? '') . ' · 대행소: ' . ($agencyLabel ?? ''))) ?></p>
<p class="text-xs text-gray-500 m-0">(단위: 매 / 원)</p>
</div>
<div class="yearly-sales-scroll-wrap border border-gray-300 overflow-x-auto">
<table class="w-full data-table text-xs sm:text-sm" id="yearly-sales-table">
<colgroup>
<col style="width: 9%;"/>
<col style="width: 5%;"/>
<?php foreach (($colSpec ?? []) as $_): ?>
<col style="width: <?= esc((string) $metricColPct, 'attr') ?>%;"/>
<?php endforeach; ?>
</colgroup>
<thead>
<tr>
<th class="align-middle min-w-0 sm:min-w-[7rem] max-w-[10rem] sm:max-w-[12rem] text-left pl-2">품목</th>
<th class="align-middle min-w-0 sm:min-w-[4.5rem]">구분</th>
<?php foreach ($colSpec ?? [] as $col): ?>
<th class="align-middle text-center min-w-0 sm:min-w-[4.5rem] border-l border-gray-200"><?= esc((string) ($col['label'] ?? '')) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody class="text-right">
<?php if (! ($hasYearlyData ?? false)): ?>
<tr>
<td colspan="<?= (int) $colCount ?>" class="text-center text-gray-400 py-6">조회된 데이터가 없습니다.</td>
</tr>
<?php else: ?>
<?php foreach ($itemBlocks ?? [] as $block): ?>
<?php $lines = $block['lines'] ?? []; ?>
<?php foreach ($lines as $liIdx => $li): ?>
<tr class="odd:bg-white even:bg-gray-50/80">
<?php if ($liIdx === 0): ?>
<td rowspan="4" class="text-left align-top pl-2 pt-1 font-medium border-r border-gray-200"><?= esc((string) ($block['name'] ?? '')) ?></td>
<?php endif; ?>
<td class="text-left pl-2 border-r border-gray-100"><?= esc((string) ($li['measure'] ?? '')) ?></td>
<?php
$cells = (array) ($li['cells'] ?? []);
$mk = (string) ($li['measureKey'] ?? '');
?>
<?php foreach ($colSpec ?? [] as $col): ?>
<?php $cid = (string) ($col['id'] ?? ''); ?>
<?php $cell = (array) ($cells[$cid] ?? ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]); ?>
<td class="border-l border-gray-100 tabular-nums"><?= $fmtMeasureCell($cell, $mk, (bool) ($hasBsFee ?? false)) ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
<?php endforeach; ?>
<?php $fLines = $footerBlock['lines'] ?? []; ?>
<?php foreach ($fLines as $fIdx => $li): ?>
<tr class="bg-amber-50 font-semibold border-t-2 border-amber-200">
<?php if ($fIdx === 0): ?>
<td rowspan="4" class="text-center align-middle text-amber-900 border-r border-amber-200"><?= esc((string) ($footerBlock['name'] ?? '전체 합계')) ?></td>
<?php endif; ?>
<td class="text-left pl-2 border-r border-amber-100"><?= esc((string) ($li['measure'] ?? '')) ?></td>
<?php
$cells = (array) ($li['cells'] ?? []);
$mk = (string) ($li['measureKey'] ?? '');
?>
<?php foreach ($colSpec ?? [] as $col): ?>
<?php $cid = (string) ($col['id'] ?? ''); ?>
<?php $cell = (array) ($cells[$cid] ?? ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]); ?>
<td class="border-l border-amber-100 tabular-nums"><?= $fmtMeasureCell($cell, $mk, (bool) ($hasBsFee ?? false)) ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<script>
(function () {
const year = <?= json_encode((int) ($year ?? (int) date('Y')), JSON_THROW_ON_ERROR) ?>;
let savedTitle = document.title;
function stamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '_' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
}
window.addEventListener('beforeprint', function () {
savedTitle = document.title;
document.title = '년판매현황_' + year + '_' + stamp();
});
window.addEventListener('afterprint', function () {
document.title = savedTitle;
});
})();
</script>

View File

@@ -1,74 +1,301 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">주문 접수</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-4xl">
<div class="border border-gray-300 p-4 mt-2 bg-white">
<form action="<?= mgmt_url('shop-orders/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">판매소 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="so_ds_idx" required>
<option value="">선택</option>
<?php foreach ($shops as $shop): ?>
<option value="<?= esc($shop->ds_idx) ?>" <?= (int) old('so_ds_idx') === (int) $shop->ds_idx ? 'selected' : '' ?>>
<?= esc($shop->ds_name) ?>
</option>
<?php endforeach; ?>
</select>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">판매소 검색 <span class="text-red-500">*</span></label>
<input id="shop-search" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-72" type="text" list="shop-search-list" placeholder="코드/사업자번호/대표자명/상호/전화/주소"/>
<datalist id="shop-search-list">
<?php foreach ($shops as $shop): ?>
<option value="<?= esc(trim(($shop->ds_shop_no ?? '') . ' ' . ($shop->ds_name ?? '') . ' ' . ($shop->ds_rep_name ?? '') . ' ' . ($shop->ds_biz_no ?? '') . ' ' . ($shop->ds_tel ?? '') . ' ' . ($shop->ds_addr ?? ''))) ?>"></option>
<?php endforeach; ?>
</datalist>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">판매소 선택 <span class="text-red-500">*</span></label>
<select id="shop-select" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-72" name="so_ds_idx" required>
<option value="">선택</option>
<?php foreach ($shops as $shop): ?>
<option
value="<?= esc($shop->ds_idx) ?>"
data-shop-no="<?= esc((string) ($shop->ds_shop_no ?? '')) ?>"
data-name="<?= esc((string) ($shop->ds_name ?? '')) ?>"
data-rep-name="<?= esc((string) ($shop->ds_rep_name ?? '')) ?>"
data-rep-phone="<?= esc((string) ($shop->ds_rep_phone ?? '')) ?>"
data-tel="<?= esc((string) ($shop->ds_tel ?? '')) ?>"
data-address="<?= esc(trim((string) ($shop->ds_addr ?? '') . ' ' . (string) ($shop->ds_addr_detail ?? ''))) ?>"
data-va-bank="<?= esc((string) ($shop->ds_va_bank ?? '')) ?>"
data-va-account="<?= esc((string) ($shop->ds_va_account ?? '')) ?>"
<?= (int) old('so_ds_idx') === (int) $shop->ds_idx ? 'selected' : '' ?>
>
<?= esc(($shop->ds_shop_no ? '[' . $shop->ds_shop_no . '] ' : '') . $shop->ds_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="border border-gray-300 p-2 bg-gray-50">
<div class="text-sm font-bold text-gray-700 mb-2">지정판매소 정보</div>
<table class="w-full text-sm">
<tr><th class="text-left w-28 py-1">판매소 코드</th><td id="shop-info-code" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">상호</th><td id="shop-info-name" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">대표자명</th><td id="shop-info-rep" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">연락처</th><td id="shop-info-tel" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">주소</th><td id="shop-info-addr" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">가상계좌</th><td id="shop-info-va" class="py-1 text-gray-700">-</td></tr>
</table>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">접수일</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44 bg-gray-100" type="date" value="<?= esc(date('Y-m-d')) ?>" readonly/>
<span class="text-xs text-gray-500">배달일 기본값은 접수일 다음날입니다.</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">배달일 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="so_delivery_date" type="date" value="<?= esc(old('so_delivery_date', date('Y-m-d', strtotime('+1 day')))) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">결제방법 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="so_payment_type" required>
<select id="payment-type" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="so_payment_type" required>
<option value="">선택</option>
<option value="이체" <?= old('so_payment_type') === '이체' ? 'selected' : '' ?>>이체</option>
<option value="가상계좌" <?= old('so_payment_type') === '가상계좌' ? 'selected' : '' ?>>가상계좌</option>
</select>
<span id="payment-guide" class="text-xs text-gray-500"></span>
</div>
<div class="mt-4">
<label class="block text-sm font-bold text-gray-700 mb-2">주문 품목</label>
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-bold text-gray-700">전화 주문 접수표</label>
<button type="button" id="add-order-row" class="border border-gray-300 bg-white px-3 py-1 rounded-sm text-xs text-gray-700 hover:bg-gray-50">행 추가</button>
</div>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-16">순번</th>
<th>봉투</th>
<th class="w-32">수량</th>
<th class="w-14">순번</th>
<th class="w-48">품목</th>
<th class="w-36">1박스(낱장/판매가)</th>
<th class="w-36">1팩(낱장/판매가)</th>
<th class="w-24">단가</th>
<th class="w-28">주문수량</th>
<th class="w-28">금액</th>
<th class="w-32">포장(박스/팩/낱장)</th>
<th class="w-20">행삭제</th>
</tr>
</thead>
<tbody>
<tbody id="order-rows">
<?php for ($i = 0; $i < 3; $i++): ?>
<tr>
<td class="text-center"><?= $i + 1 ?></td>
<tr class="order-row">
<td class="text-center row-no"><?= $i + 1 ?></td>
<td>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full" name="item_bag_code[]">
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full bag-code-select" name="item_bag_code[]">
<option value="">선택</option>
<?php foreach ($bagCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>">
<?php $code = (string) $cd->cd_code; $price = $priceMap[$code] ?? null; $unit = $unitMap[$code] ?? null; ?>
<option value="<?= esc($code) ?>" data-unit-price="<?= esc((string) (int) ($price->bp_consumer ?? 0)) ?>" data-box-sheets="<?= esc((string) (int) ($unit->pu_total_per_box ?? 0)) ?>" data-box-packs="<?= esc((string) (int) ($unit->pu_box_per_pack ?? 0)) ?>" data-pack-sheets="<?= esc((string) (int) ($unit->pu_pack_per_sheet ?? 0)) ?>">
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right" name="item_qty[]" type="number" min="0" value="0"/>
<td class="text-right px-2 box-info-cell">0 / 0</td>
<td class="text-right px-2 pack-info-cell">0 / 0</td>
<td class="text-right px-2 unit-price-cell">0</td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input" name="item_qty[]" type="number" min="0" value="0"/></td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-amount-input" type="number" min="0" step="1" value="0"/></td>
<td class="text-right px-2 pack-result-cell">박스=0, 팩=0, 낱장=0</td>
<td class="text-center px-2">
<button type="button" class="remove-order-row border border-red-300 text-red-600 px-2 py-0.5 rounded text-xs hover:bg-red-50">삭제</button>
</td>
</tr>
<?php endfor; ?>
</tbody>
<tfoot>
<tr class="font-semibold bg-gray-50">
<td colspan="5" class="text-right px-2 py-1">합계</td>
<td class="text-right px-2 py-1" id="sum-qty">0</td>
<td class="text-right px-2 py-1" id="sum-amount">0</td>
<td class="text-right px-2 py-1" id="sum-pack">박스=0, 팩=0, 낱장=0</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">저장</button>
<a href="<?= mgmt_url('shop-orders') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</form>
</div>
<template id="order-row-template">
<tr class="order-row">
<td class="text-center row-no">1</td>
<td>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full bag-code-select" name="item_bag_code[]">
<option value="">선택</option>
<?php foreach ($bagCodes as $cd): ?>
<?php $code = (string) $cd->cd_code; $price = $priceMap[$code] ?? null; $unit = $unitMap[$code] ?? null; ?>
<option value="<?= esc($code) ?>" data-unit-price="<?= esc((string) (int) ($price->bp_consumer ?? 0)) ?>" data-box-sheets="<?= esc((string) (int) ($unit->pu_total_per_box ?? 0)) ?>" data-box-packs="<?= esc((string) (int) ($unit->pu_box_per_pack ?? 0)) ?>" data-pack-sheets="<?= esc((string) (int) ($unit->pu_pack_per_sheet ?? 0)) ?>">
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td class="text-right px-2 box-info-cell">0 / 0</td>
<td class="text-right px-2 pack-info-cell">0 / 0</td>
<td class="text-right px-2 unit-price-cell">0</td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input" name="item_qty[]" type="number" min="0" value="0"/></td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-amount-input" type="number" min="0" step="1" value="0"/></td>
<td class="text-right px-2 pack-result-cell">박스=0, 팩=0, 낱장=0</td>
<td class="text-center px-2">
<button type="button" class="remove-order-row border border-red-300 text-red-600 px-2 py-0.5 rounded text-xs hover:bg-red-50">삭제</button>
</td>
</tr>
</template>
<script>
(function () {
const shopSearch = document.getElementById('shop-search');
const shopSelect = document.getElementById('shop-select');
const paymentType = document.getElementById('payment-type');
const paymentGuide = document.getElementById('payment-guide');
const addRowButton = document.getElementById('add-order-row');
const orderRows = document.getElementById('order-rows');
const rowTemplate = document.getElementById('order-row-template');
const form = shopSelect.closest('form');
function nf(n) { return new Intl.NumberFormat('ko-KR').format(n || 0); }
function updateShopInfo() {
const opt = shopSelect.options[shopSelect.selectedIndex];
const bank = opt?.dataset?.vaBank || '';
const account = opt?.dataset?.vaAccount || '';
const va = bank || account ? [bank, account].filter(Boolean).join(' ') : '-';
document.getElementById('shop-info-code').textContent = opt?.dataset?.shopNo || '-';
document.getElementById('shop-info-name').textContent = opt?.dataset?.name || '-';
document.getElementById('shop-info-rep').textContent = opt?.dataset?.repName || '-';
document.getElementById('shop-info-tel').textContent = opt?.dataset?.tel || opt?.dataset?.repPhone || '-';
document.getElementById('shop-info-addr').textContent = opt?.dataset?.address || '-';
document.getElementById('shop-info-va').textContent = va;
paymentGuide.textContent = paymentType.value === '가상계좌' ? ('가상계좌 안내: ' + va) : '';
}
function matchShopByKeyword(keyword) {
const q = (keyword || '').trim().toLowerCase();
if (!q) { return; }
for (let i = 0; i < shopSelect.options.length; i++) {
const opt = shopSelect.options[i];
const merged = [opt.dataset.shopNo || '', opt.dataset.name || '', opt.dataset.repName || '', opt.dataset.tel || '', opt.dataset.address || '', opt.text || ''].join(' ').toLowerCase();
if (merged.includes(q)) { shopSelect.selectedIndex = i; updateShopInfo(); return; }
}
}
function calcRow(row, source) {
const select = row.querySelector('.bag-code-select');
const qtyInput = row.querySelector('.item-qty-input');
const amountInput = row.querySelector('.item-amount-input');
const selected = select.options[select.selectedIndex];
let qty = parseInt(qtyInput.value || '0', 10) || 0;
const unitPrice = parseInt(selected?.dataset?.unitPrice || '0', 10) || 0;
const boxSheets = parseInt(selected?.dataset?.boxSheets || '0', 10) || 0;
const boxPacks = parseInt(selected?.dataset?.boxPacks || '0', 10) || 0;
const packSheets = parseInt(selected?.dataset?.packSheets || '0', 10) || 0;
const rawAmount = parseInt(amountInput?.value || '0', 10) || 0;
if (source === 'amount' && unitPrice > 0) {
qty = Math.max(0, Math.round(rawAmount / unitPrice));
qtyInput.value = String(qty);
}
let box = 0, pack = 0, sheet = qty;
if (boxSheets > 0) {
box = Math.floor(qty / boxSheets);
const remain = qty % boxSheets;
if (packSheets > 0) { pack = Math.floor(remain / packSheets); sheet = remain % packSheets; } else { sheet = remain; }
} else if (packSheets > 0) {
pack = Math.floor(qty / packSheets);
sheet = qty % packSheets;
}
const amount = unitPrice * qty;
const boxPrice = boxSheets * unitPrice;
const packPrice = packSheets * unitPrice;
row.querySelector('.box-info-cell').textContent = nf(boxSheets) + ' / ' + nf(boxPrice);
row.querySelector('.pack-info-cell').textContent = nf(packSheets) + ' / ' + nf(packPrice);
row.querySelector('.unit-price-cell').textContent = nf(unitPrice);
if (amountInput && source !== 'amount') {
amountInput.value = String(amount);
}
const innerPackCount = box * boxPacks;
const innerSheetCount = box * boxSheets;
row.querySelector('.pack-result-cell').textContent = '박스=' + nf(box) + '(내부 팩=' + nf(innerPackCount) + ', 내부 낱장=' + nf(innerSheetCount) + '), 잔여 팩=' + nf(pack) + ', 잔여 낱장=' + nf(sheet);
return { qty, amount, box, pack, sheet };
}
function recalcAllRows(sourceRow, sourceType) {
let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0;
document.querySelectorAll('.order-row').forEach((row, index) => {
const noCell = row.querySelector('.row-no');
if (noCell) {
noCell.textContent = String(index + 1);
}
const source = row === sourceRow ? sourceType : 'qty';
const r = calcRow(row, source);
sumQty += r.qty; sumAmount += r.amount; sumBox += r.box; sumPack += r.pack; sumSheet += r.sheet;
});
document.getElementById('sum-qty').textContent = nf(sumQty);
document.getElementById('sum-amount').textContent = nf(sumAmount);
document.getElementById('sum-pack').textContent = '박스=' + nf(sumBox) + ', 팩=' + nf(sumPack) + ', 낱장=' + nf(sumSheet);
}
shopSearch?.addEventListener('change', (e) => matchShopByKeyword(e.target.value));
shopSearch?.addEventListener('blur', (e) => matchShopByKeyword(e.target.value));
shopSelect?.addEventListener('change', updateShopInfo);
paymentType?.addEventListener('change', updateShopInfo);
orderRows?.addEventListener('change', function (e) {
const row = e.target.closest('.order-row');
if (e.target.closest('.bag-code-select') || e.target.closest('.item-qty-input')) {
recalcAllRows(row, 'qty');
} else if (e.target.closest('.item-amount-input')) {
recalcAllRows(row, 'amount');
}
});
orderRows?.addEventListener('input', function (e) {
const row = e.target.closest('.order-row');
if (e.target.closest('.item-qty-input')) {
recalcAllRows(row, 'qty');
}
});
orderRows?.addEventListener('click', function (e) {
const removeButton = e.target.closest('.remove-order-row');
if (!removeButton) {
return;
}
const row = removeButton.closest('.order-row');
if (!row) {
return;
}
if (orderRows.querySelectorAll('.order-row').length <= 1) {
alert('최소 1개 행은 유지해야 합니다.');
return;
}
row.remove();
recalcAllRows(null, 'qty');
});
addRowButton?.addEventListener('click', function () {
if (!rowTemplate || !orderRows) {
return;
}
const fragment = rowTemplate.content.cloneNode(true);
orderRows.appendChild(fragment);
recalcAllRows(null, 'qty');
});
form?.addEventListener('submit', function (e) {
let hasItem = false;
document.querySelectorAll('.order-row').forEach((row) => {
const code = row.querySelector('.bag-code-select').value;
const qty = parseInt(row.querySelector('.item-qty-input').value || '0', 10) || 0;
if (code && qty > 0) { hasItem = true; }
});
if (!hasItem) { e.preventDefault(); alert('주문 품목과 수량을 1개 이상 입력해 주세요.'); }
});
updateShopInfo();
recalcAllRows(null, 'qty');
})();
</script>

View File

@@ -26,6 +26,7 @@
<th>판매소</th>
<th>접수일</th>
<th>배달일</th>
<th>접수채널</th>
<th>결제</th>
<th>입금</th>
<th>수령</th>
@@ -42,6 +43,12 @@
<td class="text-left pl-2"><?= esc($row->so_ds_name) ?></td>
<td class="text-center"><?= esc($row->so_order_date) ?></td>
<td class="text-center"><?= esc($row->so_delivery_date) ?></td>
<td class="text-center">
<?php
$channelMap = ['phone' => '전화', 'web' => '웹', 'app' => '앱', 'counter' => '창구'];
echo esc($channelMap[$row->so_channel ?? ''] ?? ($row->so_channel ?? '전화'));
?>
</td>
<td class="text-center"><?= esc($row->so_payment_type) ?></td>
<td class="text-center">
<?php
@@ -72,7 +79,7 @@
</tr>
<?php endforeach; ?>
<?php if (empty($list)): ?>
<tr><td colspan="11" class="text-center text-gray-400 py-4">등록된 주문이 없습니다.</td></tr>
<tr><td colspan="12" class="text-center text-gray-400 py-4">등록된 주문이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>

View File

@@ -5,6 +5,7 @@
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>로그인 - 종량제 시스템</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {

View File

@@ -5,6 +5,7 @@
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>2 인증 - 종량제 시스템</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {

View File

@@ -5,6 +5,7 @@
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>회원가입 - 종량제 시스템</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {

View File

@@ -5,6 +5,7 @@
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>2 인증 등록 - 종량제 시스템</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {

View File

@@ -0,0 +1,123 @@
<?php
$baseYm = (string) ($baseYm ?? date('Y-m'));
$baseYmDot = str_replace('-', '.', $baseYm);
$trendBasis = (string) ($trendBasis ?? 'year_avg');
$deviationMin = (float) ($deviationMin ?? 0);
$queried = (bool) ($queried ?? false);
$filters = is_array($filters ?? null) ? $filters : [];
$rows = is_array($rows ?? null) ? $rows : [];
$agencies = is_array($filters['agencies'] ?? null) ? $filters['agencies'] : [];
$saIdx = (int) ($saIdx ?? 0);
$reportMeta = is_array($reportMeta ?? null) ? $reportMeta : [];
$shopCount = (int) ($reportMeta['shopCount'] ?? 0);
$monthSalesShops = (int) ($reportMeta['monthSalesShops'] ?? 0);
$error = (string) ($error ?? '');
$lgPickNotice = (string) ($lgPickNotice ?? '');
$prevAvgLabel = ($trendBasis ?? '') === 'month' ? '전년 동월' : '전년 평균';
?>
<?= view('components/print_header', [
'printTitle' => '월별 판매 추이 분석',
'printExtraLines' => ['기준년월: ' . $baseYmDot, '(단위: 매)'],
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">월별 판매 추이 분석</span>
<div class="flex gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm">인쇄</button>
<a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm">종료</a>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200 no-print text-sm">
<form method="get" action="<?= site_url('bag/analytics/monthly-trend') ?>" class="flex flex-wrap items-end gap-3">
<input type="hidden" name="search" value="1"/>
<div>
<label class="block text-gray-600 mb-0.5">기준년월</label>
<input type="month" name="base_ym" value="<?= esc($baseYm) ?>" class="border border-gray-300 rounded px-2 py-1 min-w-[11rem] w-full max-w-[14rem]"/>
</div>
<div>
<label class="block text-gray-600 mb-0.5">판매추이기준</label>
<select name="trend_basis" class="border border-gray-300 rounded px-2 py-1 min-w-[12rem] w-full max-w-[16rem]">
<option value="year_avg" <?= $trendBasis === 'year_avg' ? 'selected' : '' ?>>년 평균</option>
<option value="month" <?= $trendBasis === 'month' ? 'selected' : '' ?>>동월(전년)</option>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">편차</label>
<input type="number" name="deviation_min" value="<?= esc((string) $deviationMin) ?>" step="0.01" min="0" class="border border-gray-300 rounded px-2 py-1 w-28"/>
<span class="text-gray-500">% 이상(절대값)</span>
</div>
<div>
<label class="block text-gray-600 mb-0.5">대행소</label>
<select name="sa_idx" class="border border-gray-300 rounded px-2 py-1 min-w-[14rem] w-full max-w-[18rem]">
<option value="0">전체</option>
<?php foreach ($agencies as $agency): ?>
<?php $aid = (int) ($agency->sa_idx ?? 0); ?>
<option value="<?= $aid ?>" <?= $saIdx === $aid ? 'selected' : '' ?>><?= esc((string) ($agency->sa_name ?? '')) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</form>
<p class="text-xs text-gray-500 mt-1">(단위: 매) · <?= esc($prevAvgLabel) ?> 대비 기준월 판매량 편차를 표시합니다.</p>
</section>
<?php if ($error !== ''): ?>
<div class="m-2 p-3 border border-red-200 bg-red-50 text-sm text-red-800 no-print"><?= esc($error) ?></div>
<?php endif; ?>
<?php if ($lgPickNotice !== ''): ?>
<div class="m-2 p-3 border border-blue-200 bg-blue-50 text-sm text-blue-900 no-print"><?= esc($lgPickNotice) ?></div>
<?php endif; ?>
<?php if ($queried && $monthSalesShops === 0 && $shopCount > 0): ?>
<div class="m-2 p-3 border border-amber-200 bg-amber-50 text-sm text-amber-900 no-print">
<strong><?= esc($baseYmDot) ?></strong> 기준월에 등록된 판매 실적이 없습니다.
(지정판매소 <?= number_format($shopCount) ?>곳 · 해당 월 판매 <?= number_format($monthSalesShops) ?>곳)
판매 데이터가 있는 월(예: 2026-05)로 기준년월을 바꿔 조회해 보세요.
</div>
<?php endif; ?>
<div class="m-2 border border-gray-300">
<div class="bg-gray-100 px-3 py-1.5 text-sm font-bold border-b">월별 판매 추이 분석 조회 내역</div>
<div class="overflow-auto max-h-[28rem]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>대행소</th>
<th class="w-24">판매소번호</th>
<th>지정판매소</th>
<th class="w-20">성명</th>
<th class="w-20 text-right"><?= esc($prevAvgLabel) ?></th>
<th class="w-20 text-right">월 판매량</th>
<th class="w-20 text-right">평균 차</th>
<th class="w-16 text-right">편차(%)</th>
<th class="w-24 text-center">지정일자</th>
</tr>
</thead>
<tbody>
<?php if (! $queried): ?>
<tr><td colspan="9" class="text-center text-gray-400 py-8">조회 조건 입력 후 조회</td></tr>
<?php elseif ($rows === [] && $shopCount === 0): ?>
<tr><td colspan="9" class="text-center text-gray-500 py-8">등록된 지정판매소가 없습니다.</td></tr>
<?php elseif ($rows === []): ?>
<tr><td colspan="9" class="text-center text-gray-500 py-8">편차 조건(|%|)에 맞는 자료가 없습니다. (0%이면 전체 표시)</td></tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<tr>
<td class="text-left pl-2"><?= esc((string) ($row['agency_name'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['shop_no'] ?? '')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['shop_name'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['rep_name'] ?? '')) ?></td>
<td class="text-right tabular-nums"><?= number_format((int) ($row['prev_avg'] ?? 0)) ?></td>
<td class="text-right tabular-nums"><?= number_format((int) ($row['monthly_qty'] ?? 0)) ?></td>
<td class="text-right tabular-nums"><?= number_format((int) ($row['avg_diff'] ?? 0)) ?></td>
<td class="text-right tabular-nums"><?= esc((string) ($row['deviation_pct'] ?? '0')) ?></td>
<td class="text-center"><?= esc((string) ($row['designated_at'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,104 @@
<?php
$baseYear = (int) ($baseYear ?? (int) date('Y'));
$season = (string) ($season ?? 'spring');
$seasonLabel = (string) ($seasonLabel ?? '봄');
$seasonMonthsLabel = (string) ($seasonMonthsLabel ?? '');
$deviationMin = (float) ($deviationMin ?? 0);
$queried = (bool) ($queried ?? false);
$filters = is_array($filters ?? null) ? $filters : [];
$rows = is_array($rows ?? null) ? $rows : [];
$seasonCatalog = \App\Libraries\BagAnalyticsReportBuilder::seasonCatalog();
$seasonScope = $seasonMonthsLabel !== ''
? $baseYear . '년 ' . $seasonLabel . ' (' . $seasonMonthsLabel . ')'
: (string) $baseYear . '년 ' . $seasonLabel;
?>
<?= view('components/print_header', [
'printTitle' => '계절별 판매 추이 분석',
'printExtraLines' => ['기준: ' . $seasonScope, '(단위: 매)'],
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">계절별 판매 추이 분석</span>
<div class="flex gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm">인쇄</button>
<a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm">종료</a>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200 no-print text-sm">
<form id="seasonal-trend-form" method="get" action="<?= site_url('bag/analytics/seasonal-trend') ?>" class="flex flex-wrap items-end gap-3">
<input type="hidden" name="search" value="1"/>
<div>
<label class="block text-gray-600 mb-0.5">기준년도</label>
<input type="number" name="base_year" value="<?= (int) $baseYear ?>" min="2000" max="2100" class="border border-gray-300 rounded px-2 py-1 min-w-[7rem] w-28"/>
</div>
<div>
<label class="block text-gray-600 mb-0.5">계절선택</label>
<select name="season" class="border border-gray-300 rounded px-2 py-1 min-w-[14rem] w-full max-w-[18rem]" onchange="this.form.submit()">
<?php foreach ($seasonCatalog as $val => $def): ?>
<option value="<?= esc($val, 'attr') ?>" <?= $season === $val ? 'selected' : '' ?>>
<?= esc((string) $def['label']) ?> (<?= esc((string) $def['months_label']) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">편차</label>
<input type="number" name="deviation_min" value="<?= esc((string) $deviationMin) ?>" step="0.01" min="0" class="border border-gray-300 rounded px-2 py-1 w-28"/>
<span class="text-gray-500">% 이상(절대값)</span>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</form>
<p class="text-xs text-gray-500 mt-1">
(단위: 매) · 계절을 바꾸면 자동 조회됩니다.
<?php if ($queried && $seasonMonthsLabel !== ''): ?>
· 현재: <strong><?= esc($seasonScope) ?></strong> 판매 월평균(3개월 합÷3) vs 전년 동일 계절
<?php endif; ?>
</p>
</section>
<div class="m-2 border border-gray-300">
<div class="bg-gray-100 px-3 py-1.5 text-sm font-bold border-b">
계절별 판매 추이 분석 조회 내역
<?php if ($queried): ?> — <?= esc($seasonScope) ?><?php endif; ?>
</div>
<div class="overflow-auto max-h-[28rem]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>대행소</th>
<th>지정판매소</th>
<th class="w-24">판매소번호</th>
<th class="w-20">성명</th>
<th class="w-24 text-right">전년 계절평균</th>
<th class="w-24 text-right">기준년 계절평균</th>
<th class="w-20 text-right">평균 차</th>
<th class="w-16 text-right">편차(%)</th>
<th class="w-24 text-center">지정일자</th>
</tr>
</thead>
<tbody>
<?php if (! $queried): ?>
<tr><td colspan="9" class="text-center text-gray-400 py-8">조회 조건 입력 후 조회</td></tr>
<?php elseif ($rows === []): ?>
<tr><td colspan="9" class="text-center text-gray-500 py-8">편차 조건(|%|)에 맞는 자료가 없습니다.</td></tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<tr>
<td class="text-left pl-2"><?= esc((string) ($row['agency_name'] ?? '')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['shop_name'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['shop_no'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['rep_name'] ?? '')) ?></td>
<td class="text-right tabular-nums"><?= number_format((int) ($row['prev_season_avg'] ?? 0)) ?></td>
<td class="text-right tabular-nums"><?= number_format((int) ($row['base_season_avg'] ?? 0)) ?></td>
<td class="text-right tabular-nums"><?= number_format((int) ($row['avg_diff'] ?? 0)) ?></td>
<td class="text-right tabular-nums"><?= esc((string) ($row['deviation_pct'] ?? '0')) ?></td>
<td class="text-center"><?= esc((string) ($row['designated_at'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,99 @@
<?php
$year = (int) ($year ?? (int) date('Y'));
$queried = (bool) ($queried ?? false);
$filters = is_array($filters ?? null) ? $filters : [];
$report = is_array($report ?? null) ? $report : [];
$lgName = (string) ($lgName ?? '');
$rows = is_array($report['rows'] ?? null) ? $report['rows'] : [];
$months = is_array($report['months'] ?? null) ? $report['months'] : range(1, 12);
$prevYear = (int) ($report['prevYear'] ?? $year - 1);
$printExtra = [
$lgName !== '' ? $lgName : '',
'(단위: 매, 원) ' . $year . '년',
];
?>
<?= view('components/print_header', ['printTitle' => '전년대비 판매 통계분석', 'printExtraLines' => $printExtra]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">전년 대비 판매 분석</span>
<div class="flex gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm">인쇄</button>
<a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm">종료</a>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200 no-print text-sm">
<form method="get" action="<?= site_url('bag/analytics/year-over-year') ?>" class="flex flex-wrap items-end gap-3">
<input type="hidden" name="search" value="1"/>
<div>
<label class="block text-gray-600 mb-0.5">조회년도</label>
<input type="number" name="year" value="<?= (int) $year ?>" min="2000" max="2100" class="border border-gray-300 rounded px-2 py-1 w-24"/>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</form>
</section>
<div class="m-2 border border-gray-300 overflow-auto">
<p class="text-center text-sm font-bold py-2 bg-gray-50 border-b">전년대비 판매 통계분석</p>
<table class="w-full data-table text-xs">
<thead>
<tr>
<th rowspan="2" class="w-32">품목</th>
<th rowspan="2" class="w-14">구분</th>
<th rowspan="2" class="w-12">년도</th>
<th rowspan="2" class="w-16">계</th>
<?php foreach ($months as $mo): ?>
<th class="w-14"><?= (int) $mo ?>월</th>
<?php endforeach; ?>
</tr>
</thead>
<tbody class="text-right">
<?php if ($queried && $rows === []): ?>
<tr><td colspan="<?= 4 + count($months) ?>" class="text-center text-gray-500 py-6">해당 자료가 없습니다.</td></tr>
<?php endif; ?>
<?php
$byProduct = [];
foreach ($rows as $block) {
$code = (string) ($block['bag_code'] ?? '');
if (! isset($byProduct[$code])) {
$byProduct[$code] = ['name' => (string) ($block['bag_name'] ?? ''), 'blocks' => []];
}
$byProduct[$code]['blocks'][] = $block;
}
foreach ($byProduct as $product):
$productRowspan = 0;
foreach ($product['blocks'] as $b) {
$productRowspan += count($b['lines'] ?? []);
}
$printedProduct = false;
foreach ($product['blocks'] as $block):
$sectionRows = count($block['lines'] ?? []);
$sectionPrinted = false;
foreach ($block['lines'] ?? [] as $line):
?>
<tr>
<?php if (! $printedProduct): ?>
<td class="text-left pl-2 font-medium" rowspan="<?= (int) $productRowspan ?>"><?= esc($product['name']) ?></td>
<?php $printedProduct = true; endif; ?>
<?php if (! $sectionPrinted): ?>
<td class="text-center" rowspan="<?= (int) $sectionRows ?>"><?= esc((string) ($block['section'] ?? '')) ?></td>
<?php $sectionPrinted = true; endif; ?>
<td class="text-center"><?= esc((string) ($line['label'] ?? '')) ?></td>
<td class="tabular-nums font-semibold"><?= number_format((int) ($line['total'] ?? 0)) ?></td>
<?php foreach ($months as $mo): ?>
<td class="tabular-nums <?= ($line['label'] ?? '') === '증감' && (int) (($line['months'][$mo] ?? 0)) < 0 ? 'text-red-600' : '' ?>">
<?= number_format((int) (($line['months'][$mo] ?? 0))) ?>
</td>
<?php endforeach; ?>
</tr>
<?php
endforeach;
endforeach;
endforeach;
?>
</tbody>
</table>
</div>

View File

@@ -6,6 +6,10 @@
$canManage = ! empty($canManage);
$rowCanEdit = $rowCanEdit ?? [];
$showActionsCol = $canManage;
$rowStartNo = 1;
if (isset($pager) && $pager !== null) {
$rowStartNo = (int) ($pager->getCurrentPage() - 1) * (int) $pager->getPerPage() + 1;
}
?>
<div class="space-y-3">
<?= view('components/print_header', ['printTitle' => '세부코드 - ' . esc($kind->ck_name)]) ?>
@@ -41,13 +45,13 @@ $showActionsCol = $canManage;
</tr>
</thead>
<tbody>
<?php foreach ($list as $row): ?>
<?php $rowNo = $rowStartNo; foreach ($list as $row): ?>
<?php
$isPlatform = (($row->cd_source ?? 'platform') === 'platform' && (int) ($row->cd_lg_idx ?? 0) === 0);
$scopeLabel = $isPlatform ? '공통' : '지자체';
?>
<tr>
<td class="text-center"><?= esc((string) $row->cd_idx) ?></td>
<td class="text-center"><?= (string) $rowNo ?></td>
<td class="text-center font-mono"><?= esc($row->cd_code) ?></td>
<td><?= esc($row->cd_name) ?></td>
<td class="text-center text-xs"><?= esc($scopeLabel) ?></td>
@@ -68,6 +72,7 @@ $showActionsCol = $canManage;
</td>
<?php endif; ?>
</tr>
<?php $rowNo++; ?>
<?php endforeach; ?>
</tbody>
</table>

View File

@@ -19,7 +19,7 @@ $detailColCount = 7 + ($canManageDetails ? 1 : 0);
<h3 class="text-base font-bold text-gray-700">기본코드 종류</h3>
<div class="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
<?php if ($canManageKinds): ?>
<a href="<?= base_url('admin/code-kinds/create') ?>" class="inline-flex items-center rounded bg-[#1c4e80] px-3 py-1.5 text-white shadow hover:opacity-90">코드 종류 등록</a>
<a href="<?= base_url('admin/code-kinds/create') ?>" class="inline-flex items-center rounded bg-[#1c4e80] px-3 py-1.5 text-white shadow hover:opacity-90">기본코드 등록</a>
<?php else: ?>
<span class="text-gray-500">코드 종류 등록·수정은 super admin·본부 관리자만 가능합니다.</span>
<?php endif; ?>
@@ -28,10 +28,10 @@ $detailColCount = 7 + ($canManageDetails ? 1 : 0);
<div class="border border-gray-300 overflow-auto">
<table class="data-table w-full">
<thead><tr>
<th class="w-14"><?= $showKindActions ? 'PK' : '번호' ?></th>
<th class="w-14">번호</th>
<th class="w-24">코드</th>
<th>코드명</th>
<th class="w-24">세부건수</th>
<th class="w-24">세부코드</th>
<th class="w-20">상태</th>
<th class="w-40">등록일</th>
<?php if ($showKindActions): ?>
@@ -47,7 +47,7 @@ $detailColCount = 7 + ($canManageDetails ? 1 : 0);
?>
<tr class="<?= $isSelected ? 'bg-blue-50' : '' ?> cursor-pointer hover:bg-blue-50"
onclick="window.location.href='<?= esc($detailUrl, 'attr') ?>'">
<td class="text-center"><?= $showKindActions ? esc((string) $row->ck_idx) : (string) $i ?></td>
<td class="text-center"><?= (string) $i ?></td>
<td class="text-center font-mono"><?= esc($row->ck_code) ?></td>
<td><?= esc($row->ck_name) ?></td>
<td class="text-center"><?= (int) ($countMap[$row->ck_idx] ?? 0) ?>개</td>
@@ -106,13 +106,13 @@ $detailColCount = 7 + ($canManageDetails ? 1 : 0);
</thead>
<tbody>
<?php if (! empty($detailList)): ?>
<?php foreach ($detailList as $row): ?>
<?php $dNo = 0; foreach ($detailList as $row): $dNo++; ?>
<?php
$isPlatform = (($row->cd_source ?? 'platform') === 'platform' && (int) ($row->cd_lg_idx ?? 0) === 0);
$scopeLabel = $isPlatform ? '공통' : '지자체';
?>
<tr>
<td class="text-center"><?= esc((string) $row->cd_idx) ?></td>
<td class="text-center"><?= (string) $dNo ?></td>
<td class="text-center font-mono"><?= esc($row->cd_code) ?></td>
<td><?= esc($row->cd_name) ?></td>
<td class="text-center text-xs"><?= esc($scopeLabel) ?></td>

View File

@@ -1,70 +1,482 @@
<?php
$bagMeta = is_array($bagMeta ?? null) ? $bagMeta : [];
$inventoryMap = is_array($inventoryMap ?? null) ? $inventoryMap : [];
$availableBagRows = is_array($availableBagRows ?? null) ? $availableBagRows : [];
$recentIssueRows = is_array($recentIssueRows ?? null) ? $recentIssueRows : [];
$dongCodes = is_array($dongCodes ?? null) ? $dongCodes : [];
$freeDongSet = is_array($freeDongSet ?? null) ? $freeDongSet : [];
$destTypeOptions = is_array($destTypeOptions ?? null) ? $destTypeOptions : ['동사무소', '구청', '기타'];
$defaultDestType = (string) old('bi2_dest_type', (string) ($destTypeOptions[0] ?? '구청'));
$oldItemCodes = old('item_bag_code');
$oldItemQtys = old('item_qty');
$oldItemPacks = old('item_pack');
$oldItemCodes = is_array($oldItemCodes) ? $oldItemCodes : [];
$oldItemQtys = is_array($oldItemQtys) ? $oldItemQtys : [];
$oldItemPacks = is_array($oldItemPacks) ? $oldItemPacks : [];
$initialRows = [];
$oldCount = max(count($oldItemCodes), count($oldItemQtys), count($oldItemPacks));
for ($i = 0; $i < $oldCount; $i++) {
$initialRows[] = [
'code' => trim((string) ($oldItemCodes[$i] ?? '')),
'qty' => max(0, (int) ($oldItemQtys[$i] ?? 0)),
'pack' => (string) ($oldItemPacks[$i] ?? 'sheet'),
];
}
if ($initialRows === []) {
$initialRows[] = ['code' => '', 'qty' => 0, 'pack' => 'sheet'];
}
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">무료용 불출 처리</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<form action="<?= base_url('bag/issue/store') ?>" method="POST" class="space-y-4">
<div class="border border-gray-300 p-3 mt-2 bg-white">
<form action="<?= base_url('bag/issue/store') ?>" method="POST" class="space-y-3" id="bag-issue-form">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">연도 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 text-right" name="bi2_year" type="number" min="2000" max="2099" value="<?= esc(old('bi2_year', date('Y'))) ?>" required/>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-6 gap-2 text-sm">
<div class="flex items-center gap-2 min-w-0">
<label class="font-bold text-gray-700 whitespace-nowrap shrink-0">불출년도</label>
<input class="border border-gray-300 rounded px-2 py-1 w-24 max-w-full text-right" name="bi2_year" type="number" min="2000" max="2099" value="<?= esc(old('bi2_year', date('Y'))) ?>" required/>
</div>
<div class="flex items-center gap-2 min-w-0">
<label class="font-bold text-gray-700 whitespace-nowrap shrink-0">분기</label>
<select class="border border-gray-300 rounded px-2 py-1 w-24 max-w-full" name="bi2_quarter" required>
<option value="1" <?= old('bi2_quarter', (string) ceil((int) date('n') / 3)) === '1' ? 'selected' : '' ?>>1/4</option>
<option value="2" <?= old('bi2_quarter', (string) ceil((int) date('n') / 3)) === '2' ? 'selected' : '' ?>>2/4</option>
<option value="3" <?= old('bi2_quarter', (string) ceil((int) date('n') / 3)) === '3' ? 'selected' : '' ?>>3/4</option>
<option value="4" <?= old('bi2_quarter', (string) ceil((int) date('n') / 3)) === '4' ? 'selected' : '' ?>>4/4</option>
</select>
</div>
<div class="flex items-center gap-2 min-w-0">
<label class="font-bold text-gray-700 whitespace-nowrap shrink-0">불출구분</label>
<select class="border border-gray-300 rounded px-2 py-1 w-28 max-w-full" name="bi2_issue_type" id="bi2_issue_type" required>
<option value="무료용" <?= old('bi2_issue_type', '무료용') === '무료용' ? 'selected' : '' ?>>무료용</option>
<option value="공공용" <?= old('bi2_issue_type') === '공공용' ? 'selected' : '' ?>>공공용</option>
</select>
</div>
<div class="flex items-center gap-2 min-w-0">
<label class="font-bold text-gray-700 whitespace-nowrap shrink-0">불출일</label>
<input class="border border-gray-300 rounded px-2 py-1 w-36 max-w-full" name="bi2_issue_date" type="date" value="<?= esc(old('bi2_issue_date', date('Y-m-d'))) ?>" required/>
</div>
<div class="flex items-center gap-2 min-w-0">
<label class="font-bold text-gray-700 whitespace-nowrap shrink-0">불출처구분</label>
<select class="border border-gray-300 rounded px-2 py-1 w-28 max-w-full" name="bi2_dest_type" id="bi2_dest_type">
<?php foreach ($destTypeOptions as $option): ?>
<option value="<?= esc($option) ?>" <?= $defaultDestType === $option ? 'selected' : '' ?>><?= esc($option) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2 min-w-0 md:col-span-2 xl:col-span-2">
<label class="font-bold text-gray-700 whitespace-nowrap shrink-0">불출처(동)</label>
<select class="border border-gray-300 rounded px-2 py-1 w-full min-w-[20rem]" id="bi2_dest_name" name="bi2_dest_name" required>
<option value="">선택</option>
<?php foreach ($dongCodes as $dong): ?>
<?php $dCode = (string) ($dong->cd_code ?? ''); ?>
<?php $dName = (string) ($dong->cd_name ?? $dCode); ?>
<?php $hasFree = isset($freeDongSet[$dCode]); ?>
<?php $oldDest = (string) old('bi2_dest_name'); ?>
<option
value="<?= esc($dName) ?>"
data-dong-code="<?= esc($dCode) ?>"
data-has-free="<?= $hasFree ? '1' : '0' ?>"
<?= $oldDest === $dName ? 'selected' : '' ?>
>
<?= esc($dName) ?><?= $hasFree ? ' (무료용 가능)' : ' (무료용 없음)' ?>
</option>
<?php endforeach; ?>
</select>
<input type="hidden" name="bi2_dest_dong_code" id="bi2_dest_dong_code" value="<?= esc((string) old('bi2_dest_dong_code')) ?>" />
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">분기 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32" name="bi2_quarter" required>
<option value="">선택</option>
<option value="1" <?= old('bi2_quarter') === '1' ? 'selected' : '' ?>>1</option>
<option value="2" <?= old('bi2_quarter') === '2' ? 'selected' : '' ?>>2</option>
<option value="3" <?= old('bi2_quarter') === '3' ? 'selected' : '' ?>>3</option>
<option value="4" <?= old('bi2_quarter') === '4' ? 'selected' : '' ?>>4</option>
</select>
<div class="border border-gray-300 rounded p-2 bg-gray-50 space-y-2">
<div class="flex flex-wrap items-center gap-2 text-sm">
<label class="font-bold text-gray-700">바코드 스캔</label>
<input
id="barcode_input"
type="text"
class="border border-gray-300 rounded px-2 py-1 w-72"
placeholder="스캐너로 바코드를 입력 후 Enter"
autocomplete="off"
/>
<button type="button" id="add-row-btn" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm hover:bg-white">행 추가</button>
<span class="text-xs text-gray-500">동일 바코드 연속 스캔은 무시됩니다.</span>
</div>
<div class="text-xs text-gray-600">
입고 재고가 있는 봉투/스티커만 불출 가능합니다. 저장 시 포장단위가 낱장으로 환산되어 재고가 차감됩니다.
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">구분 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bi2_issue_type" required>
<option value="">선택</option>
<option value="무료용" <?= old('bi2_issue_type') === '무료용' ? 'selected' : '' ?>>무료용</option>
<option value="공공용" <?= old('bi2_issue_type') === '공공용' ? 'selected' : '' ?>>공공용</option>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">불출일 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bi2_issue_date" type="date" value="<?= esc(old('bi2_issue_date', date('Y-m-d'))) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">불출처 유형</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bi2_dest_type" type="text" placeholder="동사무소" value="<?= esc(old('bi2_dest_type')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">불출처명 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bi2_dest_name" type="text" value="<?= esc(old('bi2_dest_name')) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">봉투코드 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bi2_bag_code" required>
<option value="">선택</option>
<?php foreach ($bagCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('bi2_bag_code') === $cd->cd_code ? 'selected' : '' ?>>
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">수량 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 text-right" name="bi2_qty" type="number" min="0" value="<?= esc(old('bi2_qty', '0')) ?>" required/>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table text-sm" id="issue-item-table">
<thead>
<tr>
<th class="w-14">No</th>
<th class="w-44">봉투코드</th>
<th>봉투종류</th>
<th class="w-28">수량</th>
<th class="w-28">포장</th>
<th class="w-32">재고(낱장)</th>
<th class="w-36">환산(낱장)</th>
<th class="w-20">작업</th>
</tr>
</thead>
<tbody id="issue-item-body"></tbody>
<tfoot>
<tr class="bg-gray-50 font-semibold">
<td colspan="6" class="text-right pr-2">합계(환산 낱장)</td>
<td class="text-right pr-2" id="sum_sheet_qty">0</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
<a href="<?= base_url('bag/issue') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">저장</button>
<a href="<?= base_url('bag/issue/cancel') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</form>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-2 mt-2">
<section class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">불출 가능 봉투(현재 재고)</div>
<div class="overflow-auto max-h-[300px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-12">No</th>
<th class="w-28">봉투코드</th>
<th>봉투명</th>
<th class="w-24">재고(낱장)</th>
<th class="w-24">팩당 낱장</th>
<th class="w-24">박스당 낱장</th>
</tr>
</thead>
<tbody>
<?php if ($availableBagRows !== []): ?>
<?php foreach ($availableBagRows as $idx => $row): ?>
<tr>
<td class="text-center"><?= $idx + 1 ?></td>
<td class="text-center"><?= esc((string) ($row['bag_code'] ?? '')) ?></td>
<td class="pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($row['inventory_qty'] ?? 0)) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($row['pack_per_sheet'] ?? 1)) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($row['total_per_box'] ?? 1)) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="6" class="text-center text-gray-400 py-4">불출 가능한 재고가 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">최근 불출 내역</div>
<div class="overflow-auto max-h-[300px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-12">No</th>
<th class="w-24">불출일</th>
<th class="w-20">구분</th>
<th class="w-28">불출처</th>
<th class="w-24">봉투코드</th>
<th>봉투명</th>
<th class="w-24">수량(낱장)</th>
<th class="w-16">상태</th>
</tr>
</thead>
<tbody>
<?php if ($recentIssueRows !== []): ?>
<?php foreach ($recentIssueRows as $idx => $row): ?>
<tr>
<td class="text-center"><?= $idx + 1 ?></td>
<td class="text-center"><?= esc((string) ($row->bi2_issue_date ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row->bi2_issue_type ?? '')) ?></td>
<td class="pl-2"><?= esc((string) ($row->bi2_dest_name ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row->bi2_bag_code ?? '')) ?></td>
<td class="pl-2"><?= esc((string) ($row->bi2_bag_name ?? '')) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($row->bi2_qty ?? 0)) ?></td>
<td class="text-center">
<?php if ((string) ($row->bi2_status ?? 'normal') === 'cancelled'): ?>
<span class="text-orange-600">취소</span>
<?php else: ?>
정상
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="8" class="text-center text-gray-400 py-4">최근 불출 내역이 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</div>
<script>
(() => {
const bagMeta = <?= json_encode($bagMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const initialRows = <?= json_encode($initialRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const knownCodes = Object.keys(bagMeta);
const body = document.getElementById('issue-item-body');
const sumSheetQtyEl = document.getElementById('sum_sheet_qty');
const barcodeInput = document.getElementById('barcode_input');
const addRowBtn = document.getElementById('add-row-btn');
const issueTypeEl = document.getElementById('bi2_issue_type');
const destTypeEl = document.getElementById('bi2_dest_type');
const destNameEl = document.getElementById('bi2_dest_name');
const destDongCodeEl = document.getElementById('bi2_dest_dong_code');
const form = document.getElementById('bag-issue-form');
let lastScannedCode = '';
const rows = [];
const createBagTypeOptions = (selectedCode) => {
const opts = ['<option value="">선택</option>'];
knownCodes.forEach((code) => {
const name = bagMeta[code]?.name || code;
opts.push(`<option value="${code}" ${code === selectedCode ? 'selected' : ''}>${code} - ${name}</option>`);
});
return opts.join('');
};
const resolveBagCode = (raw) => {
const src = String(raw || '').trim();
if (src === '') return '';
if (bagMeta[src]) return src;
const noSpace = src.replace(/\s+/g, '');
if (bagMeta[noSpace]) return noSpace;
const compact = noSpace.replace(/[^0-9A-Za-z]/g, '');
if (bagMeta[compact]) return compact;
for (const code of knownCodes) {
if (compact.includes(code)) {
return code;
}
}
return '';
};
const toSheetQty = (code, qty, pack) => {
const n = Math.max(0, parseInt(String(qty || 0), 10) || 0);
const meta = bagMeta[code] || { packPerSheet: 1, totalPerBox: 1 };
if (pack === 'box') return n * Math.max(1, parseInt(meta.totalPerBox, 10) || 1);
if (pack === 'pack') return n * Math.max(1, parseInt(meta.packPerSheet, 10) || 1);
return n;
};
const recompute = () => {
let sum = 0;
rows.forEach((row, idx) => {
const tr = row.tr;
tr.querySelector('.col-no').textContent = String(idx + 1);
const code = row.codeInput.value.trim();
const qty = row.qtyInput.value;
const pack = row.packSelect.value;
const sheetQty = toSheetQty(code, qty, pack);
const invQty = parseInt((bagMeta[code]?.inventoryQty || 0), 10) || 0;
row.invCell.textContent = new Intl.NumberFormat('ko-KR').format(invQty);
row.sheetCell.textContent = new Intl.NumberFormat('ko-KR').format(sheetQty);
if (code && sheetQty > invQty) {
row.sheetCell.classList.add('text-red-600', 'font-semibold');
} else {
row.sheetCell.classList.remove('text-red-600', 'font-semibold');
}
sum += sheetQty;
});
sumSheetQtyEl.textContent = new Intl.NumberFormat('ko-KR').format(sum);
};
const syncCode = (row, code, setAsUserSelection = false) => {
const resolved = resolveBagCode(code);
if (resolved === '') return false;
row.codeInput.value = resolved;
row.typeSelect.value = resolved;
if (setAsUserSelection || row.packSelect.value === '') {
row.packSelect.value = (bagMeta[resolved]?.totalPerBox || 1) > 1 ? 'box' : 'sheet';
}
recompute();
return true;
};
const addRow = (seed = {}) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="text-center col-no"></td>
<td class="px-1 py-1">
<input type="text" name="item_bag_code[]" class="code-input border border-gray-300 rounded px-2 py-1 w-full text-sm" value="" placeholder="봉투코드"/>
</td>
<td class="px-1 py-1">
<select name="item_bag_type[]" class="type-select border border-gray-300 rounded px-2 py-1 w-full text-sm">
${createBagTypeOptions('')}
</select>
</td>
<td class="px-1 py-1">
<input type="number" min="0" step="1" name="item_qty[]" class="qty-input border border-gray-300 rounded px-2 py-1 w-full text-sm text-right" value="0"/>
</td>
<td class="px-1 py-1">
<select name="item_pack[]" class="pack-select border border-gray-300 rounded px-2 py-1 w-full text-sm">
<option value="box">박스</option>
<option value="pack">팩</option>
<option value="sheet">낱장</option>
</select>
</td>
<td class="text-right pr-2 inv-cell">0</td>
<td class="text-right pr-2 sheet-cell">0</td>
<td class="text-center">
<button type="button" class="remove-btn text-red-600 hover:underline text-xs">삭제</button>
</td>
`;
body.appendChild(tr);
const row = {
tr,
codeInput: tr.querySelector('.code-input'),
typeSelect: tr.querySelector('.type-select'),
qtyInput: tr.querySelector('.qty-input'),
packSelect: tr.querySelector('.pack-select'),
invCell: tr.querySelector('.inv-cell'),
sheetCell: tr.querySelector('.sheet-cell'),
removeBtn: tr.querySelector('.remove-btn'),
};
rows.push(row);
row.codeInput.addEventListener('change', () => {
const ok = syncCode(row, row.codeInput.value, true);
if (!ok) {
row.codeInput.value = '';
row.typeSelect.value = '';
alert('입고 재고가 있는 봉투코드만 입력할 수 있습니다.');
}
recompute();
});
row.typeSelect.addEventListener('change', () => {
row.codeInput.value = row.typeSelect.value;
recompute();
});
row.qtyInput.addEventListener('input', recompute);
row.packSelect.addEventListener('change', recompute);
row.removeBtn.addEventListener('click', () => {
if (rows.length <= 1) return;
tr.remove();
const i = rows.indexOf(row);
if (i >= 0) rows.splice(i, 1);
recompute();
});
if (seed.code) {
syncCode(row, seed.code, true);
}
row.qtyInput.value = String(Math.max(0, parseInt(String(seed.qty || 0), 10) || 0));
if (['box', 'pack', 'sheet'].includes(String(seed.pack || ''))) {
row.packSelect.value = String(seed.pack);
}
recompute();
return row;
};
const appendScannedRow = (code) => {
const row = addRow({ code, qty: 1, pack: (bagMeta[code]?.totalPerBox || 1) > 1 ? 'box' : 'sheet' });
row.qtyInput.focus();
row.qtyInput.select();
};
const syncDestDongCode = () => {
const opt = destNameEl.options[destNameEl.selectedIndex];
if (!opt) {
destDongCodeEl.value = '';
return;
}
destDongCodeEl.value = opt.getAttribute('data-dong-code') || '';
};
const updateIssueTypeUi = () => {
const isPublic = issueTypeEl.value === '공공용';
if (isPublic) {
destTypeEl.value = '구청';
}
};
addRowBtn.addEventListener('click', () => addRow({ code: '', qty: 0, pack: 'sheet' }));
barcodeInput.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
const resolved = resolveBagCode(barcodeInput.value);
barcodeInput.value = '';
if (!resolved) {
alert('인식 가능한 봉투코드가 아닙니다.');
return;
}
if (resolved === lastScannedCode) {
return;
}
lastScannedCode = resolved;
appendScannedRow(resolved);
});
destNameEl.addEventListener('change', syncDestDongCode);
issueTypeEl.addEventListener('change', updateIssueTypeUi);
form.addEventListener('submit', (event) => {
syncDestDongCode();
updateIssueTypeUi();
const validRows = rows.filter((row) => {
const code = resolveBagCode(row.codeInput.value);
const qty = parseInt(row.qtyInput.value || '0', 10) || 0;
return code !== '' && qty > 0;
});
if (validRows.length === 0) {
event.preventDefault();
alert('불출 품목을 1건 이상 입력해 주세요.');
return;
}
for (const row of validRows) {
const code = resolveBagCode(row.codeInput.value);
const sheetQty = toSheetQty(code, row.qtyInput.value, row.packSelect.value);
const inv = parseInt((bagMeta[code]?.inventoryQty || 0), 10) || 0;
if (sheetQty > inv) {
event.preventDefault();
alert(`재고 부족: ${code} (재고 ${inv}, 요청 ${sheetQty})`);
return;
}
row.codeInput.value = code;
row.typeSelect.value = code;
}
if (issueTypeEl.value === '무료용') {
const selected = destNameEl.options[destNameEl.selectedIndex];
const hasFree = selected ? selected.getAttribute('data-has-free') === '1' : false;
if (!hasFree) {
event.preventDefault();
alert('무료용 불출은 "무료용 가능" 불출처(동)만 선택할 수 있습니다.');
}
}
});
initialRows.forEach((row) => addRow(row));
if (rows.length > 1 && !initialRows[0]?.code && !initialRows[0]?.qty) {
rows[0].tr.remove();
rows.shift();
}
syncDestDongCode();
updateIssueTypeUi();
recompute();
})();
</script>

View File

@@ -17,6 +17,7 @@ $userNav = session_user_nav_display();
<title>종량제 시스템</title>
<!-- Tailwind CSS v3 with Plugins -->
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<!-- Font: Noto Sans KR -->
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<!-- Tailwind Configuration for Custom Colors and Fonts -->

View File

@@ -0,0 +1,332 @@
<?php
/**
* 라이트(축약) 대시보드 본문 — `bag/layout/main`에 삽입.
*
* `dashboard_blend_inner` 기반으로 다음을 제거:
* - KPI 카드: 「회원승인 대기」, 「지정판매소 등록」
* - 상단 표 그리드 중: 「최근 이벤트 로그」(주간 스파크 포함)
* - 차트 중: 도넛/주간 막대/레이더/분기 스택/요일 폴라/누적 영역
* - 하단 보조 영역: 지정판매소 요약·승인 대기 표·운영 브리핑
* 남기는 그래프 3종:
* 1) 월별 출고 vs 구매신청 건수 (최근 12개월)
* 2) 품목별 재고 (천 장)
* 3) 판매소별 월 출고 TOP
*
* @var string $lgLabel
*/
$lgLabel = $lgLabel ?? '북구';
$mbName = session()->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 적용 예정 (안내문 배포 완료)',
];
?>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
.blend-dash-lite {
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
font-size: 12px;
color: #333;
}
.blend-dash-lite .dense-table th, .blend-dash-lite .dense-table td { padding: 0.25rem 0.4rem; line-height: 1.25; }
.blend-dash-lite .dense-table thead th { font-size: 11px; font-weight: 600; color: #555; background: #f3f4f6; border-bottom: 1px solid #d1d5db; }
.blend-dash-lite .dense-table tbody td { border-bottom: 1px solid #eee; font-size: 11px; }
.blend-dash-lite .chart-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.blend-dash-lite .chart-card h2 {
font-size: 11px;
font-weight: 700;
color: #1f2937;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid #f3f4f6;
background: #fafafa;
}
.blend-dash-lite .chart-wrap { position: relative; height: 220px; padding: 0.4rem 0.5rem 0.5rem; }
.blend-dash-lite .chart-wrap.tall { height: 280px; }
</style>
<div class="blend-dash-lite bg-[#f0f2f5] -mx-4 -my-4 p-2 sm:p-3 min-h-full">
<div class="bg-gradient-to-r from-[#eff5fb] to-[#e8eef8] border border-gray-300 rounded-sm px-3 py-1 flex flex-wrap items-center justify-between gap-2 text-[11px] mb-2">
<span class="font-semibold text-gray-800">
<i class="fa-solid fa-gauge-simple-high text-[#2b4c8c] mr-1"></i>업무 현황 · 라이트
<span class="font-normal text-gray-500 ml-1">· 핵심 KPI·표 + 그래프 3종</span>
</span>
<div class="flex flex-wrap items-center gap-2 text-gray-600">
<span><i class="fa-regular fa-calendar mr-0.5"></i><?= date('Y-m-d (D) H:i') ?></span>
<span class="text-gray-300">|</span>
<span><?= esc($lgLabel) ?> · <strong class="text-gray-800"><?= esc($mbName) ?></strong></span>
<button type="button" class="bg-[#2b4c8c] text-white px-2 py-0.5 rounded text-[11px]" onclick="location.reload()"><i class="fa-solid fa-rotate mr-0.5"></i>새로고침</button>
</div>
</div>
<div class="space-y-2">
<div class="mb-2 flex flex-wrap gap-2">
<?php foreach ($notices as $n): ?>
<div class="flex-1 min-w-[200px] flex items-center gap-2 bg-amber-50 border border-amber-200 text-amber-900 px-2 py-1 rounded text-[11px]">
<i class="fa-solid fa-bullhorn shrink-0"></i>
<span class="truncate" title="<?= esc($n) ?>"><?= esc($n) ?></span>
</div>
<?php endforeach; ?>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-1.5 mb-2">
<?php foreach ($kpiTop as $k): ?>
<div class="bg-white border border-gray-200 rounded px-2 py-1.5 flex items-center gap-2 shadow-sm">
<div class="w-8 h-8 rounded <?= $k['bg'] ?> <?= $k['c'] ?> flex items-center justify-center shrink-0 text-sm">
<i class="fa-solid <?= esc($k['icon'], 'attr') ?>"></i>
</div>
<div class="min-w-0">
<div class="text-base font-bold text-gray-900 leading-tight"><?= esc($k['v']) ?></div>
<div class="text-[10px] text-gray-500 leading-tight"><?= esc($k['l']) ?></div>
<div class="text-[9px] text-gray-400"><?= esc($k['sub']) ?></div>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-2 mb-2">
<section class="bg-white border border-gray-200 rounded shadow-sm overflow-hidden">
<div class="px-2 py-1 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
<h2 class="text-[11px] font-bold text-gray-800"><i class="fa-solid fa-warehouse text-[#2b4c8c] mr-1"></i>품목별 재고·소진예상</h2>
<a href="<?= base_url('bag/inventory-inquiry') ?>" class="text-[10px] text-blue-600 hover:underline">상세</a>
</div>
<div class="overflow-x-auto max-h-[220px] overflow-y-auto">
<table class="w-full dense-table text-left">
<thead>
<tr>
<th>품목</th>
<th class="text-right">재고(장)</th>
<th>상태</th>
<th class="text-right">소진</th>
</tr>
</thead>
<tbody>
<?php foreach ($stockRows as $r): ?>
<tr>
<td class="font-medium text-gray-800"><?= esc($r[0]) ?></td>
<td class="text-right tabular-nums"><?= esc($r[1]) ?></td>
<td>
<?php
$badge = match ($r[2]) {
'안전' => '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',
};
?>
<span class="text-[10px] px-1 py-0 rounded <?= $badge ?>"><?= esc($r[2]) ?></span>
</td>
<td class="text-right text-gray-600"><?= esc($r[3]) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<section class="bg-white border border-gray-200 rounded shadow-sm overflow-hidden">
<div class="px-2 py-1 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
<h2 class="text-[11px] font-bold text-gray-800"><i class="fa-solid fa-list-check text-[#2b4c8c] mr-1"></i>발주 / 구매신청 진행</h2>
<span class="text-[10px] text-gray-500">최근 5건</span>
</div>
<div class="overflow-x-auto max-h-[220px] overflow-y-auto">
<table class="w-full dense-table text-left">
<thead>
<tr>
<th>문서</th>
<th>상대</th>
<th>내용</th>
<th>단계</th>
<th class="text-right">시각</th>
</tr>
</thead>
<tbody>
<?php foreach ($orderRows as $r): ?>
<tr>
<td class="text-blue-700 font-mono text-[10px]"><?= esc($r[0]) ?></td>
<td class="truncate max-w-[6rem]" title="<?= esc($r[1]) ?>"><?= esc($r[1]) ?></td>
<td class="truncate max-w-[8rem]" title="<?= esc($r[2]) ?>"><?= esc($r[2]) ?></td>
<td><span class="text-[10px] bg-slate-100 px-1 rounded"><?= esc($r[3]) ?></span></td>
<td class="text-right text-gray-500 text-[10px] whitespace-nowrap"><?= esc($r[4]) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
</div>
<section class="chart-card mb-2">
<h2><i class="fa-solid fa-chart-line text-[#2b4c8c] mr-1"></i>월별 출고 vs 구매신청 건수 (최근 12개월)</h2>
<div class="chart-wrap tall"><canvas id="liteChLineYear"></canvas></div>
</section>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-2 mb-2">
<section class="chart-card">
<h2><i class="fa-solid fa-boxes-stacked text-[#2b4c8c] mr-1"></i>품목별 재고 (천 장)</h2>
<div class="chart-wrap"><canvas id="liteChBarSku"></canvas></div>
</section>
<section class="chart-card">
<h2><i class="fa-solid fa-store text-[#2b4c8c] mr-1"></i>판매소별 월 출고 TOP</h2>
<div class="chart-wrap"><canvas id="liteChBarHStore"></canvas></div>
</section>
</div>
<p class="text-center text-[10px] text-gray-400 pb-1">
<a href="<?= esc($dashHome) ?>" class="text-[#2b4c8c] hover:underline">메인 /dashboard</a>
· <a href="<?= esc($dashBlend) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/blend</a>
· <span class="text-gray-700 font-semibold">/dashboard/lite (현재)</span>
</p>
</div>
<script>
(function () {
const C = {
primary: '#2b4c8c',
blue: '#3b82f6',
teal: '#0d9488',
emerald: '#059669',
amber: '#d97706',
rose: '#e11d48',
grid: 'rgba(0,0,0,.06)',
};
Chart.defaults.font.family = "'Malgun Gothic','Apple SD Gothic Neo','Noto Sans KR',sans-serif";
Chart.defaults.font.size = 11;
Chart.defaults.color = '#4b5563';
const commonOpts = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 10, padding: 8, font: { size: 10 } } },
},
};
const axisOpts = {
scales: {
x: { grid: { color: C.grid }, ticks: { maxRotation: 45, minRotation: 0, font: { size: 10 } } },
y: { grid: { color: C.grid }, ticks: { font: { size: 10 } }, beginAtZero: true },
},
};
new Chart(document.getElementById('liteChLineYear'), {
type: 'line',
data: {
labels: ['3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월', '1월', '2월'],
datasets: [
{
label: '출고(천 장)',
data: [320, 340, 310, 355, 380, 360, 370, 390, 400, 385, 410, 395],
borderColor: C.primary,
backgroundColor: 'rgba(43, 76, 140, 0.08)',
fill: true,
tension: 0.35,
pointRadius: 3,
},
{
label: '구매신청(건)',
data: [118, 125, 112, 130, 142, 128, 135, 140, 155, 148, 160, 152],
borderColor: C.teal,
backgroundColor: 'transparent',
tension: 0.35,
yAxisID: 'y1',
pointRadius: 3,
},
],
},
options: {
...commonOpts,
scales: {
x: axisOpts.scales.x,
y: { type: 'linear', position: 'left', grid: { color: C.grid }, title: { display: true, text: '출고', font: { size: 10 } }, beginAtZero: true },
y1: {
type: 'linear',
position: 'right',
grid: { drawOnChartArea: false },
title: { display: true, text: '건수', font: { size: 10 } },
beginAtZero: true,
},
},
},
});
new Chart(document.getElementById('liteChBarSku'), {
type: 'bar',
data: {
labels: ['5L', '10L', '20L', '스티커', '재사용', '특수'],
datasets: [{
label: '재고',
data: [12.4, 8.2, 2.1, 15.0, 4.3, 0.9],
backgroundColor: [C.primary, C.blue, C.amber, C.teal, C.emerald, C.rose],
borderRadius: 4,
}],
},
options: {
...commonOpts,
...axisOpts,
indexAxis: 'x',
plugins: { ...commonOpts.plugins, legend: { display: false } },
},
});
new Chart(document.getElementById('liteChBarHStore'), {
type: 'bar',
data: {
labels: ['행복마트 북구', '◇◇할인점', '□□마트', '○○슈퍼', '△△상회'],
datasets: [{
label: '천 장',
data: [5.2, 4.8, 3.9, 3.5, 2.1],
backgroundColor: C.primary,
borderRadius: 4,
}],
},
options: {
...commonOpts,
indexAxis: 'y',
plugins: { ...commonOpts.plugins, legend: { display: false } },
scales: {
x: { grid: { color: C.grid }, beginAtZero: true, ticks: { font: { size: 10 } } },
y: { grid: { display: false }, ticks: { font: { size: 10 } } },
},
},
});
})();
</script>
</div>

View File

@@ -0,0 +1,333 @@
<?php
$saleDate = (string) ($saleDate ?? date('Y-m-d'));
$shops = is_array($shops ?? null) ? $shops : [];
$sales = is_array($sales ?? null) ? $sales : [];
$canCancel = (bool) ($canCancel ?? false);
$devSoldScans = is_array($devSoldScans ?? null) ? $devSoldScans : [];
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">지정판매소 판매 취소</span>
</section>
<div class="border border-gray-300 p-3 mt-2 bg-white space-y-3">
<form id="cancel-form" action="<?= base_url('bag/sale/designated-cancel/submit') ?>" method="POST" class="flex flex-wrap items-end gap-2">
<?= csrf_field() ?>
<input type="hidden" name="sale_date" value="<?= esc($saleDate) ?>"/>
<input type="hidden" name="selected_codes_json" id="selected-codes-json" value="[]"/>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">판매일자</label>
<input type="date" id="sale-date" value="<?= esc($saleDate) ?>" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-44"/>
</div>
<button type="submit" class="bg-btn-exit text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90" <?= $canCancel ? '' : 'disabled' ?>>취소</button>
<?php if (! $canCancel): ?>
<span class="text-xs text-orange-600">과거 일자는 조회만 가능합니다. (취소 불가)</span>
<?php endif; ?>
</form>
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3">
<section class="xl:col-span-2 border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">판매 리스트</div>
<div class="max-h-64 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-14">번호</th>
<th>지정판매소</th>
</tr>
</thead>
<tbody id="shop-list-body">
<?php if ($shops !== []): ?>
<?php foreach ($shops as $idx => $s): ?>
<tr class="shop-row cursor-pointer hover:bg-blue-50" data-ds-idx="<?= esc((string) ($s['bssc_ds_idx'] ?? 0), 'attr') ?>">
<td class="text-center"><?= $idx + 1 ?></td>
<td class="text-left pl-2"><?= esc((string) ($s['ds_name'] ?? ('판매소#' . (string) ($s['bssc_ds_idx'] ?? 0)))) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="2" class="text-center text-gray-400 py-6">조회된 판매 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="xl:col-span-3 border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">판매 취소 리스트</div>
<div class="max-h-64 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>품목</th>
<th class="w-20">단가</th>
<th class="w-24">판매액</th>
<th class="w-14">취소</th>
<th class="w-24">취소액</th>
</tr>
</thead>
<tbody id="item-list-body">
<tr><td colspan="5" class="text-center text-gray-400 py-6">판매소를 선택해 주세요.</td></tr>
</tbody>
<tfoot>
<tr class="font-semibold bg-gray-50">
<td colspan="2" class="text-right px-2 py-1">판매 합계</td>
<td class="text-right px-2 py-1" id="sum-sale-amount">0</td>
<td class="text-center px-2 py-1">-</td>
<td class="text-right px-2 py-1" id="sum-cancel-amount">0</td>
</tr>
<tr class="font-semibold bg-gray-50">
<td colspan="4" class="text-right px-2 py-1">총 판매액 - 총 취소액</td>
<td class="text-right px-2 py-1" id="sum-net-amount">0</td>
</tr>
</tfoot>
</table>
</div>
</section>
</div>
<section class="border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">판매 취소 봉투 코드</div>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>봉투 종류</th>
<th class="w-36">봉투 코드</th>
<th class="w-16">수량</th>
<th class="w-20">단가</th>
<th class="w-24">금액</th>
</tr>
</thead>
<tbody id="code-list-body">
<tr><td colspan="5" class="text-center text-gray-400 py-6">판매 취소 리스트에서 취소할 품목(취소 컬럼)을 선택해 주세요.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="border border-amber-400 bg-amber-50/50 p-3 mt-3 rounded-sm">
<p class="text-xs text-amber-900 mb-2 leading-relaxed">
<strong class="text-amber-950">[개발용 임시 표]</strong>
지정판매소 <strong>판매 취소</strong> 화면 테스트를 위해, 위에서 선택한 <strong>판매일자</strong>(<code class="bg-amber-100 px-1 rounded"><?= esc($saleDate) ?></code>)에
<code class="bg-amber-100 px-1 rounded">bssc_regdate</code>가 해당 일자이고 상태가 <strong>판매(sold)</strong>인 스캔 코드를 표시합니다(최대 500건).
운영에는 불필요하므로 <strong>개발이 끝나면 이 블록 전체를 제거</strong>해 주세요.
</p>
<div class="max-h-56 overflow-auto border border-amber-300 bg-white">
<table class="w-full data-table text-xs">
<thead>
<tr>
<th>지정판매소</th>
<th class="w-14">주문</th>
<th>봉투 종류</th>
<th class="w-40">봉투 바코드</th>
<th class="w-14">포장</th>
<th class="w-12">수량</th>
<th class="w-16">상태</th>
<th class="w-36">등록일시</th>
</tr>
</thead>
<tbody>
<?php if ($devSoldScans === []): ?>
<tr><td colspan="8" class="text-center text-gray-400 py-4">해당 판매일자에 판매(sold) 스캔 코드가 없습니다.</td></tr>
<?php else: ?>
<?php foreach ($devSoldScans as $r): ?>
<tr>
<td class="text-left pl-1"><?= esc(trim((string) ($r['ds_name'] ?? '')) !== '' ? (string) $r['ds_name'] : ('판매소#' . (string) ($r['bssc_ds_idx'] ?? '0'))) ?></td>
<td class="text-center"><?= esc((string) ($r['bssc_so_idx'] ?? '')) ?></td>
<td class="text-left pl-1"><?= esc((string) ($r['bssc_bag_code'] ?? '')) ?> <?= esc((string) ($r['bssc_bag_name'] ?? '')) ?></td>
<td class="text-center font-mono"><?= esc((string) ($r['bssc_code'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($r['bssc_unit'] ?? '')) ?></td>
<td class="text-right pr-1"><?= esc((string) ($r['bssc_qty'] ?? '0')) ?></td>
<td class="text-center"><?= esc((string) ($r['bssc_state'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($r['bssc_regdate'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</div>
<script>
(() => {
const saleDate = <?= json_encode($saleDate, JSON_UNESCAPED_UNICODE) ?>;
const canCancel = <?= $canCancel ? 'true' : 'false' ?>;
const sales = <?= json_encode($sales, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
const saleDateInput = document.getElementById('sale-date');
const itemListBody = document.getElementById('item-list-body');
const codeListBody = document.getElementById('code-list-body');
const selectedCodesInput = document.getElementById('selected-codes-json');
const cancelForm = document.getElementById('cancel-form');
let currentDsIdx = null;
const selectedCodeSet = new Set();
const selectedItemSet = new Set();
function rowsByShop() {
return sales.filter((r) => Number(r.ds_idx) === Number(currentDsIdx));
}
function rowsByItem() {
const rows = rowsByShop();
const map = new Map();
rows.forEach((r) => {
const key = String(r.bag_code || '');
if (!map.has(key)) {
map.set(key, {
bag_code: key,
bag_name: String(r.bag_name || ''),
unit_price: Number(r.unit_price || 0),
sale_amount: 0,
cancel_amount: 0,
});
}
const entry = map.get(key);
entry.sale_amount += Number(r.amount || 0);
if (selectedCodeSet.has(String(r.code || ''))) {
entry.cancel_amount += Number(r.amount || 0);
}
});
return Array.from(map.values());
}
function renderSums(items) {
let sumSale = 0;
let sumCancel = 0;
items.forEach((it) => {
sumSale += it.sale_amount;
sumCancel += it.cancel_amount;
});
document.getElementById('sum-sale-amount').textContent = nf(sumSale);
document.getElementById('sum-cancel-amount').textContent = nf(sumCancel);
document.getElementById('sum-net-amount').textContent = nf(sumSale - sumCancel);
}
function renderItems() {
if (!currentDsIdx) {
itemListBody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-400 py-6">판매소를 선택해 주세요.</td></tr>';
renderSums([]);
return;
}
const items = rowsByItem();
if (items.length === 0) {
itemListBody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-400 py-6">판매 품목이 없습니다.</td></tr>';
renderSums([]);
return;
}
itemListBody.innerHTML = items.map((it) => {
const key = String(it.bag_code || '');
const checked = selectedItemSet.has(key) ? 'checked' : '';
const disabled = canCancel ? '' : 'disabled';
return `
<tr>
<td class="text-left pl-2">${key} ${it.bag_name || ''}</td>
<td class="text-right pr-2">${nf(it.unit_price)}</td>
<td class="text-right pr-2">${nf(it.sale_amount)}</td>
<td class="text-center"><input type="checkbox" class="item-cancel-check" data-bag-code="${key}" ${checked} ${disabled}></td>
<td class="text-right pr-2">${nf(it.cancel_amount)}</td>
</tr>
`;
}).join('');
renderSums(items);
}
function renderCodes() {
if (!currentDsIdx || selectedItemSet.size === 0) {
codeListBody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-400 py-6">판매 취소 리스트에서 취소할 품목(취소 컬럼)을 선택해 주세요.</td></tr>';
return;
}
const rows = rowsByShop().filter((r) => selectedItemSet.has(String(r.bag_code || '')));
if (rows.length === 0) {
codeListBody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-400 py-6">봉투 코드가 없습니다.</td></tr>';
return;
}
codeListBody.innerHTML = rows.map((r) => `
<tr>
<td class="text-left pl-2">${r.bag_code || ''} ${r.bag_name || ''}</td>
<td class="text-center">${String(r.code || '')}</td>
<td class="text-right pr-2">${nf(r.qty)}</td>
<td class="text-right pr-2">${nf(r.unit_price)}</td>
<td class="text-right pr-2">${nf(r.amount)}</td>
</tr>
`).join('');
}
function syncSelectedCodesField() {
selectedCodesInput.value = JSON.stringify(Array.from(selectedCodeSet));
}
function toggleItemCodes(bagCode, checked) {
rowsByShop().forEach((r) => {
if (String(r.bag_code || '') === bagCode) {
const code = String(r.code || '');
if (!code) return;
if (checked) selectedCodeSet.add(code); else selectedCodeSet.delete(code);
}
});
}
document.getElementById('shop-list-body')?.addEventListener('click', (e) => {
const tr = e.target.closest('.shop-row');
if (!tr) return;
document.querySelectorAll('.shop-row').forEach((row) => row.classList.remove('bg-blue-100'));
tr.classList.add('bg-blue-100');
currentDsIdx = Number(tr.dataset.dsIdx || 0) || null;
selectedCodeSet.clear();
selectedItemSet.clear();
renderItems();
renderCodes();
syncSelectedCodesField();
});
itemListBody?.addEventListener('change', (e) => {
const itemCheck = e.target.closest('.item-cancel-check');
if (!itemCheck) return;
const bagCode = String(itemCheck.dataset.bagCode || '');
if (itemCheck.checked) selectedItemSet.add(bagCode); else selectedItemSet.delete(bagCode);
toggleItemCodes(bagCode, itemCheck.checked);
renderItems();
renderCodes();
syncSelectedCodesField();
});
saleDateInput?.addEventListener('change', () => {
const val = saleDateInput.value || '';
if (!val) return;
location.href = `<?= base_url('bag/sale/designated-cancel') ?>?sale_date=${encodeURIComponent(val)}`;
});
cancelForm?.addEventListener('submit', (e) => {
if (!canCancel) {
e.preventDefault();
alert('과거 판매일자는 취소 처리할 수 없습니다.');
return;
}
if (selectedCodeSet.size === 0) {
e.preventDefault();
alert('취소할 품목/봉투코드를 선택해 주세요.');
return;
}
if (!confirm('선택한 품목을 취소 처리 하시겠습니까?')) {
e.preventDefault();
return;
}
syncSelectedCodesField();
});
const firstShop = document.querySelector('.shop-row');
if (firstShop) {
firstShop.classList.add('bg-blue-100');
currentDsIdx = Number(firstShop.dataset.dsIdx || 0) || null;
renderItems();
renderCodes();
syncSelectedCodesField();
}
})();
</script>
<?= view('bag/_dev_all_sales_panel') ?>

View File

@@ -0,0 +1,260 @@
<?php
$returnDate = (string) ($returnDate ?? date('Y-m-d'));
$shops = is_array($shops ?? null) ? $shops : [];
$returns = is_array($returns ?? null) ? $returns : [];
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">지정판매소 반품 취소</span>
</section>
<div class="border border-gray-300 p-3 mt-2 bg-white space-y-3">
<form id="return-cancel-form" action="<?= base_url('bag/sale/designated-return-cancel/save') ?>" method="POST" class="flex flex-wrap items-end gap-2">
<?= csrf_field() ?>
<input type="hidden" name="return_date" id="return-date-hidden" value="<?= esc($returnDate) ?>"/>
<input type="hidden" name="selected_codes_json" id="selected-codes-json" value="[]"/>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">반품 일자</label>
<input type="date" id="return-date" value="<?= esc($returnDate) ?>" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-44"/>
</div>
<button type="button" id="btn-search" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">조회</button>
<button type="submit" class="bg-btn-exit text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">반품 취소 저장</button>
</form>
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3">
<section class="xl:col-span-2 border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">지정판매소 리스트</div>
<div class="max-h-64 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>지정판매소 상호명</th>
<th class="w-24">대표자명</th>
<th class="w-28">반품일자</th>
</tr>
</thead>
<tbody id="shop-list-body">
<?php if ($shops !== []): ?>
<?php foreach ($shops as $s): ?>
<tr class="shop-row cursor-pointer hover:bg-blue-50" data-ds-idx="<?= esc((string) ($s['brsc_ds_idx'] ?? 0), 'attr') ?>">
<td class="text-left pl-2"><?= esc((string) ($s['ds_name'] ?? ('판매소#' . (string) ($s['brsc_ds_idx'] ?? 0)))) ?></td>
<td class="text-center"><?= esc((string) ($s['ds_rep_name'] ?? '-')) ?></td>
<td class="text-center"><?= esc((string) ($s['brsc_return_date'] ?? $returnDate)) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="3" class="text-center text-gray-400 py-6">조회된 반품 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="xl:col-span-3 border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">반품 리스트</div>
<div class="max-h-64 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>품목</th>
<th class="w-20">단가</th>
<th class="w-24">판매액</th>
<th class="w-14">취소</th>
<th class="w-24">취소액</th>
</tr>
</thead>
<tbody id="item-list-body">
<tr><td colspan="5" class="text-center text-gray-400 py-6">지정판매소를 선택해 주세요.</td></tr>
</tbody>
<tfoot>
<tr class="font-semibold bg-gray-50">
<td colspan="2" class="text-right px-2 py-1">판매 합계</td>
<td class="text-right px-2 py-1" id="sum-sale-amount">0</td>
<td class="text-center px-2 py-1">-</td>
<td class="text-right px-2 py-1" id="sum-cancel-amount">0</td>
</tr>
</tfoot>
</table>
</div>
</section>
</div>
<section class="border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">반품 취소 대상 봉투 코드</div>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>봉투 종류</th>
<th class="w-36">봉투코드</th>
<th class="w-16">수량</th>
<th class="w-20">단가</th>
<th class="w-24">금액</th>
</tr>
</thead>
<tbody id="code-list-body">
<tr><td colspan="5" class="text-center text-gray-400 py-6">반품 리스트에서 취소할 품목(취소 컬럼)을 선택해 주세요.</td></tr>
</tbody>
</table>
</div>
</section>
</div>
<script>
(() => {
const rows = <?= json_encode($returns, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
const shopListBody = document.getElementById('shop-list-body');
const itemListBody = document.getElementById('item-list-body');
const codeListBody = document.getElementById('code-list-body');
const selectedCodesInput = document.getElementById('selected-codes-json');
const returnDateInput = document.getElementById('return-date');
const returnDateHidden = document.getElementById('return-date-hidden');
const selectedCodeSet = new Set();
const selectedItemSet = new Set();
let currentDsIdx = null;
function rowsByShop() {
return rows.filter((r) => Number(r.ds_idx) === Number(currentDsIdx));
}
function rowsByItem() {
const map = new Map();
rowsByShop().forEach((r) => {
const key = String(r.bag_code || '');
if (!map.has(key)) {
map.set(key, { bag_code: key, bag_name: String(r.bag_name || ''), unit_price: Number(r.unit_price || 0), sale_amount: 0, cancel_amount: 0 });
}
const item = map.get(key);
item.sale_amount += Number(r.amount || 0);
if (selectedCodeSet.has(String(r.code || ''))) item.cancel_amount += Number(r.amount || 0);
});
return Array.from(map.values());
}
function syncHidden() {
selectedCodesInput.value = JSON.stringify(Array.from(selectedCodeSet));
returnDateHidden.value = returnDateInput.value || '';
}
function renderItems() {
if (!currentDsIdx) {
itemListBody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-400 py-6">지정판매소를 선택해 주세요.</td></tr>';
document.getElementById('sum-sale-amount').textContent = '0';
document.getElementById('sum-cancel-amount').textContent = '0';
return;
}
const items = rowsByItem();
if (items.length === 0) {
itemListBody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-400 py-6">반품 품목이 없습니다.</td></tr>';
document.getElementById('sum-sale-amount').textContent = '0';
document.getElementById('sum-cancel-amount').textContent = '0';
return;
}
itemListBody.innerHTML = items.map((it) => {
const key = String(it.bag_code || '');
return `
<tr>
<td class="text-left pl-2">${key} ${it.bag_name || ''}</td>
<td class="text-right pr-2">${nf(it.unit_price)}</td>
<td class="text-right pr-2">${nf(it.sale_amount)}</td>
<td class="text-center"><input type="checkbox" class="item-cancel-check" data-bag-code="${key}" ${selectedItemSet.has(key) ? 'checked' : ''}></td>
<td class="text-right pr-2">${nf(it.cancel_amount)}</td>
</tr>
`;
}).join('');
const sumSale = items.reduce((s, it) => s + Number(it.sale_amount || 0), 0);
const sumCancel = items.reduce((s, it) => s + Number(it.cancel_amount || 0), 0);
document.getElementById('sum-sale-amount').textContent = nf(sumSale);
document.getElementById('sum-cancel-amount').textContent = nf(sumCancel);
}
function renderCodes() {
if (!currentDsIdx || selectedItemSet.size === 0) {
codeListBody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-400 py-6">반품 리스트에서 취소할 품목(취소 컬럼)을 선택해 주세요.</td></tr>';
return;
}
const targetRows = rowsByShop().filter((r) => selectedItemSet.has(String(r.bag_code || '')));
if (targetRows.length === 0) {
codeListBody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-400 py-6">반품 코드가 없습니다.</td></tr>';
return;
}
codeListBody.innerHTML = targetRows.map((r) => `
<tr>
<td class="text-left pl-2">${r.bag_code || ''} ${r.bag_name || ''}</td>
<td class="text-center">${String(r.code || '')}</td>
<td class="text-right pr-2">${nf(r.qty)}</td>
<td class="text-right pr-2">${nf(r.unit_price)}</td>
<td class="text-right pr-2">${nf(r.amount)}</td>
</tr>
`).join('');
}
function toggleItemCodes(bagCode, checked) {
rowsByShop().forEach((r) => {
if (String(r.bag_code || '') !== bagCode) return;
const code = String(r.code || '');
if (!code) return;
if (checked) selectedCodeSet.add(code); else selectedCodeSet.delete(code);
});
}
document.getElementById('btn-search')?.addEventListener('click', () => {
const val = returnDateInput.value || '';
if (!val) return;
location.href = `<?= base_url('bag/sale/designated-return-cancel') ?>?return_date=${encodeURIComponent(val)}`;
});
shopListBody?.addEventListener('click', (e) => {
const tr = e.target.closest('.shop-row');
if (!tr) return;
document.querySelectorAll('.shop-row').forEach((row) => row.classList.remove('bg-blue-100'));
tr.classList.add('bg-blue-100');
currentDsIdx = Number(tr.dataset.dsIdx || 0) || null;
selectedCodeSet.clear();
selectedItemSet.clear();
renderItems();
renderCodes();
syncHidden();
});
itemListBody?.addEventListener('change', (e) => {
const itemCheck = e.target.closest('.item-cancel-check');
if (!itemCheck) return;
const bagCode = String(itemCheck.dataset.bagCode || '');
if (itemCheck.checked) selectedItemSet.add(bagCode); else selectedItemSet.delete(bagCode);
toggleItemCodes(bagCode, itemCheck.checked);
renderItems();
renderCodes();
syncHidden();
});
document.getElementById('return-cancel-form')?.addEventListener('submit', (e) => {
syncHidden();
if ((returnDateHidden.value || '') === '') {
e.preventDefault();
alert('반품 일자를 선택해 주세요.');
return;
}
if (selectedCodeSet.size === 0) {
e.preventDefault();
alert('반품 취소할 품목/봉투코드를 선택해 주세요.');
return;
}
if (!confirm('선택한 반품 품목을 취소 처리 하시겠습니까?')) {
e.preventDefault();
}
});
const firstShop = document.querySelector('.shop-row');
if (firstShop) {
firstShop.classList.add('bg-blue-100');
currentDsIdx = Number(firstShop.dataset.dsIdx || 0) || null;
renderItems();
renderCodes();
syncHidden();
}
})();
</script>
<?= view('bag/_dev_all_sales_panel') ?>

View File

@@ -0,0 +1,520 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">지정판매소 판매</span>
</section>
<div class="border border-gray-300 p-3 mt-2 bg-white space-y-3">
<form id="sale-save-form" action="<?= base_url('bag/sale/designated/save') ?>" method="POST">
<?= csrf_field() ?>
<input type="hidden" name="so_idx" id="save-so-idx" value=""/>
<input type="hidden" name="ds_idx" id="save-ds-idx" value=""/>
<input type="hidden" name="scans_json" id="save-scans-json" value="[]"/>
<div class="flex flex-wrap items-end gap-2">
<div class="relative">
<label class="block text-xs font-bold text-gray-700 mb-1">판매소 검색</label>
<input id="shop-search" type="text" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-[36rem] max-w-full" placeholder="코드/상호/대표자/전화/주소"/>
<datalist id="shop-search-list" class="hidden">
<?php foreach (($shops ?? []) as $shop): ?>
<option value="<?= esc(trim(($shop->ds_shop_no ?? '') . ' ' . ($shop->ds_name ?? '') . ' ' . ($shop->ds_rep_name ?? '') . ' ' . ($shop->ds_tel ?? '') . ' ' . ($shop->ds_addr ?? ''))) ?>"></option>
<?php endforeach; ?>
</datalist>
<div id="shop-search-suggest" class="hidden absolute left-0 top-full mt-1 w-[36rem] max-w-full max-h-56 overflow-auto border border-gray-300 bg-white shadow-lg z-20"></div>
</div>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">봉투코드 입력(스캔)</label>
<input id="barcode-input" type="text" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-80" placeholder="박스/팩/낱장 바코드"/>
</div>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">판매일</label>
<div class="border border-gray-300 bg-gray-100 rounded px-2 py-1.5 text-sm w-36"><?= esc(date('Y-m-d')) ?></div>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">판매 저장</button>
</div>
</form>
<div id="scan-message" class="text-sm text-gray-600"></div>
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3">
<section class="xl:col-span-2 border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">지정판매소 정보</div>
<table class="w-full text-sm">
<tr><th class="text-left px-3 py-1.5 w-28">판매소 코드</th><td id="shop-info-code" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">상호</th><td id="shop-info-name" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">대표자명</th><td id="shop-info-rep" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">대표전화</th><td id="shop-info-tel" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">주소</th><td id="shop-info-addr" class="px-3 py-1.5">-</td></tr>
</table>
</section>
<section class="xl:col-span-3 border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">주문 접수 리스트</div>
<div class="max-h-56 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-14">번호</th>
<th>판매소</th>
<th class="w-28">접수일</th>
<th class="w-28">배달일</th>
<th class="w-16">상태</th>
</tr>
</thead>
<tbody id="order-list-body"></tbody>
</table>
</div>
</section>
</div>
<section id="dev-saleable-panel" class="hidden border border-amber-400 bg-amber-50/50 p-3 rounded-sm">
<p class="text-xs text-amber-900 mb-2 leading-relaxed">
<strong class="text-amber-950">[개발용 임시]</strong>
<strong>주문 접수 리스트</strong>에서 주문을 선택하면, 그 주문의 지정판매소(<code class="bg-amber-100 px-1 rounded">so_ds_idx</code>) 기준으로
판매 테스트에 쓸 수 있는 바코드 후보를 표시합니다.
(① 해당 판매소에 연결된 <code class="bg-amber-100 px-1 rounded">bag_sale_scan_code</code> 중 <code class="bg-amber-100 px-1 rounded">in_stock</code>,
② 같은 판매소의 <strong>전화·정상 주문</strong> 품목 봉투코드(수령완료 포함, 주문 리스트와 동일 범위) 및 <strong>선택한 주문</strong> 품목에 맞는 <code class="bg-amber-100 px-1 rounded">bag_receiving_pack_code</code> <code class="bg-amber-100 px-1 rounded">in_stock</code> 팩 코드.
입고 행의 <strong>수량</strong>은 팩에 담긴 <code class="bg-amber-100 px-1 rounded">brpc_sheet_qty</code>(낱장 수)입니다.)
<strong>개발 완료 후 이 블록과 API 라우트를 제거</strong>해 주세요.
</p>
<div class="max-h-52 overflow-auto border border-amber-300 bg-white">
<table class="w-full data-table text-xs">
<thead>
<tr>
<th class="w-28">출처</th>
<th class="w-40">바코드(대표)</th>
<th>봉투 종류</th>
<th class="w-16">포장</th>
<th class="w-10">수량</th>
<th class="w-12">주문</th>
<th class="w-14">상태</th>
<th>비고(낱장범위 등)</th>
</tr>
</thead>
<tbody id="dev-saleable-tbody">
<tr><td colspan="8" class="text-center text-gray-400 py-4">주문을 선택하면 목록이 표시됩니다.</td></tr>
</tbody>
</table>
</div>
</section>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-3">
<section class="border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">판매 내역</div>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-14">선택</th>
<th>봉투 종류</th>
<th class="w-20">접수량</th>
<th class="w-20">판매량</th>
<th class="w-20">단가</th>
<th class="w-24">판매금액</th>
</tr>
</thead>
<tbody id="sale-items-body">
<tr><td colspan="6" class="text-center text-gray-400 py-6">주문을 선택해 주세요.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">판매 상세 내역</div>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>봉투 종류</th>
<th class="w-28">봉투 코드</th>
<th class="w-16">수량</th>
<th class="w-16">포장단위</th>
</tr>
</thead>
<tbody id="scan-detail-body">
<tr><td colspan="4" class="text-center text-gray-400 py-6">바코드를 스캔해 주세요.</td></tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
<script>
(() => {
const shops = <?= json_encode(array_map(static function ($s): array {
return [
'ds_idx' => (int) ($s->ds_idx ?? 0),
'ds_shop_no' => (string) ($s->ds_shop_no ?? ''),
'ds_name' => (string) ($s->ds_name ?? ''),
'ds_rep_name' => (string) ($s->ds_rep_name ?? ''),
'ds_tel' => (string) ($s->ds_tel ?? ''),
'ds_addr' => trim((string) ($s->ds_addr ?? '') . ' ' . (string) ($s->ds_addr_detail ?? '')),
];
}, $shops ?? []), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const orders = <?= json_encode($orders ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const scanApi = '<?= base_url('bag/sale/designated/scan') ?>';
const devSaleableApi = '<?= base_url('bag/sale/designated/dev-saleable-barcodes') ?>';
const csrfName = '<?= csrf_token() ?>';
const csrfHash = '<?= csrf_hash() ?>';
const shopSearch = document.getElementById('shop-search');
const shopSuggest = document.getElementById('shop-search-suggest');
const barcodeInput = document.getElementById('barcode-input');
const orderListBody = document.getElementById('order-list-body');
const saleItemsBody = document.getElementById('sale-items-body');
const scanDetailBody = document.getElementById('scan-detail-body');
const saveForm = document.getElementById('sale-save-form');
const saveSoIdx = document.getElementById('save-so-idx');
const saveDsIdx = document.getElementById('save-ds-idx');
const saveScansJson = document.getElementById('save-scans-json');
const scanMessage = document.getElementById('scan-message');
const devSaleablePanel = document.getElementById('dev-saleable-panel');
const devSaleableTbody = document.getElementById('dev-saleable-tbody');
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
let selectedShop = null;
let selectedOrder = null;
let selectedBagCode = '';
const pendingScans = [];
const shopMap = new Map(shops.map((s) => [String(s.ds_idx), s]));
function setMessage(msg, isError = false) {
scanMessage.textContent = msg || '';
scanMessage.className = isError ? 'text-sm text-red-600' : 'text-sm text-emerald-700';
}
function mergedShopText(shop) {
return [shop.ds_shop_no, shop.ds_name, shop.ds_rep_name, shop.ds_tel, shop.ds_addr]
.filter(Boolean)
.join(' ');
}
function hideSuggest() {
if (shopSuggest) {
shopSuggest.classList.add('hidden');
shopSuggest.innerHTML = '';
}
}
function renderSuggest(query) {
if (!shopSuggest) return;
const q = String(query || '').trim().toLowerCase();
const matched = (q
? shops.filter((s) => mergedShopText(s).toLowerCase().includes(q))
: shops
).slice(0, 30);
if (matched.length === 0) {
hideSuggest();
return;
}
shopSuggest.innerHTML = matched.map((s) => `
<button type="button" class="shop-suggest-item w-full text-left px-2 py-1.5 hover:bg-blue-50 text-xs border-b border-gray-100 whitespace-normal break-all" data-ds-idx="${s.ds_idx}">
${mergedShopText(s)}
</button>
`).join('');
shopSuggest.classList.remove('hidden');
}
function hideDevSaleablePanel() {
if (devSaleablePanel) devSaleablePanel.classList.add('hidden');
if (devSaleableTbody) {
devSaleableTbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-4">주문을 선택하면 목록이 표시됩니다.</td></tr>';
}
}
async function loadDevSaleableBarcodes(dsIdx) {
if (!devSaleablePanel || !devSaleableTbody) return;
const idx = Number(dsIdx || 0);
if (!idx) {
hideDevSaleablePanel();
return;
}
devSaleablePanel.classList.remove('hidden');
devSaleableTbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-4">불러오는 중…</td></tr>';
try {
let devUrl = `${devSaleableApi}?ds_idx=${encodeURIComponent(String(idx))}`;
if (selectedOrder && Number(selectedOrder.so_idx || 0) > 0) {
devUrl += `&so_idx=${encodeURIComponent(String(selectedOrder.so_idx))}`;
}
const res = await fetch(devUrl, { credentials: 'same-origin' });
const data = await res.json();
if (!data.ok) {
devSaleableTbody.innerHTML = `<tr><td colspan="8" class="text-center text-red-600 py-4">${data.message || '조회 실패'}</td></tr>`;
return;
}
const rows = Array.isArray(data.rows) ? data.rows : [];
if (rows.length === 0) {
devSaleableTbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-4">표시할 바코드가 없습니다.</td></tr>';
return;
}
devSaleableTbody.innerHTML = rows.map((r) => `
<tr>
<td class="text-left pl-1">${r.source || ''}</td>
<td class="text-center font-mono">${r.code || ''}</td>
<td class="text-left pl-1">${r.bag_code || ''} ${r.bag_name || ''}</td>
<td class="text-center">${r.unit || ''}</td>
<td class="text-right pr-1">${nf(r.qty || 0)}</td>
<td class="text-center">${r.so_idx ? String(r.so_idx) : '-'}</td>
<td class="text-center">${r.state || ''}</td>
<td class="text-left pl-1">${r.extra || ''}</td>
</tr>
`).join('');
} catch {
devSaleableTbody.innerHTML = '<tr><td colspan="8" class="text-center text-red-600 py-4">네트워크 오류</td></tr>';
}
}
function updateShopInfo(shop) {
document.getElementById('shop-info-code').textContent = shop?.ds_shop_no || '-';
document.getElementById('shop-info-name').textContent = shop?.ds_name || '-';
document.getElementById('shop-info-rep').textContent = shop?.ds_rep_name || '-';
document.getElementById('shop-info-tel').textContent = shop?.ds_tel || '-';
document.getElementById('shop-info-addr').textContent = shop?.ds_addr || '-';
}
function renderOrderList() {
const rows = (selectedShop ? orders.filter((o) => Number(o.so_ds_idx) === Number(selectedShop.ds_idx)) : orders);
if (rows.length === 0) {
orderListBody.innerHTML = '<tr><td colspan="5" class="text-center py-6 text-gray-400">조건에 맞는 주문이 없습니다.</td></tr>';
return;
}
orderListBody.innerHTML = rows.map((o) => `
<tr class="order-row cursor-pointer hover:bg-blue-50 ${selectedOrder && Number(selectedOrder.so_idx) === Number(o.so_idx) ? 'bg-blue-100' : ''}" data-order-id="${o.so_idx}">
<td class="text-center">${o.so_idx}</td>
<td class="text-left pl-2">${o.so_ds_name || ''}</td>
<td class="text-center">${o.so_order_date || ''}</td>
<td class="text-center">${o.so_delivery_date || ''}</td>
<td class="text-center">${o.so_status === 'cancelled' ? '주문 취소' : (Number(o.so_received || 0) === 1 ? '판매 완료' : '판매 진행')}</td>
</tr>
`).join('');
}
function pendingQtyForBag(code) {
return pendingScans.filter((s) => s.bag_code === code).reduce((sum, s) => sum + (Number(s.qty) || 0), 0);
}
function renderSaleItems() {
if (!selectedOrder) {
saleItemsBody.innerHTML = '<tr><td colspan="6" class="text-center text-gray-400 py-6">주문을 선택해 주세요.</td></tr>';
return;
}
const items = Array.isArray(selectedOrder.items) ? selectedOrder.items : [];
if (items.length === 0) {
saleItemsBody.innerHTML = '<tr><td colspan="6" class="text-center text-gray-400 py-6">품목 정보가 없습니다.</td></tr>';
return;
}
saleItemsBody.innerHTML = items.map((it) => {
const bagCode = String(it.soi_bag_code || '');
const sold = Number(it.sold_qty || 0) + pendingQtyForBag(bagCode);
const unitPrice = Number(it.soi_unit_price || 0);
const amount = sold * unitPrice;
const checked = selectedBagCode === bagCode ? 'checked' : '';
return `
<tr>
<td class="text-center"><input type="radio" name="pick-bag" value="${bagCode}" ${checked}></td>
<td class="text-left pl-2">${bagCode} ${it.soi_bag_name || ''}</td>
<td class="text-right pr-2">${nf(it.soi_qty || 0)}</td>
<td class="text-right pr-2">${nf(sold)}</td>
<td class="text-right pr-2">${nf(unitPrice)}</td>
<td class="text-right pr-2">${nf(amount)}</td>
</tr>
`;
}).join('');
}
function renderScanDetails() {
const rows = pendingScans.filter((s) => !selectedBagCode || s.bag_code === selectedBagCode);
if (rows.length === 0) {
scanDetailBody.innerHTML = '<tr><td colspan="4" class="text-center text-gray-400 py-6">바코드를 스캔해 주세요.</td></tr>';
return;
}
scanDetailBody.innerHTML = rows.map((r) => `
<tr>
<td class="text-left pl-2">${r.bag_code} ${r.bag_name || ''}</td>
<td class="text-center">${r.barcode}</td>
<td class="text-right pr-2">${nf(r.qty)}</td>
<td class="text-center">${r.unit}</td>
</tr>
`).join('');
}
function selectOrder(orderId) {
selectedOrder = orders.find((o) => Number(o.so_idx) === Number(orderId)) || null;
selectedBagCode = '';
pendingScans.length = 0;
saveSoIdx.value = selectedOrder ? String(selectedOrder.so_idx) : '';
saveDsIdx.value = selectedOrder ? String(selectedOrder.so_ds_idx || '') : (selectedShop ? String(selectedShop.ds_idx) : '');
saveScansJson.value = '[]';
// 주문이 선택되면 그 주문의 지정판매소를 「지정판매소 정보」 표·검색 input 에도 자동 반영한다.
if (selectedOrder) {
const matchedShop = shopMap.get(String(selectedOrder.so_ds_idx)) || null;
if (matchedShop) {
selectedShop = matchedShop;
if (shopSearch) shopSearch.value = mergedShopText(matchedShop);
updateShopInfo(matchedShop);
}
}
renderOrderList();
renderSaleItems();
renderScanDetails();
if (selectedOrder && Number(selectedOrder.so_ds_idx || 0) > 0) {
loadDevSaleableBarcodes(selectedOrder.so_ds_idx);
} else {
hideDevSaleablePanel();
}
}
async function submitScan() {
const code = (barcodeInput.value || '').trim();
if (!code) return;
if (!selectedOrder) {
setMessage('주문 접수 리스트에서 주문을 먼저 선택해 주세요.', true);
return;
}
const payload = new URLSearchParams();
payload.set(csrfName, csrfHash);
payload.set('so_idx', String(selectedOrder.so_idx));
payload.set('barcode', code);
const pendingByBag = {};
pendingScans.forEach((s) => {
const k = String(s.bag_code || '');
pendingByBag[k] = (pendingByBag[k] || 0) + (Number(s.qty || 0));
});
payload.set('pending_by_bag', JSON.stringify(pendingByBag));
const res = await fetch(scanApi, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: payload.toString(),
});
const data = await res.json();
if (!data.ok) {
setMessage(data.message || '스캔 처리 실패', true);
return;
}
if (!selectedBagCode) {
selectedBagCode = String(data.bag_code || '');
}
pendingScans.push({
barcode: code,
bag_code: data.bag_code,
bag_name: data.bag_name,
qty: Number(data.qty || 0),
unit: data.unit,
pack_ids: Array.isArray(data.pack_ids) ? data.pack_ids : [],
});
saveScansJson.value = JSON.stringify(pendingScans);
barcodeInput.value = '';
setMessage(`등록 완료: ${data.bag_code} / ${data.unit} / 수량 ${nf(data.qty)}`);
renderSaleItems();
renderScanDetails();
}
shopSearch?.addEventListener('change', (e) => {
const q = String(e.target.value || '').trim().toLowerCase();
selectedShop = shops.find((s) => {
const merged = [s.ds_shop_no, s.ds_name, s.ds_rep_name, s.ds_tel, s.ds_addr].join(' ').toLowerCase();
return merged.includes(q);
}) || null;
selectedOrder = null;
selectedBagCode = '';
pendingScans.length = 0;
saveSoIdx.value = '';
saveDsIdx.value = selectedShop ? String(selectedShop.ds_idx) : '';
saveScansJson.value = '[]';
updateShopInfo(selectedShop);
hideDevSaleablePanel();
renderOrderList();
renderSaleItems();
renderScanDetails();
setMessage('');
hideSuggest();
});
shopSearch?.addEventListener('input', (e) => {
renderSuggest(e.target.value || '');
});
shopSearch?.addEventListener('focus', (e) => {
renderSuggest(e.target.value || '');
});
shopSearch?.addEventListener('click', (e) => {
renderSuggest(e.target.value || '');
});
shopSuggest?.addEventListener('click', (e) => {
const btn = e.target.closest('.shop-suggest-item');
if (!btn) return;
const dsIdx = Number(btn.dataset.dsIdx || 0);
const shop = shops.find((s) => Number(s.ds_idx) === dsIdx) || null;
if (!shop) return;
shopSearch.value = mergedShopText(shop);
selectedShop = shop;
selectedOrder = null;
selectedBagCode = '';
pendingScans.length = 0;
saveSoIdx.value = '';
saveDsIdx.value = selectedShop ? String(selectedShop.ds_idx) : '';
saveScansJson.value = '[]';
updateShopInfo(selectedShop);
hideDevSaleablePanel();
renderOrderList();
renderSaleItems();
renderScanDetails();
setMessage('');
hideSuggest();
});
document.addEventListener('click', (e) => {
if (!shopSuggest || !shopSearch) return;
if (e.target === shopSearch || shopSuggest.contains(e.target)) return;
hideSuggest();
});
barcodeInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitScan();
}
});
orderListBody?.addEventListener('click', (e) => {
const tr = e.target.closest('.order-row');
if (!tr) return;
selectOrder(tr.dataset.orderId);
});
saleItemsBody?.addEventListener('change', (e) => {
const radio = e.target.closest('input[name="pick-bag"]');
if (!radio) return;
selectedBagCode = radio.value || '';
renderScanDetails();
});
saveForm?.addEventListener('submit', (e) => {
if (!selectedOrder) {
e.preventDefault();
alert('주문을 먼저 선택해 주세요.');
return;
}
if (pendingScans.length === 0) {
e.preventDefault();
alert('없는 바코드이거나 유효한 스캔 내역이 없습니다.');
return;
}
});
renderOrderList();
renderSaleItems();
renderScanDetails();
})();
</script>
<?= view('bag/_dev_all_sales_panel') ?>

View File

@@ -0,0 +1,424 @@
<?php
$devSoldScans = is_array($devSoldScans ?? null) ? $devSoldScans : [];
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">지정판매소 반품</span>
</section>
<div class="border border-gray-300 p-3 mt-2 bg-white space-y-3">
<form id="return-save-form" action="<?= base_url('bag/sale/designated-return/save') ?>" method="POST">
<?= csrf_field() ?>
<input type="hidden" name="ds_idx" id="save-ds-idx" value=""/>
<input type="hidden" name="scans_json" id="save-scans-json" value="[]"/>
<div class="flex flex-wrap items-end gap-2">
<div class="relative">
<label class="block text-xs font-bold text-gray-700 mb-1">판매소 검색</label>
<input id="shop-search" type="text" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-[36rem] max-w-full" placeholder="코드/상호/대표자/전화/주소"/>
<div id="shop-search-suggest" class="hidden absolute left-0 top-full mt-1 w-[36rem] max-w-full max-h-56 overflow-auto border border-gray-300 bg-white shadow-lg z-20"></div>
</div>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">봉투코드 입력</label>
<input id="barcode-input" type="text" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-80" placeholder="박스/팩/낱장 바코드"/>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">반품 저장</button>
</div>
</form>
<div id="scan-message" class="text-sm text-gray-600"></div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-3">
<section class="border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">지정판매소 정보</div>
<table class="w-full text-sm">
<tr><th class="text-left px-3 py-1.5 w-28">판매소 코드</th><td id="shop-info-code" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">상호</th><td id="shop-info-name" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">대표자명</th><td id="shop-info-rep" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">대표전화</th><td id="shop-info-tel" class="px-3 py-1.5">-</td></tr>
<tr><th class="text-left px-3 py-1.5">주소</th><td id="shop-info-addr" class="px-3 py-1.5">-</td></tr>
</table>
</section>
<section class="border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">반품 리스트</div>
<div class="max-h-64 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-14">선택</th>
<th>봉투 종류</th>
<th class="w-20">수량</th>
<th class="w-20">단가</th>
<th class="w-24">금액</th>
<th class="w-16">제거</th>
</tr>
</thead>
<tbody id="return-list-body">
<tr><td colspan="6" class="text-center text-gray-400 py-6">판매소를 선택하고 바코드를 스캔해 주세요.</td></tr>
</tbody>
<tfoot>
<tr class="font-semibold bg-gray-50">
<td colspan="2" class="text-right px-2 py-1">합계</td>
<td class="text-right px-2 py-1" id="sum-qty">0</td>
<td class="text-right px-2 py-1">-</td>
<td class="text-right px-2 py-1" id="sum-amount">0</td>
<td class="px-2 py-1"></td>
</tr>
</tfoot>
</table>
</div>
</section>
</div>
<section class="border border-gray-300">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">반품 봉투 코드</div>
<div class="max-h-72 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>봉투 종류</th>
<th class="w-36">봉투 코드</th>
<th class="w-16">수량</th>
<th class="w-20">금액</th>
</tr>
</thead>
<tbody id="return-code-body">
<tr><td colspan="4" class="text-center text-gray-400 py-6">품목을 선택해 주세요.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="border border-amber-400 bg-amber-50/50 p-3 mt-3 rounded-sm">
<p class="text-xs text-amber-900 mb-2 leading-relaxed">
<strong class="text-amber-950">[개발용 임시 표]</strong>
반품 스캔 테스트를 위해, 현재 지자체에서 <code class="bg-amber-100 px-1 rounded">bag_sale_scan_code</code> 상태가
<strong>판매(sold)</strong>인 봉투 바코드 일부(최대 200건, 최근 등록순)를 표시합니다.
운영 화면에는 포함되지 않아야 하므로 <strong>개발이 끝나면 이 블록 전체를 제거</strong>해 주세요.
</p>
<div class="max-h-56 overflow-auto border border-amber-300 bg-white">
<table class="w-full data-table text-xs">
<thead>
<tr>
<th>지정판매소</th>
<th class="w-14">주문</th>
<th>봉투 종류</th>
<th class="w-40">봉투 바코드</th>
<th class="w-14">포장</th>
<th class="w-12">수량</th>
<th class="w-16">상태</th>
<th class="w-36">등록일시</th>
</tr>
</thead>
<tbody>
<?php if ($devSoldScans === []): ?>
<tr><td colspan="8" class="text-center text-gray-400 py-4">판매(sold) 상태인 스캔 코드가 없습니다.</td></tr>
<?php else: ?>
<?php foreach ($devSoldScans as $r): ?>
<tr>
<td class="text-left pl-1"><?= esc(trim((string) ($r['ds_name'] ?? '')) !== '' ? (string) $r['ds_name'] : ('판매소#' . (string) ($r['bssc_ds_idx'] ?? '0'))) ?></td>
<td class="text-center"><?= esc((string) ($r['bssc_so_idx'] ?? '')) ?></td>
<td class="text-left pl-1"><?= esc((string) ($r['bssc_bag_code'] ?? '')) ?> <?= esc((string) ($r['bssc_bag_name'] ?? '')) ?></td>
<td class="text-center font-mono"><?= esc((string) ($r['bssc_code'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($r['bssc_unit'] ?? '')) ?></td>
<td class="text-right pr-1"><?= esc((string) ($r['bssc_qty'] ?? '0')) ?></td>
<td class="text-center"><?= esc((string) ($r['bssc_state'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($r['bssc_regdate'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</div>
<script>
(() => {
const shops = <?= json_encode(array_map(static function ($s): array {
return [
'ds_idx' => (int) ($s->ds_idx ?? 0),
'ds_shop_no' => (string) ($s->ds_shop_no ?? ''),
'ds_name' => (string) ($s->ds_name ?? ''),
'ds_rep_name' => (string) ($s->ds_rep_name ?? ''),
'ds_tel' => (string) ($s->ds_tel ?? ''),
'ds_addr' => trim((string) ($s->ds_addr ?? '') . ' ' . (string) ($s->ds_addr_detail ?? '')),
];
}, $shops ?? []), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const scanApi = '<?= base_url('bag/sale/designated-return/scan') ?>';
const csrfName = '<?= csrf_token() ?>';
const csrfHash = '<?= csrf_hash() ?>';
const shopSearch = document.getElementById('shop-search');
const shopSuggest = document.getElementById('shop-search-suggest');
const barcodeInput = document.getElementById('barcode-input');
const returnListBody = document.getElementById('return-list-body');
const returnCodeBody = document.getElementById('return-code-body');
const saveForm = document.getElementById('return-save-form');
const saveDsIdx = document.getElementById('save-ds-idx');
const saveScansJson = document.getElementById('save-scans-json');
const scanMessage = document.getElementById('scan-message');
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
let selectedShop = null;
let selectedBagCode = '';
const scannedRows = [];
function mergedShopText(shop) {
return [shop.ds_shop_no, shop.ds_name, shop.ds_rep_name, shop.ds_tel, shop.ds_addr].filter(Boolean).join(' ');
}
function setMessage(msg, isError = false) {
scanMessage.textContent = msg || '';
scanMessage.className = isError ? 'text-sm text-red-600' : 'text-sm text-emerald-700';
}
function updateShopInfo(shop) {
document.getElementById('shop-info-code').textContent = shop?.ds_shop_no || '-';
document.getElementById('shop-info-name').textContent = shop?.ds_name || '-';
document.getElementById('shop-info-rep').textContent = shop?.ds_rep_name || '-';
document.getElementById('shop-info-tel').textContent = shop?.ds_tel || '-';
document.getElementById('shop-info-addr').textContent = shop?.ds_addr || '-';
}
function hideSuggest() {
if (!shopSuggest) return;
shopSuggest.classList.add('hidden');
shopSuggest.innerHTML = '';
}
function renderSuggest(query) {
if (!shopSuggest) return;
const q = String(query || '').trim().toLowerCase();
const matched = (q ? shops.filter((s) => mergedShopText(s).toLowerCase().includes(q)) : shops).slice(0, 30);
if (matched.length === 0) {
hideSuggest();
return;
}
shopSuggest.innerHTML = matched.map((s) => `
<button type="button" class="shop-suggest-item w-full text-left px-2 py-1.5 hover:bg-blue-50 text-xs border-b border-gray-100 whitespace-normal break-all" data-ds-idx="${s.ds_idx}">
${mergedShopText(s)}
</button>
`).join('');
shopSuggest.classList.remove('hidden');
}
function aggregateByBag() {
const map = new Map();
scannedRows.forEach((r) => {
const key = String(r.bag_code || '');
if (!map.has(key)) {
map.set(key, {
bag_code: key,
bag_name: String(r.bag_name || ''),
unit_price: Number(r.unit_price || 0),
qty: 0,
amount: 0,
});
}
const item = map.get(key);
item.qty += Number(r.qty || 0);
item.amount += Number(r.amount || 0);
});
return Array.from(map.values());
}
function renderReturnList() {
const rows = aggregateByBag();
if (rows.length === 0) {
returnListBody.innerHTML = '<tr><td colspan="6" class="text-center text-gray-400 py-6">판매소를 선택하고 바코드를 스캔해 주세요.</td></tr>';
document.getElementById('sum-qty').textContent = '0';
document.getElementById('sum-amount').textContent = '0';
return;
}
returnListBody.innerHTML = rows.map((r) => `
<tr>
<td class="text-center"><input type="radio" name="pick-return-bag" value="${r.bag_code}" ${selectedBagCode === r.bag_code ? 'checked' : ''}></td>
<td class="text-left pl-2">${r.bag_code} ${r.bag_name || ''}</td>
<td class="text-right pr-2">${nf(r.qty)}</td>
<td class="text-right pr-2">${nf(r.unit_price)}</td>
<td class="text-right pr-2">${nf(r.amount)}</td>
<td class="text-center"><button type="button" class="btn-remove-bag text-red-600 hover:underline text-xs" data-bag-code="${r.bag_code}">제거</button></td>
</tr>
`).join('');
const sumQty = rows.reduce((s, r) => s + Number(r.qty || 0), 0);
const sumAmount = rows.reduce((s, r) => s + Number(r.amount || 0), 0);
document.getElementById('sum-qty').textContent = nf(sumQty);
document.getElementById('sum-amount').textContent = nf(sumAmount);
}
function renderCodeTable() {
if (!selectedBagCode) {
returnCodeBody.innerHTML = '<tr><td colspan="4" class="text-center text-gray-400 py-6">품목을 선택해 주세요.</td></tr>';
return;
}
const rows = scannedRows.filter((r) => String(r.bag_code || '') === selectedBagCode);
if (rows.length === 0) {
returnCodeBody.innerHTML = '<tr><td colspan="4" class="text-center text-gray-400 py-6">반품 봉투 코드가 없습니다.</td></tr>';
return;
}
returnCodeBody.innerHTML = rows.map((r) => `
<tr>
<td class="text-left pl-2">${r.bag_code || ''} ${r.bag_name || ''}</td>
<td class="text-center">${r.code || ''}</td>
<td class="text-right pr-2">${nf(r.qty)}</td>
<td class="text-right pr-2">${nf(r.amount)}</td>
</tr>
`).join('');
}
function removeScannedByBagCode(bagCode) {
if (!bagCode) return;
for (let i = scannedRows.length - 1; i >= 0; i -= 1) {
if (String(scannedRows[i].bag_code || '') === bagCode) scannedRows.splice(i, 1);
}
if (selectedBagCode === bagCode) selectedBagCode = '';
saveScansJson.value = JSON.stringify(scannedRows);
}
function applyResolvedShopFromScan(data) {
const dsIdx = Number(data.ds_idx || 0);
if (!dsIdx) return;
let shop = shops.find((s) => Number(s.ds_idx) === dsIdx) || null;
if (!shop) {
shop = {
ds_idx: dsIdx,
ds_shop_no: String(data.ds_shop_no || ''),
ds_name: String(data.ds_name || ''),
ds_rep_name: String(data.ds_rep_name || ''),
ds_tel: String(data.ds_tel || ''),
ds_addr: String(data.ds_addr || ''),
};
}
const prevIdx = selectedShop ? Number(selectedShop.ds_idx) : 0;
if (prevIdx && prevIdx !== dsIdx && scannedRows.length > 0) {
scannedRows.length = 0;
saveScansJson.value = '[]';
selectedBagCode = '';
setMessage('바코드에 해당하는 지정판매소로 변경되어 이전 스캔 목록은 초기화되었습니다.', false);
}
selectedShop = shop;
shopSearch.value = mergedShopText(shop);
saveDsIdx.value = String(dsIdx);
updateShopInfo(shop);
}
async function submitScan() {
const code = (barcodeInput.value || '').trim();
if (!code) return;
if (scannedRows.some((r) => String(r.code || '') === code)) {
setMessage('이미 스캔한 바코드입니다.', true);
barcodeInput.value = '';
return;
}
const payload = new URLSearchParams();
payload.set(csrfName, csrfHash);
payload.set('ds_idx', selectedShop ? String(selectedShop.ds_idx) : '0');
payload.set('barcode', code);
const res = await fetch(scanApi, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: payload.toString(),
});
const data = await res.json();
if (!data.ok) {
setMessage(data.message || '스캔 실패', true);
return;
}
applyResolvedShopFromScan(data);
scannedRows.push({
code: data.code,
bag_code: data.bag_code,
bag_name: data.bag_name,
qty: Number(data.qty || 0),
unit: data.unit || '',
unit_price: Number(data.unit_price || 0),
amount: Number(data.amount || 0),
so_idx: Number(data.so_idx || 0),
});
saveScansJson.value = JSON.stringify(scannedRows);
barcodeInput.value = '';
if (!selectedBagCode) selectedBagCode = String(data.bag_code || '');
setMessage(`반품 코드 등록: ${data.code} / 수량 ${nf(data.qty)}`);
renderReturnList();
renderCodeTable();
}
shopSearch?.addEventListener('input', (e) => renderSuggest(e.target.value || ''));
shopSearch?.addEventListener('focus', (e) => renderSuggest(e.target.value || ''));
shopSearch?.addEventListener('click', (e) => renderSuggest(e.target.value || ''));
shopSuggest?.addEventListener('click', (e) => {
const btn = e.target.closest('.shop-suggest-item');
if (!btn) return;
const dsIdx = Number(btn.dataset.dsIdx || 0);
const shop = shops.find((s) => Number(s.ds_idx) === dsIdx) || null;
if (!shop) return;
selectedShop = shop;
shopSearch.value = mergedShopText(shop);
saveDsIdx.value = String(shop.ds_idx);
selectedBagCode = '';
scannedRows.length = 0;
saveScansJson.value = '[]';
updateShopInfo(shop);
renderReturnList();
renderCodeTable();
setMessage('');
hideSuggest();
});
document.addEventListener('click', (e) => {
if (!shopSuggest || !shopSearch) return;
if (e.target === shopSearch || shopSuggest.contains(e.target)) return;
hideSuggest();
});
barcodeInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitScan();
}
});
returnListBody?.addEventListener('change', (e) => {
const radio = e.target.closest('input[name="pick-return-bag"]');
if (!radio) return;
selectedBagCode = radio.value || '';
renderCodeTable();
});
returnListBody?.addEventListener('click', (e) => {
const btn = e.target.closest('.btn-remove-bag');
if (!btn) return;
const bagCode = String(btn.dataset.bagCode || '');
if (!bagCode) return;
if (!confirm(`'${bagCode}' 봉투종류의 스캔 내역을 모두 제거할까요?`)) return;
removeScannedByBagCode(bagCode);
renderReturnList();
renderCodeTable();
setMessage(`반품 리스트에서 ${bagCode} 봉투종류를 제거했습니다.`);
});
saveForm?.addEventListener('submit', (e) => {
if (!selectedShop) {
e.preventDefault();
alert('판매소를 선택해 주세요.');
return;
}
if (scannedRows.length === 0) {
e.preventDefault();
alert('반품할 바코드를 스캔해 주세요.');
return;
}
});
renderReturnList();
renderCodeTable();
})();
</script>
<?= view('bag/_dev_all_sales_panel') ?>

View File

@@ -1,93 +1,288 @@
<div class="space-y-1">
<form method="get" class="flex items-center gap-3 text-sm mb-3">
<label class="font-bold text-gray-700">조회기간</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<span>~</span>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
<a href="<?= base_url('bag/flow') ?>" class="text-sm text-gray-500 hover:text-gray-700">초기화</a>
</form>
<div class="flex gap-2 mb-2">
<a href="<?= base_url('bag/receiving/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">입고 처리</a>
<a href="<?= base_url('bag/sale/create') ?>" class="bg-white text-blue-600 border border-blue-300 px-3 py-1.5 rounded-sm text-sm">판매 등록</a>
<a href="<?= base_url('bag/issue/create') ?>" class="bg-white text-blue-600 border border-blue-300 px-3 py-1.5 rounded-sm text-sm">불출 처리</a>
</div>
<?php
$startDate = (string) ($startDate ?? date('Y-m-01'));
$endDate = (string) ($endDate ?? date('Y-m-d'));
$aggMode = (string) ($aggMode ?? 'period');
$bagCode = (string) ($bagCode ?? '');
$bagKind = (string) ($bagKind ?? '');
$saIdx = (int) ($saIdx ?? 0);
$rows = is_array($rows ?? null) ? $rows : [];
$bagProducts = is_array($bagProducts ?? null) ? $bagProducts : [];
$bagKindOptions = is_array($bagKindOptions ?? null) ? $bagKindOptions : [];
$agencies = is_array($agencies ?? null) ? $agencies : [];
$exportQuery = (string) ($exportQuery ?? 'search=1');
$queried = (bool) ($queried ?? false);
$fmt = static fn ($n): string => number_format((int) $n);
<!-- 수불 요약 -->
<table class="data-table">
$printExtraLines = [];
if ($queried) {
$aggLabel = $aggMode === 'daily' ? '일자별' : '기간별';
$printExtraLines[] = '조회기간: ' . $startDate . ' ~ ' . $endDate . ' (' . $aggLabel . ')';
}
?>
<div class="flow-print-sheet">
<?= view('components/print_header', [
'printTitle' => '기간별 봉투 수불 현황',
'printExtraLines' => $printExtraLines,
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">기간별 봉투 수불 현황</span>
<div class="flex flex-wrap items-center gap-2">
<a href="<?= base_url('bag/flow/export?' . esc($exportQuery, 'attr')) ?>" class="bg-green-700 text-white px-3 py-1 rounded-sm text-sm hover:bg-green-800">엑셀저장</a>
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
<a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">종료</a>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="get" action="<?= base_url('bag/flow') ?>" class="flex flex-wrap items-end gap-x-3 gap-y-2 text-sm">
<input type="hidden" name="search" value="1"/>
<div class="flex flex-wrap items-center gap-2">
<label class="font-bold text-gray-700 whitespace-nowrap">조회기간</label>
<input type="date" name="start_date" value="<?= esc($startDate) ?>" class="border border-gray-300 rounded px-2 py-1" required/>
<span>~</span>
<input type="date" name="end_date" value="<?= esc($endDate) ?>" class="border border-gray-300 rounded px-2 py-1" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="font-bold text-gray-700 whitespace-nowrap">봉투형식</label>
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1 min-w-[11rem]">
<option value="">전체 봉투</option>
<?php foreach ($bagProducts as $bp): ?>
<option value="<?= esc((string) $bp['code']) ?>" <?= $bagCode === (string) $bp['code'] ? 'selected' : '' ?>>
<?= esc((string) $bp['code']) ?> — <?= esc((string) $bp['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="font-bold text-gray-700 whitespace-nowrap">봉투구분</label>
<select name="bag_kind" class="border border-gray-300 rounded px-2 py-1 min-w-[8rem]">
<option value="">전체</option>
<?php foreach ($bagKindOptions as $opt): ?>
<option value="<?= esc((string) $opt->cd_code) ?>" <?= $bagKind === (string) $opt->cd_code ? 'selected' : '' ?>>
<?= esc((string) $opt->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="font-bold text-gray-700 whitespace-nowrap">대행소</label>
<select name="sa_idx" class="border border-gray-300 rounded px-2 py-1 min-w-[10rem]">
<option value="0">전체</option>
<?php foreach ($agencies as $agency): ?>
<?php
$aid = (int) ($agency->sa_idx ?? 0);
$label = (string) ($agency->sa_name ?? '');
if (isset($agency->sa_kind) && (string) $agency->sa_kind !== '') {
$label = (string) $agency->sa_kind . ' — ' . $label;
}
?>
<option value="<?= $aid ?>" <?= $saIdx === $aid ? 'selected' : '' ?>><?= esc($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-3">
<span class="font-bold text-gray-700 whitespace-nowrap">집계방식</span>
<label class="inline-flex items-center gap-1">
<input type="radio" name="agg_mode" value="daily" <?= $aggMode === 'daily' ? 'checked' : '' ?>/>
일자별
</label>
<label class="inline-flex items-center gap-1">
<input type="radio" name="agg_mode" value="period" <?= $aggMode === 'period' ? 'checked' : '' ?>/>
기간별
</label>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
<a href="<?= base_url('bag/flow') ?>" class="text-gray-500 hover:text-gray-800 px-2">초기화</a>
</form>
<p class="text-xs text-gray-500 mt-1">전일재고 = 조회 시작일 전날 기준 품목별 재고(입고·반품·기타 출고 누적). 대행소 선택 시 <strong>판매</strong>만 해당 대행소 소속 판매소 기준입니다.</p>
</section>
<?php if (! $queried): ?>
<div class="m-2 p-3 border border-blue-200 bg-blue-50 text-sm text-blue-900 no-print">
조회 조건을 설정한 뒤 <strong>조회</strong> 버튼을 눌러 주세요.
</div>
<?php endif; ?>
<?php if ($queried): ?>
<div class="p-2 overflow-auto flow-report-wrap">
<table class="w-full data-table text-sm flow-report-table">
<thead>
<tr>
<th rowspan="2">봉투코드</th>
<th rowspan="2">봉투명</th>
<th rowspan="2">현재재고</th>
<th colspan="2">입고</th>
<th colspan="2">출고</th>
<th rowspan="2" class="flow-col-date">일자</th>
<th rowspan="2" class="flow-col-item">품목</th>
<th rowspan="2" class="flow-col-num">
<span class="flow-lbl-screen">전일재고</span><span class="flow-lbl-print">전일</span>
</th>
<th colspan="4">입고</th>
<th colspan="6">출고</th>
<th rowspan="2" class="flow-col-num">잔량</th>
</tr>
<tr>
<th>입고수량</th><th>반품수량</th>
<th>판매수량</th><th>불출수량</th>
<th class="flow-col-num">입고</th>
<th class="flow-col-num">반품</th>
<th class="flow-col-num">기타</th>
<th class="flow-col-num">
<span class="flow-lbl-screen">입고계</span><span class="flow-lbl-print">입계</span>
</th>
<th class="flow-col-num">판매</th>
<th class="flow-col-num">
<span class="flow-lbl-screen">일반불출</span><span class="flow-lbl-print">일반</span>
</th>
<th class="flow-col-num">
<span class="flow-lbl-screen">무료불출</span><span class="flow-lbl-print">무료</span>
</th>
<th class="flow-col-num">반품</th>
<th class="flow-col-num">기타</th>
<th class="flow-col-num">
<span class="flow-lbl-screen">출고계</span><span class="flow-lbl-print">출계</span>
</th>
</tr>
</thead>
<tbody>
<?php
// 봉투코드별 수불 집계
$summary = [];
// 재고
foreach ($inventory as $inv) {
$code = $inv->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);
?>
<?php if (! empty($summary)): ?>
<?php $idx = 0; foreach ($summary as $code => $s): $idx++; ?>
<tr>
<td class="text-center"><?= esc($code) ?></td>
<td><?= esc($s['name']) ?></td>
<td class="text-right"><?= number_format($s['stock']) ?></td>
<td class="text-right"><?= number_format($s['recv']) ?></td>
<td class="text-right"><?= number_format($s['return']) ?></td>
<td class="text-right"><?= number_format($s['sale']) ?></td>
<td class="text-right"><?= number_format($s['issue']) ?></td>
<tbody class="text-right">
<?php if ($rows !== []): ?>
<?php foreach ($rows as $row): ?>
<?php
$rowType = (string) ($row['row_type'] ?? 'data');
$trClass = match ($rowType) {
'subtotal', 'grand' => 'bg-amber-50 font-semibold',
default => '',
};
?>
<tr class="<?= esc($trClass) ?>">
<td class="flow-col-date text-center"><?= esc((string) ($row['date'] ?? '')) ?></td>
<td class="flow-col-item text-left pl-2"><?= esc((string) ($row['item_name'] ?? '')) ?></td>
<td class="flow-col-num tabular-nums"><?= $fmt($row['prev_stock'] ?? 0) ?></td>
<td class="flow-col-num tabular-nums"><?= $fmt($row['recv_in'] ?? 0) ?></td>
<td class="flow-col-num tabular-nums"><?= $fmt($row['recv_return'] ?? 0) ?></td>
<td class="flow-col-num tabular-nums"><?= $fmt($row['recv_misc'] ?? 0) ?></td>
<td class="flow-col-num tabular-nums"><?= $fmt($row['recv_total'] ?? 0) ?></td>
<td class="flow-col-num tabular-nums"><?= $fmt($row['out_sale'] ?? 0) ?></td>
<td class="flow-col-num tabular-nums"><?= $fmt($row['out_issue_gen'] ?? 0) ?></td>
<td class="flow-col-num tabular-nums"><?= $fmt($row['out_issue_free'] ?? 0) ?></td>
<td class="flow-col-num tabular-nums"><?= $fmt($row['out_return'] ?? 0) ?></td>
<td class="flow-col-num tabular-nums"><?= $fmt($row['out_misc'] ?? 0) ?></td>
<td class="flow-col-num tabular-nums"><?= $fmt($row['out_total'] ?? 0) ?></td>
<td class="flow-col-num font-semibold tabular-nums"><?= $fmt($row['balance'] ?? 0) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="7" class="text-center text-gray-400 py-4">수불 데이터가 없습니다.</td></tr>
<?php endif; ?>
<?php else: ?>
<tr>
<td colspan="15" class="text-center text-gray-400 py-8">조회 결과가 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<style>
.flow-lbl-print { display: none; }
@media screen {
.flow-report-wrap { overflow-x: auto; }
.flow-report-table { min-width: 1200px; }
}
@media print {
@page {
size: A4 portrait;
margin: 10mm 8mm;
}
html { font-size: 12px !important; }
.flow-print-sheet {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box;
}
.print-header,
.print-header table,
.print-header hr {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box;
}
.print-header table td[style*="width:45%"] table {
width: 160px !important;
max-width: 38% !important;
font-size: 9px !important;
}
.flow-report-wrap {
overflow: hidden !important;
padding: 0 !important;
width: 100% !important;
max-width: 100% !important;
}
.flow-report-table.data-table {
min-width: 0 !important;
width: 100% !important;
max-width: 100% !important;
table-layout: fixed !important;
font-size: 6px !important;
}
.flow-report-table.data-table th,
.flow-report-table.data-table td {
white-space: normal !important;
word-break: keep-all;
overflow-wrap: anywhere;
padding: 1px 1px !important;
line-height: 1.1;
vertical-align: middle;
}
.flow-lbl-screen { display: none !important; }
.flow-lbl-print { display: inline !important; }
/* 세로 A4: 일자 10% + 품목 14% + 수치 12열 각 6.33% ≈ 100% */
.flow-report-table .flow-col-date {
width: 10%;
font-size: 5px !important;
text-align: center;
}
.flow-report-table .flow-col-item {
width: 14%;
text-align: left;
font-size: 5px !important;
padding-top: 3px !important;
padding-bottom: 3px !important;
line-height: 1.25;
}
.flow-report-table .flow-col-num {
width: 6.33%;
white-space: nowrap !important;
font-size: 6px !important;
text-align: right;
padding-left: 0 !important;
padding-right: 1px !important;
}
.flow-report-table thead th {
font-size: 5px !important;
font-weight: 700;
padding: 1px 0 !important;
}
.flow-report-table tbody tr {
break-inside: avoid;
page-break-inside: avoid;
}
}
</style>

View File

@@ -1,27 +1,122 @@
<div class="space-y-1">
<div class="flex items-center justify-between mb-2">
<span></span>
<a href="<?= base_url('bag/inventory/adjust') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">재고 조정</a>
<?= view('components/print_header', [
'printTitle' => '재고 현황',
'printDate' => (string) ($baseDate ?? date('Y-m-d')),
'printExtraLines' => [
'기준일자: ' . (string) ($baseDate ?? date('Y-m-d')),
],
]) ?>
<?php
$baseDate = (string) ($baseDate ?? date('Y-m-d'));
$agencyIdx = (int) ($agencyIdx ?? 0);
$rows = is_array($rows ?? null) ? $rows : [];
$subtotals = is_array($subtotals ?? null) ? $subtotals : [];
$grandTotals = is_array($grandTotals ?? null) ? $grandTotals : ['total' => 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;
}
}
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<form method="get" class="flex flex-wrap items-end justify-between gap-2">
<div class="flex flex-wrap items-end gap-2 text-sm">
<label class="font-bold text-gray-700">기준일자</label>
<input type="date" name="base_date" value="<?= esc($baseDate) ?>" class="border border-gray-300 rounded px-2 py-1 min-w-[10rem]">
<label class="font-bold text-gray-700">대행소</label>
<select name="agency_idx" class="border border-gray-300 rounded px-2 py-1 min-w-[12rem]">
<option value="0">전체</option>
<?php foreach ($agencyOptions as $agency): ?>
<?php $idx = (int) ($agency->sa_idx ?? 0); ?>
<option value="<?= esc((string) $idx) ?>" <?= $agencyIdx === $idx ? 'selected' : '' ?>>
<?= esc((string) ($agency->sa_name ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2">
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= base_url('bag/inventory/export?' . http_build_query(['base_date' => $baseDate, 'agency_idx' => $agencyIdx])) ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
<button type="button" onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= base_url('bag/inventory/inspection-select') ?>" class="no-print border border-blue-300 text-blue-700 px-3 py-1 rounded-sm text-sm hover:bg-blue-50 transition">실사 선별 조회</a>
</div>
</form>
</section>
<div class="mt-2 border border-gray-300 bg-white p-2 print:p-0 print:border-0">
<div class="overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-36">품 목 구 분</th>
<th>봉투/스티커 종류</th>
<th class="w-32">계</th>
<th class="w-32">시군구 재고</th>
<th class="w-32">대행소 재고</th>
</tr>
</thead>
<tbody>
<?php if ($rows !== []): ?>
<?php
$groupRowCount = [];
foreach ($rows as $row) {
$group = (string) ($row['group'] ?? '');
if (! isset($groupRowCount[$group])) {
$groupRowCount[$group] = 0;
}
$groupRowCount[$group]++;
}
$printedGroupCount = [];
?>
<?php foreach ($rows as $row): ?>
<?php
$group = (string) ($row['group'] ?? '');
if (! isset($printedGroupCount[$group])) {
$printedGroupCount[$group] = 0;
}
$printedGroupCount[$group]++;
$isFirst = $printedGroupCount[$group] === 1;
$isLast = $printedGroupCount[$group] === (int) ($groupRowCount[$group] ?? 0);
?>
<tr>
<?php if ($isFirst): ?>
<td class="text-center font-semibold bg-gray-50" rowspan="<?= esc((string) ($groupRowCount[$group] ?? 1)) ?>"><?= esc($group) ?></td>
<?php endif; ?>
<td class="pl-2"><?= esc((string) ($row['name'] ?? '')) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($row['total_qty'] ?? 0)) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($row['gugun_qty'] ?? 0)) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($row['agency_qty'] ?? 0)) ?></td>
</tr>
<?php if ($isLast && isset($subtotalByGroup[$group])): ?>
<?php $s = $subtotalByGroup[$group]; ?>
<tr class="bg-blue-50 font-semibold">
<td class="text-center">소계</td>
<td class="text-right pr-2"><?= number_format((int) ($s['total_qty'] ?? 0)) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($s['gugun_qty'] ?? 0)) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($s['agency_qty'] ?? 0)) ?></td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="5" class="text-center text-gray-400 py-4">조회 결과가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr class="bg-gray-100 font-bold">
<td class="text-center" colspan="2">합계</td>
<td class="text-right pr-2"><?= number_format((int) ($grandTotals['total'] ?? 0)) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($grandTotals['gugun'] ?? 0)) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($grandTotals['agency'] ?? 0)) ?></td>
</tr>
</tfoot>
</table>
</div>
<table class="data-table">
<thead><tr>
<th class="w-16">번호</th><th>봉투코드</th><th>봉투명</th><th>현재재고(낱장)</th><th>최종갱신</th>
</tr></thead>
<tbody>
<?php if (! empty($list)): ?>
<?php foreach ($list as $i => $row): ?>
<tr>
<td class="text-center"><?= $i + 1 ?></td>
<td class="text-center"><?= esc($row->bi_bag_code ?? '') ?></td>
<td><?= esc($row->bi_bag_name ?? '') ?></td>
<td class="text-right"><?= number_format((int)($row->bi_qty ?? 0)) ?></td>
<td class="text-center"><?= esc($row->bi_updated_at ?? '') ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="5" class="text-center text-gray-400 py-4">재고 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
<p class="mt-2 text-xs text-gray-500">
※ 기준일자까지 갱신된 재고를 집계합니다. 대행소 재고는 별도 재고 연계 전까지 0으로 표시됩니다.
</p>
</div>

View File

@@ -1,43 +0,0 @@
<div class="max-w-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-bold text-gray-700">재고 수량 조정 (실사)</h3>
</div>
<form action="<?= base_url('bag/inventory/adjust') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">봉투코드 <span class="text-red-500">*</span></label>
<select name="bag_code" required class="w-full border border-gray-300 rounded px-3 py-2 text-sm">
<option value="">선택</option>
<?php foreach ($inventory as $item): ?>
<option value="<?= esc($item->bi_bag_code) ?>"><?= esc($item->bi_bag_code) ?> — <?= esc($item->bi_bag_name) ?> (현재: <?= number_format((int)$item->bi_qty) ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">조정 유형 <span class="text-red-500">*</span></label>
<select name="adjust_type" required class="w-full border border-gray-300 rounded px-3 py-2 text-sm">
<option value="set">실사 수량으로 설정</option>
<option value="add">증가 (+)</option>
<option value="sub">감소 (-)</option>
</select>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">수량 <span class="text-red-500">*</span></label>
<input type="number" name="qty" required min="0" value="0" class="w-full border border-gray-300 rounded px-3 py-2 text-sm"/>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">사유</label>
<input type="text" name="reason" placeholder="실사 조정, 오류 수정 등" class="w-full border border-gray-300 rounded px-3 py-2 text-sm"/>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-2 rounded-sm text-sm">조정</button>
<a href="<?= base_url('bag/inventory') ?>" class="bg-gray-200 text-gray-700 px-6 py-2 rounded-sm text-sm">취소</a>
</div>
</form>
</div>

View File

@@ -5,6 +5,7 @@
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>스마트 폐기물 관리 시스템 - 재고 관리</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<style data-purpose="custom-styles">
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap');

View File

@@ -0,0 +1,90 @@
<?php
$inspection = is_array($inspection ?? null) ? $inspection : [];
$items = is_array($items ?? null) ? $items : [];
$status = (string) ($inspection['bis_status'] ?? '');
?>
<div class="space-y-3">
<section class="border border-gray-300 bg-white p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm text-gray-700">
<span class="font-bold">실사번호:</span> <?= esc((string) ($inspection['bis_idx'] ?? '')) ?>
<span class="mx-2">|</span>
<span class="font-bold">작업일자:</span> <?= esc((string) ($inspection['bis_work_date'] ?? '')) ?>
<span class="mx-2">|</span>
<span class="font-bold">상태:</span> <?= esc($status) ?>
</div>
<a href="<?= base_url('bag/inventory/inspection-select') ?>" class="text-sm text-blue-700 hover:underline">실사 선별 조회로 돌아가기</a>
</div>
</section>
<section class="border border-gray-300 bg-white p-3">
<form method="post" action="<?= base_url('bag/inventory/inspection/' . (int) ($inspection['bis_idx'] ?? 0) . '/save') ?>" class="space-y-3">
<?= csrf_field() ?>
<div class="overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-14">번호</th>
<th>품목명</th>
<th class="w-24">전산수량</th>
<th class="w-24">실사수량</th>
<th class="w-24">차이수량</th>
<th class="w-20">반영여부</th>
</tr>
</thead>
<tbody>
<?php if ($items !== []): ?>
<?php foreach ($items as $i => $row): ?>
<?php
$itemId = (int) ($row['bisi_idx'] ?? 0);
$systemQty = (int) ($row['bisi_system_qty'] ?? 0);
$actualQty = $row['bisi_actual_qty'];
$actualQty = $actualQty === null ? '' : (string) ((int) $actualQty);
$diffQty = (int) ($row['bisi_diff_qty'] ?? 0);
?>
<tr>
<td class="text-center"><?= $i + 1 ?></td>
<td class="pl-2"><?= esc((string) ($row['bisi_bag_name'] ?? '')) ?> <span class="text-xs text-gray-400">(<?= esc((string) ($row['bisi_bag_code'] ?? '')) ?>)</span></td>
<td class="text-right pr-2"><?= number_format($systemQty) ?></td>
<td class="text-right pr-2">
<input type="number" min="0" name="actual_qty[<?= esc((string) $itemId, 'attr') ?>]" value="<?= esc($actualQty) ?>" class="border border-gray-300 rounded px-1 py-0.5 w-24 text-right">
</td>
<td class="text-right pr-2 <?= $diffQty === 0 ? '' : ($diffQty > 0 ? 'text-blue-700' : 'text-red-700') ?>"><?= number_format($diffQty) ?></td>
<td class="text-center"><?= ((string) ($row['bisi_apply_yn'] ?? 'N')) === 'Y' ? 'Y' : 'N' ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="6" class="text-center text-gray-400 py-4">실사 품목이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="flex justify-end gap-2">
<button type="submit" class="bg-btn-search text-white px-5 py-1.5 rounded-sm text-sm">실사 저장</button>
</div>
</form>
</section>
<section class="border border-gray-300 bg-white p-3">
<form method="post" action="<?= base_url('bag/inventory/inspection/' . (int) ($inspection['bis_idx'] ?? 0) . '/apply') ?>" id="inspection-apply-form">
<?= csrf_field() ?>
<div class="flex items-center justify-between">
<p class="text-sm text-gray-600">실사 저장 후 확정 시 차이수량이 현재 재고에 반영됩니다.</p>
<button type="submit" class="bg-green-600 text-white px-5 py-1.5 rounded-sm text-sm">실사 확정(재고 반영)</button>
</div>
</form>
</section>
</div>
<script>
(() => {
const form = document.getElementById('inspection-apply-form');
if (!form) return;
form.addEventListener('submit', (event) => {
const ok = window.confirm('실사 결과를 재고에 반영하시겠습니까?');
if (!ok) {
event.preventDefault();
}
});
})();
</script>

View File

@@ -63,11 +63,7 @@ $sheetTotalDiff = $sheetTotalActual - $sheetTotalQty;
<?php endforeach; ?>
</select>
<label class="font-bold text-gray-700 ml-2">조회구분</label>
<select name="view_type" class="border border-gray-300 rounded px-2 py-1 min-w-[7rem]">
<option value="box" <?= $viewType === 'box' ? 'selected' : '' ?>>박스별</option>
<option value="pack" <?= $viewType === 'pack' ? 'selected' : '' ?>>팩별</option>
</select>
<input type="hidden" name="view_type" value="<?= esc($viewType) ?>">
</div>
<div class="flex items-center gap-2">
<button type="submit" class="bg-btn-search text-white px-3 py-1 rounded-sm">조회</button>

View File

@@ -1,54 +1,409 @@
<div class="space-y-1">
<div class="flex items-center justify-between mb-1">
<form method="get" class="flex items-center gap-3 text-sm">
<label class="font-bold text-gray-700">불출일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<span>~</span>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
<a href="<?= base_url('bag/issue') ?>" class="text-sm text-gray-500 hover:text-gray-700">초기화</a>
<?= view('components/print_header', ['printTitle' => '무료용 불출 취소']) ?>
<?php
$filters = is_array($filters ?? null) ? $filters : [];
$issueMonth = (string) ($filters['issue_month'] ?? '');
$destName = (string) ($filters['dest_name'] ?? '');
$issueType = (string) ($filters['issue_type'] ?? '');
$bagCode = (string) ($filters['bag_code'] ?? '');
$selectedGroupDate = (string) ($selectedGroupDate ?? '');
$selectedGroupDest = (string) ($selectedGroupDest ?? '');
$selectedIssueId = (int) ($selectedIssueId ?? 0);
$selectedBagCode = (string) ($selectedBagCode ?? '');
?>
<div class="space-y-2">
<div class="border border-gray-300 bg-white p-2">
<form method="get" class="flex flex-wrap items-end gap-2 text-sm">
<label class="font-bold text-gray-700">불출월</label>
<select name="issue_month" class="border border-gray-300 rounded px-2 py-1 min-w-[9rem]">
<option value="">전체</option>
<?php foreach (($monthOptions ?? []) as $month): ?>
<option value="<?= esc($month) ?>" <?= $issueMonth === $month ? 'selected' : '' ?>><?= esc($month) ?></option>
<?php endforeach; ?>
</select>
<label class="font-bold text-gray-700">불출처</label>
<select name="dest_name" class="border border-gray-300 rounded px-2 py-1 min-w-[10rem]">
<option value="">전체</option>
<?php foreach (($destOptions ?? []) as $opt): ?>
<option value="<?= esc($opt) ?>" <?= $destName === $opt ? 'selected' : '' ?>><?= esc($opt) ?></option>
<?php endforeach; ?>
</select>
<label class="font-bold text-gray-700">불출구분</label>
<select name="issue_type" class="border border-gray-300 rounded px-2 py-1 min-w-[8rem]">
<option value="">전체</option>
<?php foreach (($typeOptions ?? []) as $opt): ?>
<option value="<?= esc($opt) ?>" <?= $issueType === $opt ? 'selected' : '' ?>><?= esc($opt) ?></option>
<?php endforeach; ?>
</select>
<label class="font-bold text-gray-700">봉투종류</label>
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1 min-w-[12rem]">
<option value="">전체</option>
<?php foreach (($bagOptions ?? []) as $opt): ?>
<?php $code = (string) ($opt['code'] ?? ''); ?>
<option value="<?= esc($code) ?>" <?= $bagCode === $code ? 'selected' : '' ?>>
<?= esc($code) ?> - <?= esc((string) ($opt['name'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
<input type="hidden" name="sel_date" value="<?= esc($selectedGroupDate) ?>"/>
<input type="hidden" name="sel_dest" value="<?= esc($selectedGroupDest) ?>"/>
<input type="hidden" name="sel_issue_id" value="<?= esc((string) $selectedIssueId) ?>"/>
<input type="hidden" name="sel_bag_code" value="<?= esc($selectedBagCode) ?>"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
<a href="<?= base_url('bag/issue/cancel') ?>" class="text-sm text-gray-500 hover:text-gray-700">초기화</a>
<button type="button" onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-4 py-1 rounded-sm hover:bg-gray-50">인쇄</button>
<a href="<?= base_url('bag/issue/create') ?>" class="bg-btn-search text-white px-3 py-1 rounded-sm">불출 처리</a>
</form>
<a href="<?= base_url('bag/issue/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">불출 처리</a>
</div>
<table class="data-table">
<thead><tr>
<th class="w-16">번호</th><th>연도</th><th>분기</th><th>구분</th><th>불출일</th><th>불출처</th><th>봉투코드</th><th>봉투명</th><th>수량</th><th>상태</th><th>작업</th>
</tr></thead>
<tbody>
<?php if (! empty($list)): ?>
<?php foreach ($list as $i => $row): ?>
<tr>
<td class="text-center"><?= $i + 1 ?></td>
<td class="text-center"><?= esc($row->bi2_year ?? '') ?></td>
<td class="text-center"><?= esc($row->bi2_quarter ?? '') ?></td>
<td class="text-center"><?= esc($row->bi2_issue_type ?? '') ?></td>
<td class="text-center"><?= esc($row->bi2_issue_date ?? '') ?></td>
<td><?= esc($row->bi2_dest_name ?? '') ?></td>
<td class="text-center"><?= esc($row->bi2_bag_code ?? '') ?></td>
<td><?= esc($row->bi2_bag_name ?? '') ?></td>
<td class="text-right"><?= number_format((int)($row->bi2_qty ?? 0)) ?></td>
<td class="text-center">
<?php
$st = $row->bi2_status ?? 'normal';
echo match($st) { 'normal' => '정상', 'cancelled' => '<span class="text-orange-600">취소</span>', default => esc($st) };
?>
</td>
<td class="text-center">
<?php if (($row->bi2_status ?? '') === 'normal'): ?>
<form method="post" action="<?= base_url('bag/issue/cancel/' . $row->bi2_idx) ?>" class="inline" onsubmit="return confirm('취소하시겠습니까?')">
<?= csrf_field() ?>
<button class="text-orange-600 hover:underline text-xs">취소</button>
</form>
<?php else: ?>
-
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="11" class="text-center text-gray-400 py-4">등록된 불출이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
<form method="post" action="<?= base_url('bag/issue/cancel-save') ?>" id="issue-cancel-form">
<?= csrf_field() ?>
<input type="hidden" name="issue_month" value="<?= esc($issueMonth) ?>"/>
<input type="hidden" name="dest_name" value="<?= esc($destName) ?>"/>
<input type="hidden" name="issue_type" value="<?= esc($issueType) ?>"/>
<input type="hidden" name="bag_code" value="<?= esc($bagCode) ?>"/>
<input type="hidden" name="sel_date" value="<?= esc($selectedGroupDate) ?>"/>
<input type="hidden" name="sel_dest" value="<?= esc($selectedGroupDest) ?>"/>
<input type="hidden" name="sel_issue_id" value="<?= esc((string) $selectedIssueId) ?>"/>
<input type="hidden" name="sel_bag_code" value="<?= esc($selectedBagCode) ?>"/>
<div class="grid grid-cols-1 xl:grid-cols-4 gap-2">
<section class="border border-gray-300 bg-white xl:col-span-1">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">불출 리스트</div>
<div class="overflow-auto max-h-[560px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-20">불출일자</th>
<th>불출처</th>
<th class="w-16">건수</th>
</tr>
</thead>
<tbody>
<?php if (($issueGroups ?? []) !== []): ?>
<?php foreach (($issueGroups ?? []) as $row): ?>
<?php
$date = (string) ($row['bi2_issue_date'] ?? '');
$dest = (string) ($row['bi2_dest_name'] ?? '');
$isSelected = ($date === $selectedGroupDate && $dest === $selectedGroupDest);
$url = base_url('bag/issue/cancel?' . http_build_query([
'issue_month' => $issueMonth,
'dest_name' => $destName,
'issue_type' => $issueType,
'bag_code' => $bagCode,
'sel_date' => $date,
'sel_dest' => $dest,
]));
?>
<tr
class="<?= $isSelected ? 'bg-blue-100 font-semibold' : '' ?> cursor-pointer hover:bg-blue-50"
onclick="window.location.href='<?= esc($url, 'attr') ?>'"
>
<td class="text-center <?= $isSelected ? 'border-l-4 border-blue-600' : '' ?>">
<?= esc($date) ?>
</td>
<td class="pl-2"><?= esc($dest) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($row['row_count'] ?? 0)) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="3" class="text-center text-gray-400 py-4">조회 결과가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<div class="xl:col-span-3 grid grid-cols-1 gap-2">
<section class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">불출 품목 내역</div>
<div class="overflow-auto max-h-[270px]">
<table class="w-full data-table text-sm" id="detail-table">
<thead>
<tr>
<th class="w-14">취소</th>
<th class="w-12">No</th>
<th class="w-24">일자</th>
<th class="w-20">구분</th>
<th>봉투종류</th>
<th class="w-24">수량</th>
<th class="w-24">취소수량</th>
</tr>
</thead>
<tbody>
<?php
$detailTotal = 0;
$detailCancelTotal = 0;
?>
<?php if (($detailRows ?? []) !== []): ?>
<?php foreach (($detailRows ?? []) as $idx => $row): ?>
<?php
$qty = (int) ($row['base_qty'] ?? 0);
$cancelQty = (int) ($row['cancel_qty'] ?? 0);
$detailTotal += $qty;
$detailCancelTotal += $cancelQty;
$isChecked = $cancelQty >= $qty && $qty > 0;
$rowBagCode = (string) ($row['bi2_bag_code'] ?? '');
$selectedLink = base_url('bag/issue/cancel?' . http_build_query([
'issue_month' => $issueMonth,
'dest_name' => $destName,
'issue_type' => $issueType,
'bag_code' => $bagCode,
'sel_date' => $selectedGroupDate,
'sel_dest' => $selectedGroupDest,
'sel_issue_id' => $selectedIssueId,
'sel_bag_code' => $rowBagCode,
]));
?>
<tr data-bag-code="<?= esc($rowBagCode, 'attr') ?>" class="<?= $selectedBagCode === $rowBagCode ? 'bg-blue-50' : '' ?>">
<td class="text-center">
<input type="checkbox" class="detail-check" value="1" <?= $isChecked ? 'checked' : '' ?>/>
</td>
<td class="text-center"><?= $idx + 1 ?></td>
<td class="text-center"><?= esc((string) ($row['bi2_issue_date'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['bi2_issue_type'] ?? '')) ?></td>
<td class="pl-2">
<a href="<?= esc($selectedLink) ?>" class="text-blue-700 hover:underline">
<?= esc((string) ($row['bi2_bag_name'] ?? '')) ?> (<?= esc((string) ($row['bi2_bag_code'] ?? '')) ?>)
</a>
</td>
<td class="text-right pr-2 detail-qty"><?= number_format($qty) ?></td>
<td class="text-right pr-2">
<input type="number" min="0" max="<?= esc((string) $qty) ?>" class="detail-cancel-input border border-gray-300 rounded px-1 py-0.5 w-24 text-right"
data-max="<?= esc((string) $qty) ?>"
data-bag-code="<?= esc($rowBagCode, 'attr') ?>"
value="<?= esc((string) $cancelQty) ?>"/>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="7" class="text-center text-gray-400 py-4">불출 품목 내역이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr class="bg-gray-50 font-semibold">
<td colspan="5" class="text-center">합계</td>
<td class="text-right pr-2" id="detail-total-qty"><?= number_format($detailTotal) ?></td>
<td class="text-right pr-2" id="detail-total-cancel"><?= number_format($detailCancelTotal) ?></td>
</tr>
</tfoot>
</table>
</div>
</section>
<section class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">불출 품목 코드</div>
<div class="overflow-auto max-h-[250px]">
<table class="w-full data-table text-sm" id="code-table">
<thead>
<tr>
<th class="w-14">취소</th>
<th class="w-12">No</th>
<th class="w-20">불출번호</th>
<th>봉투코드</th>
<th class="w-24">수량</th>
<th class="w-24">취소수량</th>
</tr>
</thead>
<tbody>
<?php
$codeTotal = 0;
$codeCancelTotal = 0;
?>
<?php if (($codeRows ?? []) !== []): ?>
<?php foreach (($codeRows ?? []) as $idx => $row): ?>
<?php
$bicIdx = (int) ($row['bic_idx'] ?? 0);
$qty = (int) ($row['bic_qty'] ?? 0);
$cancelQty = (int) ($row['bic_cancel_qty'] ?? 0);
$codeTotal += $qty;
$codeCancelTotal += $cancelQty;
$isChecked = $cancelQty >= $qty && $qty > 0;
?>
<tr>
<td class="text-center">
<input type="checkbox" class="code-check"
<?= $bicIdx > 0 ? 'name="code_cancel_check[' . esc((string) $bicIdx, 'attr') . ']"' : '' ?>
value="1" <?= $isChecked ? 'checked' : '' ?>/>
</td>
<td class="text-center"><?= $idx + 1 ?></td>
<td class="text-center"><?= esc((string) ($row['bic_bi2_idx'] ?? '')) ?></td>
<td class="pl-2"><?= esc((string) ($row['bic_issue_code'] ?? '')) ?></td>
<td class="text-right pr-2 code-qty"><?= number_format($qty) ?></td>
<td class="text-right pr-2">
<input type="number" min="0" max="<?= esc((string) $qty) ?>" class="code-cancel-input border border-gray-300 rounded px-1 py-0.5 w-24 text-right"
data-max="<?= esc((string) $qty) ?>"
data-bag-code="<?= esc($selectedBagCode, 'attr') ?>"
<?= $bicIdx > 0 ? 'name="code_cancel_qty[' . esc((string) $bicIdx, 'attr') . ']"' : '' ?>
value="<?= esc((string) $cancelQty) ?>"/>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="6" class="text-center text-gray-400 py-4">불출 품목 코드 내역이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr class="bg-gray-50 font-semibold">
<td colspan="4" class="text-center">합계</td>
<td class="text-right pr-2" id="code-total-qty"><?= number_format($codeTotal) ?></td>
<td class="text-right pr-2" id="code-total-cancel"><?= number_format($codeCancelTotal) ?></td>
</tr>
</tfoot>
</table>
</div>
</section>
</div>
</div>
<div class="mt-2 flex justify-end gap-2">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90">저장</button>
</div>
</form>
</div>
<script>
(() => {
const numberFormat = new Intl.NumberFormat('ko-KR');
const detailInputs = Array.from(document.querySelectorAll('.detail-cancel-input'));
const detailChecks = Array.from(document.querySelectorAll('.detail-check'));
const codeInputs = Array.from(document.querySelectorAll('.code-cancel-input'));
const codeChecks = Array.from(document.querySelectorAll('.code-check'));
const clamp = (value, max) => {
const n = parseInt(String(value || '0'), 10) || 0;
return Math.max(0, Math.min(max, n));
};
const syncDetailTotals = () => {
let qtySum = 0;
let cancelSum = 0;
document.querySelectorAll('#detail-table tbody tr').forEach((tr) => {
const qtyCell = tr.querySelector('.detail-qty');
const input = tr.querySelector('.detail-cancel-input');
if (!qtyCell || !input) return;
const qty = parseInt(qtyCell.textContent.replace(/,/g, ''), 10) || 0;
const cancel = clamp(input.value, parseInt(input.dataset.max || '0', 10) || 0);
qtySum += qty;
cancelSum += cancel;
});
const totalQty = document.getElementById('detail-total-qty');
const totalCancel = document.getElementById('detail-total-cancel');
if (totalQty) totalQty.textContent = numberFormat.format(qtySum);
if (totalCancel) totalCancel.textContent = numberFormat.format(cancelSum);
};
const syncCodeTotals = () => {
let qtySum = 0;
let cancelSum = 0;
document.querySelectorAll('#code-table tbody tr').forEach((tr) => {
const qtyCell = tr.querySelector('.code-qty');
if (!qtyCell) return;
const qty = parseInt(qtyCell.textContent.replace(/,/g, ''), 10) || 0;
qtySum += qty;
const input = tr.querySelector('.code-cancel-input');
if (input) {
cancelSum += clamp(input.value, parseInt(input.dataset.max || '0', 10) || 0);
}
});
const totalQty = document.getElementById('code-total-qty');
const totalCancel = document.getElementById('code-total-cancel');
if (totalQty) totalQty.textContent = numberFormat.format(qtySum);
if (totalCancel) totalCancel.textContent = numberFormat.format(cancelSum);
};
const syncSelectedBagCancelFromCodes = () => {
const codeInput = codeInputs[0];
if (!codeInput) return;
const bagCode = codeInput.dataset.bagCode || '';
if (bagCode === '') return;
let cancelSum = 0;
codeInputs.forEach((input) => {
const max = parseInt(input.dataset.max || '0', 10) || 0;
const value = clamp(input.value, max);
input.value = String(value);
cancelSum += value;
});
const detailInput = document.querySelector(`.detail-cancel-input[data-bag-code="${bagCode}"]`);
if (detailInput) {
const max = parseInt(detailInput.dataset.max || '0', 10) || 0;
detailInput.value = String(clamp(cancelSum, max));
const check = detailInput.closest('tr')?.querySelector('.detail-check');
if (check) {
check.checked = (clamp(cancelSum, max) >= max && max > 0);
}
}
};
detailInputs.forEach((input) => {
input.addEventListener('input', () => {
const max = parseInt(input.dataset.max || '0', 10) || 0;
input.value = String(clamp(input.value, max));
syncDetailTotals();
});
});
detailChecks.forEach((check) => {
check.addEventListener('change', () => {
const tr = check.closest('tr');
const input = tr?.querySelector('.detail-cancel-input');
if (!input) return;
const max = parseInt(input.dataset.max || '0', 10) || 0;
input.value = check.checked ? String(max) : '0';
const bagCode = input.dataset.bagCode || '';
if (bagCode !== '') {
codeInputs.forEach((codeInput) => {
if ((codeInput.dataset.bagCode || '') !== bagCode) return;
const codeMax = parseInt(codeInput.dataset.max || '0', 10) || 0;
codeInput.value = check.checked ? String(codeMax) : '0';
const codeCheck = codeInput.closest('tr')?.querySelector('.code-check');
if (codeCheck) {
codeCheck.checked = check.checked;
}
});
}
syncCodeTotals();
syncSelectedBagCancelFromCodes();
syncDetailTotals();
});
});
codeInputs.forEach((input) => {
input.addEventListener('input', () => {
const max = parseInt(input.dataset.max || '0', 10) || 0;
input.value = String(clamp(input.value, max));
const check = input.closest('tr')?.querySelector('.code-check');
if (check) {
check.checked = clamp(input.value, max) >= max && max > 0;
}
syncCodeTotals();
syncSelectedBagCancelFromCodes();
syncDetailTotals();
});
});
codeChecks.forEach((check) => {
check.addEventListener('change', () => {
const input = check.closest('tr')?.querySelector('.code-cancel-input');
if (!input) return;
const max = parseInt(input.dataset.max || '0', 10) || 0;
input.value = check.checked ? String(max) : '0';
syncCodeTotals();
syncSelectedBagCancelFromCodes();
syncDetailTotals();
});
});
syncCodeTotals();
syncSelectedBagCancelFromCodes();
syncDetailTotals();
})();
</script>

View File

@@ -42,6 +42,12 @@ tailwind.config = {
}
}
</script>
<style data-purpose="global-font-scale">
/* 전체 텍스트 +2px 확대 (요청). rem 기반 텍스트는 비례 확대된다.
다만 헤더 로고(.app-brand)는 원래 크기 유지하기 위해 root 기준 16px 로 reset. */
html { font-size: 18px; }
.app-brand, .app-brand * { font-size: 16px; }
</style>
<style data-purpose="table-layout">
.data-table { width: 100%; border-collapse: collapse; font-family: 'Malgun Gothic', 'Noto Sans KR', sans-serif; }
.data-table th, .data-table td { border: 1px solid #ccc; padding: 4px 8px; white-space: nowrap; font-size: 13px; }
@@ -60,7 +66,7 @@ body { overflow: hidden; }
}
</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col h-screen font-sans antialiased select-none">
<body class="bg-gray-100 text-gray-800 flex flex-col h-screen font-sans antialiased">
<!-- BEGIN: Top Navigation -->
<header class="relative bg-white border-b border-gray-300 h-12 flex items-center justify-between px-4 shrink-0 z-[100]">
<div class="flex items-center gap-4">
@@ -136,5 +142,44 @@ body { overflow: hidden; }
<span>종량제 시스템</span>
<span><?= date('Y.m.d (D) g:i:sA') ?></span>
</footer>
<script>
(() => {
const normalize = (s) => String(s || '').replace(/\s+/g, '').trim();
const renumberTable = (table) => {
const headRow = table.querySelector('thead tr');
if (!headRow) return;
const headers = Array.from(headRow.querySelectorAll('th'));
const numberCol = headers.findIndex((th) => normalize(th.textContent) === '번호');
if (numberCol < 0) return;
const body = table.querySelector('tbody');
if (!body) return;
const rows = Array.from(body.querySelectorAll(':scope > tr')).filter((tr) => {
const cells = tr.querySelectorAll('td');
if (cells.length === 0) return false;
if (cells.length === 1 && Number(cells[0].getAttribute('colspan') || '1') > 1) return false;
return true;
});
let no = rows.length;
rows.forEach((tr) => {
const cells = tr.querySelectorAll('td');
if (cells[numberCol]) {
cells[numberCol].textContent = String(no--);
}
});
};
const run = () => {
document.querySelectorAll('table').forEach(renumberTable);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run, { once: true });
} else {
run();
}
})();
</script>
</body>
</html>

View File

@@ -15,6 +15,7 @@ $mbName = session()->get('mb_name') ?? '담당자';
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>종량제 시스템 — 업무 현황</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<style>
body {

View File

@@ -20,6 +20,7 @@ $dashBlend = base_url('dashboard/blend');
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>종량제 시스템 — 통계·그래프 현황</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>

View File

@@ -0,0 +1,212 @@
<?php
/**
* 로그인 후 메인 — 컴팩트 대시보드(중간 밀도)
* /dashboard 대비 요소 수를 줄이고, /dashboard/simple 대비 정보량은 늘린 버전.
*
* @var string $lgLabel
*/
$lgLabel = $lgLabel ?? '북구';
$mbName = session()->get('mb_name') ?? '담당자';
$kpi = [
['label' => '미처리 구매신청', 'value' => '12건', 'hint' => '전일 대비 +2'],
['label' => '재고 부족 품목', 'value' => '3종', 'hint' => '안전재고 미달'],
['label' => '금주 입고 완료', 'value' => '8건', 'hint' => '예정 2건'],
['label' => '승인 대기', 'value' => '4명', 'hint' => '판매소/회원'],
];
$weeklyRequest = [8, 11, 9, 14, 10, 12, 7];
$monthlyOut = [320, 340, 330, 355, 372, 361, 388, 396];
$inventoryRows = [
['일반 5L', '12,400', '안전'],
['일반 10L', '8,200', '주의'],
['일반 20L', '2,100', '부족'],
['음식물 스티커', '15,000', '안전'],
['특수규격 A', '890', '부족'],
];
$requestRows = [
['2026-05-07 10:32', '행복마트 북구점', '일반 5L 2,000장', '접수'],
['2026-05-07 09:40', 'OO슈퍼', '음식물 스티커 500매', '처리중'],
['2026-05-07 08:55', 'XX상회', '일반 20L 1,000장', '발주확인'],
['2026-05-06 17:21', 'YY마트', '재사용봉투 800장', '완료'],
];
?>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<div class="bg-[#f5f7fb] -mx-4 -my-4 p-3 min-h-full">
<section class="bg-white border border-gray-200 rounded-lg shadow-sm px-4 py-3 mb-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 class="text-sm font-semibold text-gray-900">업무 현황 컴팩트 뷰</h2>
<p class="text-[11px] text-gray-500 mt-0.5">
<?= esc($lgLabel) ?> · <?= esc($mbName) ?>님 기준 · 핵심 지표와 추이만 표시
</p>
</div>
<div class="text-[11px] text-gray-500">
기준 시각 <?= date('Y-m-d H:i') ?>
</div>
</div>
</section>
<section class="grid grid-cols-2 lg:grid-cols-4 gap-2 mb-3">
<?php foreach ($kpi as $card): ?>
<article class="bg-white border border-gray-200 rounded-lg shadow-sm p-3">
<p class="text-[11px] text-gray-500"><?= esc($card['label']) ?></p>
<p class="text-xl font-bold text-gray-900 mt-1"><?= esc($card['value']) ?></p>
<p class="text-[10px] text-gray-400 mt-1"><?= esc($card['hint']) ?></p>
</article>
<?php endforeach; ?>
</section>
<section class="grid grid-cols-1 xl:grid-cols-2 gap-3 mb-3">
<article class="bg-white border border-gray-200 rounded-lg shadow-sm p-3">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-semibold text-gray-800">최근 7일 구매신청 추이</h3>
<span class="text-[10px] text-gray-400">건수</span>
</div>
<div class="h-48">
<canvas id="compactRequestChart"></canvas>
</div>
</article>
<article class="bg-white border border-gray-200 rounded-lg shadow-sm p-3">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-semibold text-gray-800">월별 출고량 추이</h3>
<span class="text-[10px] text-gray-400">천 장</span>
</div>
<div class="h-48">
<canvas id="compactOutChart"></canvas>
</div>
</article>
</section>
<section class="grid grid-cols-1 xl:grid-cols-2 gap-3">
<article class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<div class="px-3 py-2 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
<h3 class="text-xs font-semibold text-gray-800">재고 상태 요약</h3>
<a href="<?= base_url('bag/inventory') ?>" class="text-[10px] text-blue-600 hover:underline">상세</a>
</div>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead class="bg-gray-100 text-gray-600">
<tr>
<th class="text-left px-3 py-2">품목</th>
<th class="text-right px-3 py-2">재고(장)</th>
<th class="text-center px-3 py-2">상태</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<?php foreach ($inventoryRows as $row): ?>
<tr>
<td class="px-3 py-2"><?= esc($row[0]) ?></td>
<td class="px-3 py-2 text-right"><?= esc($row[1]) ?></td>
<td class="px-3 py-2 text-center">
<?php $stateClass = $row[2] === '부족' ? 'text-red-600' : ($row[2] === '주의' ? 'text-amber-600' : 'text-emerald-600'); ?>
<span class="<?= $stateClass ?> font-medium"><?= esc($row[2]) ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</article>
<article class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<div class="px-3 py-2 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
<h3 class="text-xs font-semibold text-gray-800">최근 구매신청 처리 현황</h3>
<a href="<?= base_url('bag/order/create') ?>" class="text-[10px] text-blue-600 hover:underline">등록</a>
</div>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead class="bg-gray-100 text-gray-600">
<tr>
<th class="text-left px-3 py-2">시각</th>
<th class="text-left px-3 py-2">판매소</th>
<th class="text-left px-3 py-2">신청 내용</th>
<th class="text-center px-3 py-2">상태</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<?php foreach ($requestRows as $row): ?>
<tr>
<td class="px-3 py-2 whitespace-nowrap"><?= esc($row[0]) ?></td>
<td class="px-3 py-2"><?= esc($row[1]) ?></td>
<td class="px-3 py-2"><?= esc($row[2]) ?></td>
<td class="px-3 py-2 text-center">
<span class="inline-flex px-2 py-0.5 rounded text-[10px] bg-slate-100 text-slate-700"><?= esc($row[3]) ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</article>
</section>
</div>
<script>
(() => {
const style = {
blue: '#2563eb',
blueLight: 'rgba(37, 99, 235, 0.15)',
teal: '#0f766e',
grid: 'rgba(0, 0, 0, 0.06)',
};
Chart.defaults.font.family = "'Malgun Gothic','Apple SD Gothic Neo','Noto Sans KR',sans-serif";
Chart.defaults.font.size = 11;
Chart.defaults.color = '#4b5563';
new Chart(document.getElementById('compactRequestChart'), {
type: 'bar',
data: {
labels: ['D-6', 'D-5', 'D-4', 'D-3', 'D-2', 'D-1', '오늘'],
datasets: [{
data: <?= json_encode($weeklyRequest, JSON_UNESCAPED_UNICODE) ?>,
backgroundColor: style.blue,
borderRadius: 4,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false } },
y: { beginAtZero: true, grid: { color: style.grid } },
},
},
});
new Chart(document.getElementById('compactOutChart'), {
type: 'line',
data: {
labels: ['10월', '11월', '12월', '1월', '2월', '3월', '4월', '5월'],
datasets: [{
label: '출고',
data: <?= json_encode($monthlyOut, JSON_UNESCAPED_UNICODE) ?>,
borderColor: style.teal,
backgroundColor: style.blueLight,
fill: true,
tension: 0.35,
pointRadius: 2.5,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { boxWidth: 10, padding: 8 },
},
},
scales: {
x: { grid: { display: false } },
y: { beginAtZero: false, grid: { color: style.grid } },
},
},
});
})();
</script>

View File

@@ -75,6 +75,7 @@ $notices = [
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>종량제 시스템 — 종합 현황 (정보집약)</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<style>
body {

View File

@@ -20,6 +20,7 @@ $dashBlend = base_url('dashboard/blend');
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>종량제 시스템 — 업무 현황 (모던)</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<style>

View File

@@ -0,0 +1,201 @@
<?php
/**
* 로그인 후 메인 — 단순형 요약 대시보드
* 복잡한 표/그래프 대신, 핵심 지표 몇 개와 주요 화면으로 가는 버튼만 노출.
*
* @var string $lgLabel
*/
$lgLabel = $lgLabel ?? '북구';
$mbName = session()->get('mb_name') ?? '담당자';
$weeklyRequests = [7, 12, 9, 14, 8, 11, 10];
$stockMix = [
['name' => '일반용', 'value' => 52, 'color' => '#3b82f6'],
['name' => '음식물', 'value' => 28, 'color' => '#10b981'],
['name' => '특수', 'value' => 20, 'color' => '#f59e0b'],
];
$lowStock = [
['name' => '일반 20L', 'percent' => 34],
['name' => '특수규격 A', 'percent' => 22],
['name' => '재사용봉투', 'percent' => 58],
];
?>
<div class="h-full flex flex-col gap-4">
<!-- 상단 요약 헤더 -->
<section class="bg-white border border-gray-200 rounded-lg shadow-sm px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-base font-semibold text-gray-900">
<span class="inline-flex items-center gap-2">
<span class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-50 text-blue-700 text-sm font-bold">i</span>
<span>업무 현황 요약</span>
</span>
</h1>
<p class="mt-1 text-xs text-gray-500">
<?= esc($lgLabel) ?> · <strong class="text-gray-700"><?= esc($mbName) ?></strong>님 기준으로 자주 보는 정보만 간단히 모아서 보여줍니다.
</p>
</div>
<div class="flex flex-col sm:flex-row gap-2 text-xs text-gray-600 items-start sm:items-end">
<div>
<span class="inline-block text-[11px] text-gray-500 mb-1">기준일</span>
<div class="px-2 py-1 rounded border border-gray-200 bg-gray-50 text-[11px]">
<i class="fa-regular fa-calendar mr-1 text-gray-500"></i><?= date('Y.m.d (D)') ?>
</div>
</div>
<button type="button" class="inline-flex items-center justify-center px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium shadow">
<i class="fa-solid fa-rotate mr-1"></i> 새로고침
</button>
</div>
</section>
<!-- 핵심 숫자 3개만 -->
<section class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="bg-white border border-gray-200 rounded-lg shadow-sm px-4 py-3 flex flex-col justify-between">
<p class="text-xs text-gray-500 mb-1"><i class="fa-solid fa-boxes-stacked text-emerald-600 mr-1"></i>봉투 재고 상태</p>
<p class="text-2xl font-bold text-gray-900">양호</p>
<p class="mt-1 text-[11px] text-gray-500">대부분 품목이 안전재고 이상입니다.</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm px-4 py-3 flex flex-col justify-between">
<p class="text-xs text-gray-500 mb-1"><i class="fa-solid fa-inbox text-sky-600 mr-1"></i>미처리 구매신청</p>
<p class="text-2xl font-bold text-sky-700">12건</p>
<p class="mt-1 text-[11px] text-gray-500">오늘 들어온 신청까지 포함한 개수입니다.</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm px-4 py-3 flex flex-col justify-between">
<p class="text-xs text-gray-500 mb-1"><i class="fa-solid fa-user-check text-violet-600 mr-1"></i>승인 대기</p>
<p class="text-2xl font-bold text-violet-700">4명</p>
<p class="mt-1 text-[11px] text-gray-500">회원·판매소 가입 승인 요청입니다.</p>
</div>
</section>
<!-- 작은 그래프 영역 -->
<section class="grid grid-cols-1 lg:grid-cols-3 gap-3">
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-4">
<h2 class="text-sm font-semibold text-gray-900 mb-2">최근 7일 신청 추이</h2>
<div class="h-24 flex items-end gap-1.5">
<?php $maxReq = max($weeklyRequests); ?>
<?php foreach ($weeklyRequests as $idx => $v): ?>
<?php $h = (int) round(($v / $maxReq) * 100); ?>
<div class="flex-1 flex flex-col items-center justify-end gap-1">
<span class="text-[10px] text-gray-500"><?= esc((string) $v) ?></span>
<div class="w-full rounded-t bg-gradient-to-t from-blue-700 to-blue-400" style="height: <?= $h ?>%"></div>
<span class="text-[10px] text-gray-400">D<?= 6 - $idx ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-4">
<h2 class="text-sm font-semibold text-gray-900 mb-2">재고 구성 비율</h2>
<div class="flex items-center gap-4">
<div class="w-20 h-20 rounded-full" style="background: conic-gradient(#3b82f6 0% 52%, #10b981 52% 80%, #f59e0b 80% 100%);">
<div class="w-12 h-12 bg-white rounded-full m-auto mt-4"></div>
</div>
<ul class="text-[11px] text-gray-600 space-y-1">
<?php foreach ($stockMix as $item): ?>
<li class="flex items-center gap-2">
<span class="inline-block w-2.5 h-2.5 rounded-full" style="background-color: <?= esc($item['color'], 'attr') ?>"></span>
<span><?= esc($item['name']) ?> <?= esc((string) $item['value']) ?>%</span>
</li>
<?php endforeach; ?>
</ul>
</div>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-4">
<h2 class="text-sm font-semibold text-gray-900 mb-2">부족 재고 품목</h2>
<div class="space-y-2">
<?php foreach ($lowStock as $item): ?>
<div>
<div class="flex justify-between text-[11px] text-gray-600 mb-1">
<span><?= esc($item['name']) ?></span>
<span><?= esc((string) $item['percent']) ?>%</span>
</div>
<div class="h-2 rounded bg-gray-100 overflow-hidden">
<div class="h-full rounded bg-amber-500" style="width: <?= (int) $item['percent'] ?>%"></div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</section>
<!-- 자주 가는 화면 바로가기 -->
<section class="bg-white border border-gray-200 rounded-lg shadow-sm p-4">
<h2 class="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-2">
<i class="fa-solid fa-location-arrow text-blue-600"></i>
자주 가는 화면
</h2>
<p class="text-[11px] text-gray-500 mb-3">
왼쪽 메뉴를 모두 펼치지 않고도, 자주 사용하는 업무 화면으로 바로 이동할 수 있습니다.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
<a href="<?= base_url('bag/inventory') ?>" class="group flex items-center gap-3 px-3 py-2 rounded border border-gray-200 hover:border-blue-500 hover:bg-blue-50/40 transition">
<div class="h-8 w-8 rounded-full bg-emerald-50 text-emerald-700 flex items-center justify-center">
<i class="fa-solid fa-boxes-stacked"></i>
</div>
<div class="min-w-0">
<p class="text-xs font-semibold text-gray-900 group-hover:text-blue-800">창고 재고 조회</p>
<p class="text-[11px] text-gray-500 truncate">품목별 현재 재고를 간단히 확인</p>
</div>
</a>
<a href="<?= base_url('bag/order/create') ?>" class="group flex items-center gap-3 px-3 py-2 rounded border border-gray-200 hover:border-blue-500 hover:bg-blue-50/40 transition">
<div class="h-8 w-8 rounded-full bg-sky-50 text-sky-700 flex items-center justify-center">
<i class="fa-solid fa-cart-shopping"></i>
</div>
<div class="min-w-0">
<p class="text-xs font-semibold text-gray-900 group-hover:text-blue-800">발주(구매신청) 등록</p>
<p class="text-[11px] text-gray-500 truncate">지정판매소 발주·구매신청 입력</p>
</div>
</a>
<a href="<?= base_url('bag/flow') ?>" class="group flex items-center gap-3 px-3 py-2 rounded border border-gray-200 hover:border-blue-500 hover:bg-blue-50/40 transition">
<div class="h-8 w-8 rounded-full bg-orange-50 text-orange-600 flex items-center justify-center">
<i class="fa-solid fa-arrow-right-arrow-left"></i>
</div>
<div class="min-w-0">
<p class="text-xs font-semibold text-gray-900 group-hover:text-blue-800">수불 흐름 보기</p>
<p class="text-[11px] text-gray-500 truncate">입고·출고 내역을 한눈에</p>
</div>
</a>
<a href="<?= base_url('bag/sales') ?>" class="group flex items-center gap-3 px-3 py-2 rounded border border-gray-200 hover:border-blue-500 hover:bg-blue-50/40 transition">
<div class="h-8 w-8 rounded-full bg-indigo-50 text-indigo-600 flex items-center justify-center">
<i class="fa-solid fa-receipt"></i>
</div>
<div class="min-w-0">
<p class="text-xs font-semibold text-gray-900 group-hover:text-blue-800">판매 내역 조회</p>
<p class="text-[11px] text-gray-500 truncate">기간별 봉투 판매 현황</p>
</div>
</a>
<a href="<?= base_url('bag/help') ?>" class="group flex items-center gap-3 px-3 py-2 rounded border border-gray-200 hover:border-blue-500 hover:bg-blue-50/40 transition">
<div class="h-8 w-8 rounded-full bg-gray-50 text-gray-600 flex items-center justify-center">
<i class="fa-solid fa-circle-question"></i>
</div>
<div class="min-w-0">
<p class="text-xs font-semibold text-gray-900 group-hover:text-blue-800">도움말 / 매뉴얼</p>
<p class="text-[11px] text-gray-500 truncate">업무별 사용 방법 안내</p>
</div>
</a>
<a href="<?= base_url('dashboard') ?>" class="group flex items-center gap-3 px-3 py-2 rounded border border-dashed border-gray-300 hover:border-blue-500 hover:bg-blue-50/40 transition">
<div class="h-8 w-8 rounded-full bg-white text-gray-500 flex items-center justify-center">
<i class="fa-solid fa-table-columns"></i>
</div>
<div class="min-w-0">
<p class="text-xs font-semibold text-gray-900 group-hover:text-blue-800">자세히 보기 (/dashboard)</p>
<p class="text-[11px] text-gray-500 truncate">그래프·표가 많은 기존 화면으로 이동</p>
</div>
</a>
</div>
</section>
<!-- 알림/메모 영역 (간단 텍스트만) -->
<section class="bg-white border border-gray-200 rounded-lg shadow-sm p-4">
<h2 class="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-2">
<i class="fa-solid fa-clipboard-list text-gray-600"></i>
오늘 확인하면 좋은 것들
</h2>
<ul class="list-disc list-inside text-[11px] text-gray-600 space-y-1.5">
<li>재고 부족 품목이 있는지 간단히 확인하고, 필요 시 발주를 등록합니다.</li>
<li>미처리 구매신청과 승인 대기 건을 하루 한 번 이상 처리합니다.</li>
<li>더 자세한 분석·그래프는 <span class="text-blue-700 font-semibold">/dashboard</span> 화면에서 확인할 수 있습니다.</li>
</ul>
</section>
</div>

View File

@@ -0,0 +1,130 @@
<?php
$orders = is_array($orders ?? null) ? $orders : [];
$companyMap = is_array($companyMap ?? null) ? $companyMap : [];
$itemSummary = is_array($itemSummary ?? null) ? $itemSummary : [];
$companies = is_array($companies ?? null) ? $companies : [];
$startMonthValue = (string) ($startMonth ?? date('Y-m'));
$endMonthValue = (string) ($endMonth ?? date('Y-m'));
$baseYear = (int) date('Y');
if (preg_match('/^(\d{4})-\d{2}$/', $startMonthValue, $sm)) {
$baseYear = (int) $sm[1];
} elseif (preg_match('/^(\d{4})-\d{2}$/', $endMonthValue, $em)) {
$baseYear = (int) $em[1];
}
$monthOptionValues = [];
for ($year = $baseYear - 2; $year <= $baseYear + 2; $year++) {
for ($m = 1; $m <= 12; $m++) {
$monthValue = sprintf('%04d-%02d', $year, $m);
$monthOptionValues[] = ['value' => $monthValue, 'label' => $year . '년 ' . $m . '월'];
}
}
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">LOT-No 디스켓 불출</span>
</section>
<section class="border border-gray-300 p-2 mt-2 bg-white">
<form method="get" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-6 gap-2 text-sm">
<div class="flex items-center gap-2 min-w-0">
<label class="font-bold text-gray-700 whitespace-nowrap shrink-0">시작월</label>
<select id="start_month" name="start_month" class="border border-gray-300 rounded px-2 py-1 w-full min-w-0">
<?php foreach ($monthOptionValues as $opt): ?>
<option value="<?= esc($opt['value']) ?>" <?= $opt['value'] === $startMonthValue ? 'selected' : '' ?>><?= esc($opt['label']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2 min-w-0">
<label class="font-bold text-gray-700 whitespace-nowrap shrink-0">종료월</label>
<select id="end_month" name="end_month" class="border border-gray-300 rounded px-2 py-1 w-full min-w-0">
<?php foreach ($monthOptionValues as $opt): ?>
<option value="<?= esc($opt['value']) ?>" <?= $opt['value'] === $endMonthValue ? 'selected' : '' ?>><?= esc($opt['label']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2 min-w-0 xl:col-span-2">
<label class="font-bold text-gray-700 whitespace-nowrap shrink-0">LOT-No</label>
<input type="text" name="lot_no" value="<?= esc((string) ($lotNo ?? '')) ?>" placeholder="예: ZLCH2M" class="border border-gray-300 rounded px-2 py-1 w-full min-w-0" />
</div>
<div class="flex items-center gap-2 min-w-0">
<label class="font-bold text-gray-700 whitespace-nowrap shrink-0">제작업체</label>
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1 w-full min-w-0">
<option value="0">전체</option>
<?php foreach ($companies as $company): ?>
<?php $cpIdx = (int) ($company->cp_idx ?? 0); ?>
<option value="<?= $cpIdx ?>" <?= (int) ($companyIdx ?? 0) === $cpIdx ? 'selected' : '' ?>>
<?= esc((string) ($company->cp_name ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2">
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">조회</button>
<a href="<?= base_url('bag/order/lot-seed') ?>" class="text-sm text-gray-500 hover:text-gray-700">초기화</a>
</div>
</form>
</section>
<section class="border border-gray-300 mt-2 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주목록</div>
<div class="overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-20">작업</th>
<th class="w-24">발주일</th>
<th class="w-28">LOT-No</th>
<th>제작업체</th>
<th class="w-24">품목수</th>
<th class="w-24">박스합계</th>
<th class="w-28">낱장합계</th>
<th class="w-20">상태</th>
</tr>
</thead>
<tbody>
<?php if ($orders !== []): ?>
<?php foreach ($orders as $order): ?>
<?php
$boIdx = (int) ($order->bo_idx ?? 0);
$sum = $itemSummary[$boIdx] ?? ['line_count' => 0, 'qty_box' => 0, 'qty_sheet' => 0];
$companyName = (string) ($companyMap[(int) ($order->bo_company_idx ?? 0)] ?? '-');
$status = (string) ($order->bo_status ?? 'normal');
?>
<tr>
<td class="text-center">
<form method="post" action="<?= base_url('bag/order/lot-seed/generate') ?>" class="inline">
<?= csrf_field() ?>
<input type="hidden" name="bo_idx" value="<?= $boIdx ?>" />
<button type="submit" class="text-blue-600 hover:underline text-xs">seed 생성</button>
</form>
</td>
<td class="text-center"><?= esc((string) ($order->bo_order_date ?? '')) ?></td>
<td class="text-center font-mono"><?= esc((string) ($order->bo_lot_no ?? '')) ?></td>
<td class="pl-2"><?= esc($companyName) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($sum['line_count'] ?? 0)) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($sum['qty_box'] ?? 0)) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($sum['qty_sheet'] ?? 0)) ?></td>
<td class="text-center">
<?php if ($status === 'cancelled'): ?>
<span class="text-orange-600">취소</span>
<?php elseif ($status === 'deleted'): ?>
<span class="text-red-600">삭제</span>
<?php else: ?>
정상
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="8" class="text-center text-gray-400 py-4">조회된 발주가 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<?php if (isset($pager)): ?>
<div class="mt-2 mb-2 no-print"><?= $pager->links() ?></div>
<?php endif; ?>

View File

@@ -0,0 +1,454 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">전화 주문 접수</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white">
<?php if (session()->getFlashdata('success')): ?>
<div class="mb-3 border border-emerald-300 bg-emerald-50 text-emerald-800 px-3 py-2 rounded-sm text-sm">
<?= esc(session()->getFlashdata('success')) ?>
</div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="mb-3 border border-red-300 bg-red-50 text-red-700 px-3 py-2 rounded-sm text-sm">
<?= esc(session()->getFlashdata('error')) ?>
</div>
<?php endif; ?>
<?php $flashErrors = session()->getFlashdata('errors'); ?>
<?php if (is_array($flashErrors) && $flashErrors !== []): ?>
<div class="mb-3 border border-red-300 bg-red-50 text-red-700 px-3 py-2 rounded-sm text-sm">
<ul class="list-disc list-inside">
<?php foreach ($flashErrors as $err): ?>
<li><?= esc((string) $err) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form action="<?= base_url('bag/shop-order/store') ?>" method="POST" class="space-y-4" id="phone-order-form">
<?= csrf_field() ?>
<input type="hidden" name="return_to" value="bag/order/phone"/>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div class="xl:col-span-2 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">판매소 검색</label>
<div class="relative flex-1 min-w-[20rem]">
<input id="shop-search" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-[34rem] max-w-full" type="text" autocomplete="off" placeholder="코드/사업자번호/대표자명/상호/전화/주소 중 하나 입력"/>
<div id="shop-search-suggest" class="hidden absolute left-0 top-full mt-1 w-[48rem] max-w-[90vw] max-h-72 overflow-auto border border-gray-300 bg-white shadow-lg z-30"></div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">판매소 선택 <span class="text-red-500">*</span></label>
<select id="shop-select" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-[34rem] max-w-full" name="so_ds_idx" required>
<option value="">선택</option>
<?php foreach ($shops as $shop): ?>
<option
value="<?= esc($shop->ds_idx) ?>"
data-shop-no="<?= esc((string) ($shop->ds_shop_no ?? '')) ?>"
data-biz-no="<?= esc((string) ($shop->ds_biz_no ?? '')) ?>"
data-name="<?= esc((string) ($shop->ds_name ?? '')) ?>"
data-rep-name="<?= esc((string) ($shop->ds_rep_name ?? '')) ?>"
data-tel="<?= esc((string) ($shop->ds_tel ?? '')) ?>"
data-rep-phone="<?= esc((string) ($shop->ds_rep_phone ?? '')) ?>"
data-address="<?= esc(trim((string) ($shop->ds_addr ?? '') . ' ' . (string) ($shop->ds_addr_detail ?? ''))) ?>"
data-va-bank="<?= esc((string) ($shop->ds_va_bank ?? '')) ?>"
data-va-account="<?= esc((string) ($shop->ds_va_account ?? '')) ?>"
>
<?= esc(($shop->ds_shop_no ? '[' . $shop->ds_shop_no . '] ' : '') . $shop->ds_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="border border-gray-300 p-2 bg-gray-50">
<div class="text-sm font-bold text-gray-700 mb-2">접수 정보</div>
<table class="w-full text-sm">
<tr><th class="text-left w-28 py-1">접수번호</th><td class="py-1 text-gray-800 font-semibold"><?= esc((string) ($receiptNo ?? 1)) ?></td></tr>
<tr><th class="text-left py-1">접수일</th><td class="py-1 text-gray-700"><?= esc(date('Y-m-d')) ?></td></tr>
<tr><th class="text-left py-1">배달일</th><td class="py-1 text-gray-700"><?= esc(date('Y-m-d', strtotime('+1 day'))) ?> (자동)</td></tr>
<tr><th class="text-left py-1">담당자</th><td class="py-1 text-gray-700"><?= esc((string) (session()->get('mb_name') ?? '담당자')) ?></td></tr>
</table>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div class="border border-gray-300 p-2 bg-gray-50">
<div class="text-sm font-bold text-gray-700 mb-2">지정판매소 정보</div>
<table class="w-full text-sm">
<tr><th class="text-left w-28 py-1">코드</th><td id="shop-info-code" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">사업자번호</th><td id="shop-info-biz" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">대표자명</th><td id="shop-info-rep" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">상호명</th><td id="shop-info-name" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">전화번호</th><td id="shop-info-tel" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">주소</th><td id="shop-info-addr" class="py-1 text-gray-700">-</td></tr>
</table>
</div>
<div class="border border-gray-300 p-2 bg-gray-50">
<div class="text-sm font-bold text-gray-700 mb-2">결제/가상계좌</div>
<div class="flex flex-wrap items-center gap-2 mb-2">
<label class="block text-sm font-bold text-gray-700 w-24">결제구분 <span class="text-red-500">*</span></label>
<select id="payment-type" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-40" name="so_payment_type" required>
<option value="">선택</option>
<option value="이체">이체</option>
<option value="가상계좌">가상계좌</option>
</select>
<span id="payment-guide" class="text-xs text-gray-500"></span>
</div>
<div class="text-sm">
<span class="font-semibold text-gray-700">가상계좌:</span>
<span id="shop-info-va" class="text-gray-700">-</span>
</div>
</div>
</div>
<input type="hidden" name="so_delivery_date" value="<?= esc(date('Y-m-d', strtotime('+1 day'))) ?>"/>
<div class="mt-4">
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-bold text-gray-700">전화 주문접수표</label>
<button type="button" id="add-order-row" class="border border-gray-300 bg-white px-3 py-1 rounded-sm text-xs text-gray-700 hover:bg-gray-50">행 추가</button>
</div>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-14">구분</th>
<th class="w-56">품목</th>
<th class="w-40">1박스(낱장/판매가)</th>
<th class="w-40">1팩(낱장/판매가)</th>
<th class="w-24">단가</th>
<th class="w-28">주문수량</th>
<th class="w-28">금액</th>
<th class="w-44">포장(박스/팩/낱장)</th>
<th class="w-20">삭제</th>
</tr>
</thead>
<tbody id="order-rows">
<?php for ($i = 0; $i < 3; $i++): ?>
<tr class="order-row">
<td class="text-center item-kind-cell">-</td>
<td>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full bag-code-select" name="item_bag_code[]">
<option value="">선택</option>
<?php foreach ($bagCodes as $cd): ?>
<?php
$code = (string) $cd->cd_code;
$name = (string) ($cd->cd_name ?? '');
$price = $priceMap[$code] ?? null;
$unit = $unitMap[$code] ?? null;
$unitPrice = (int) ($price->bp_consumer ?? 0);
$boxSheets = (int) ($unit->pu_total_per_box ?? 0);
$packSheets = (int) ($unit->pu_pack_per_sheet ?? 0);
$kindLabel = (mb_strpos($name, '스티커') !== false) ? '스티커' : '봉투';
?>
<option value="<?= esc($code) ?>" data-name="<?= esc($name, 'attr') ?>" data-kind-label="<?= esc($kindLabel, 'attr') ?>" data-unit-price="<?= esc((string) $unitPrice, 'attr') ?>" data-box-sheets="<?= esc((string) $boxSheets, 'attr') ?>" data-pack-sheets="<?= esc((string) $packSheets, 'attr') ?>">
<?= esc($code) ?> — <?= esc($name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td class="text-right px-2 box-info-cell">0 / 0</td>
<td class="text-right px-2 pack-info-cell">0 / 0</td>
<td class="text-right px-2 unit-price-cell">0</td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input" name="item_qty[]" type="number" min="0" value="0"/></td>
<td class="text-right px-2 item-amount-cell">0</td>
<td class="text-right px-2 pack-result-cell">박스=0, 팩=0, 낱장=0</td>
<td class="text-center px-2">
<button type="button" class="remove-order-row border border-red-300 text-red-600 px-2 py-0.5 rounded text-xs hover:bg-red-50">삭제</button>
</td>
</tr>
<?php endfor; ?>
</tbody>
<tfoot>
<tr class="font-semibold bg-gray-50">
<td colspan="5" class="text-right px-2 py-1">합계</td>
<td class="text-right px-2 py-1" id="sum-qty">0</td>
<td class="text-right px-2 py-1" id="sum-amount">0</td>
<td class="text-right px-2 py-1" id="sum-pack">박스=0, 팩=0, 낱장=0</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">저장</button>
<a href="<?= base_url('bag/sales') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</form>
</div>
<template id="order-row-template">
<tr class="order-row">
<td class="text-center item-kind-cell">-</td>
<td>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full bag-code-select" name="item_bag_code[]">
<option value="">선택</option>
<?php foreach ($bagCodes as $cd): ?>
<?php
$code = (string) $cd->cd_code;
$name = (string) ($cd->cd_name ?? '');
$price = $priceMap[$code] ?? null;
$unit = $unitMap[$code] ?? null;
$unitPrice = (int) ($price->bp_consumer ?? 0);
$boxSheets = (int) ($unit->pu_total_per_box ?? 0);
$packSheets = (int) ($unit->pu_pack_per_sheet ?? 0);
$kindLabel = (mb_strpos($name, '스티커') !== false) ? '스티커' : '봉투';
?>
<option value="<?= esc($code) ?>" data-name="<?= esc($name, 'attr') ?>" data-kind-label="<?= esc($kindLabel, 'attr') ?>" data-unit-price="<?= esc((string) $unitPrice, 'attr') ?>" data-box-sheets="<?= esc((string) $boxSheets, 'attr') ?>" data-pack-sheets="<?= esc((string) $packSheets, 'attr') ?>">
<?= esc($code) ?> — <?= esc($name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td class="text-right px-2 box-info-cell">0 / 0</td>
<td class="text-right px-2 pack-info-cell">0 / 0</td>
<td class="text-right px-2 unit-price-cell">0</td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input" name="item_qty[]" type="number" min="0" value="0"/></td>
<td class="text-right px-2 item-amount-cell">0</td>
<td class="text-right px-2 pack-result-cell">박스=0, 팩=0, 낱장=0</td>
<td class="text-center px-2">
<button type="button" class="remove-order-row border border-red-300 text-red-600 px-2 py-0.5 rounded text-xs hover:bg-red-50">삭제</button>
</td>
</tr>
</template>
<script>
(() => {
const shopSearch = document.getElementById('shop-search');
const shopSelect = document.getElementById('shop-select');
const shopSuggest = document.getElementById('shop-search-suggest');
const paymentType = document.getElementById('payment-type');
const paymentGuide = document.getElementById('payment-guide');
const addRowButton = document.getElementById('add-order-row');
const orderRows = document.getElementById('order-rows');
const rowTemplate = document.getElementById('order-row-template');
const form = document.getElementById('phone-order-form');
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
function updateShopInfo() {
const opt = shopSelect.options[shopSelect.selectedIndex];
const bank = opt?.dataset?.vaBank || '';
const account = opt?.dataset?.vaAccount || '';
const va = bank || account ? [bank, account].filter(Boolean).join(' ') : '-';
document.getElementById('shop-info-code').textContent = opt?.dataset?.shopNo || '-';
document.getElementById('shop-info-biz').textContent = opt?.dataset?.bizNo || '-';
document.getElementById('shop-info-rep').textContent = opt?.dataset?.repName || '-';
document.getElementById('shop-info-name').textContent = opt?.dataset?.name || '-';
document.getElementById('shop-info-tel').textContent = opt?.dataset?.tel || opt?.dataset?.repPhone || '-';
document.getElementById('shop-info-addr').textContent = opt?.dataset?.address || '-';
document.getElementById('shop-info-va').textContent = va;
paymentGuide.textContent = paymentType.value === '가상계좌' ? ('안내 계좌: ' + va) : '';
}
function shopMergedText(opt) {
return [
opt.dataset.shopNo || '',
opt.dataset.bizNo || '',
opt.dataset.repName || '',
opt.dataset.name || '',
opt.dataset.tel || '',
opt.dataset.address || '',
].filter(Boolean).join(' ');
}
function matchShopByKeyword(keyword) {
const q = (keyword || '').trim().toLowerCase();
if (!q) return;
for (let i = 0; i < shopSelect.options.length; i++) {
const opt = shopSelect.options[i];
if (!opt.value) continue;
const merged = (shopMergedText(opt) + ' ' + (opt.text || '')).toLowerCase();
if (merged.includes(q)) {
shopSelect.selectedIndex = i;
updateShopInfo();
return;
}
}
}
function hideSuggest() {
if (!shopSuggest) return;
shopSuggest.classList.add('hidden');
shopSuggest.innerHTML = '';
}
function renderSuggest(query) {
if (!shopSuggest || !shopSelect) return;
const q = (query || '').trim().toLowerCase();
const matched = [];
for (let i = 0; i < shopSelect.options.length; i++) {
const opt = shopSelect.options[i];
if (!opt.value) continue;
const merged = shopMergedText(opt);
if (!q || merged.toLowerCase().includes(q)) {
matched.push({ index: i, label: merged });
}
if (matched.length >= 50) break;
}
if (matched.length === 0) {
hideSuggest();
return;
}
shopSuggest.innerHTML = matched.map((m) => `
<button type="button" class="shop-suggest-item w-full text-left px-2 py-1.5 hover:bg-blue-50 text-xs border-b border-gray-100 whitespace-normal break-all" data-index="${m.index}">${m.label}</button>
`).join('');
shopSuggest.classList.remove('hidden');
}
function calcRow(row) {
const select = row.querySelector('.bag-code-select');
const qtyInput = row.querySelector('.item-qty-input');
const selected = select.options[select.selectedIndex];
const qty = parseInt(qtyInput.value || '0', 10) || 0;
const unitPrice = parseInt(selected?.dataset?.unitPrice || '0', 10) || 0;
const boxSheets = parseInt(selected?.dataset?.boxSheets || '0', 10) || 0;
const packSheets = parseInt(selected?.dataset?.packSheets || '0', 10) || 0;
const kindLabel = selected?.dataset?.kindLabel || '-';
let box = 0;
let pack = 0;
let sheet = qty;
if (boxSheets > 0) {
box = Math.floor(qty / boxSheets);
const remain = qty % boxSheets;
if (packSheets > 0) {
pack = Math.floor(remain / packSheets);
sheet = remain % packSheets;
} else {
sheet = remain;
}
} else if (packSheets > 0) {
pack = Math.floor(qty / packSheets);
sheet = qty % packSheets;
}
const amount = unitPrice * qty;
const boxPrice = boxSheets * unitPrice;
const packPrice = packSheets * unitPrice;
row.querySelector('.item-kind-cell').textContent = kindLabel;
row.querySelector('.box-info-cell').textContent = nf(boxSheets) + ' / ' + nf(boxPrice);
row.querySelector('.pack-info-cell').textContent = nf(packSheets) + ' / ' + nf(packPrice);
row.querySelector('.unit-price-cell').textContent = nf(unitPrice);
row.querySelector('.item-amount-cell').textContent = nf(amount);
row.querySelector('.pack-result-cell').textContent = '박스=' + nf(box) + ', 팩=' + nf(pack) + ', 낱장=' + nf(sheet);
return { qty, amount, box, pack, sheet };
}
function recalcAllRows() {
let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0;
document.querySelectorAll('.order-row').forEach((row) => {
const r = calcRow(row);
sumQty += r.qty;
sumAmount += r.amount;
sumBox += r.box;
sumPack += r.pack;
sumSheet += r.sheet;
});
document.getElementById('sum-qty').textContent = nf(sumQty);
document.getElementById('sum-amount').textContent = nf(sumAmount);
document.getElementById('sum-pack').textContent = '박스=' + nf(sumBox) + ', 팩=' + nf(sumPack) + ', 낱장=' + nf(sumSheet);
}
function clearSearchInputOnly() {
if (shopSearch) shopSearch.value = '';
}
// 판매소 검색 input을 다시 누르면(또는 포커스를 다시 받으면) 검색 input 텍스트만 비운다.
// 기존에 선택된 판매소 정보(셀렉트, 지정판매소 정보, 가상계좌 등)는 그대로 유지한다.
shopSearch?.addEventListener('focus', () => {
if (shopSelect && shopSelect.value) clearSearchInputOnly();
renderSuggest('');
});
shopSearch?.addEventListener('mousedown', () => {
if (shopSelect && shopSelect.value) clearSearchInputOnly();
});
shopSearch?.addEventListener('click', () => renderSuggest(shopSearch.value || ''));
shopSearch?.addEventListener('input', (e) => renderSuggest(e.target.value || ''));
shopSuggest?.addEventListener('mousedown', (e) => {
const btn = e.target.closest('.shop-suggest-item');
if (!btn) return;
e.preventDefault();
const idx = Number(btn.dataset.index || -1);
if (!Number.isInteger(idx) || idx < 0 || idx >= shopSelect.options.length) return;
shopSelect.selectedIndex = idx;
const opt = shopSelect.options[idx];
shopSearch.value = shopMergedText(opt);
updateShopInfo();
hideSuggest();
});
document.addEventListener('click', (e) => {
if (!shopSuggest || !shopSearch) return;
if (e.target === shopSearch || shopSuggest.contains(e.target)) return;
hideSuggest();
});
shopSearch?.addEventListener('change', (e) => matchShopByKeyword(e.target.value));
shopSearch?.addEventListener('blur', () => {
// 자동완성 클릭이 우선되도록 약간 지연.
setTimeout(() => {
matchShopByKeyword(shopSearch.value || '');
}, 150);
});
shopSelect?.addEventListener('change', updateShopInfo);
paymentType?.addEventListener('change', updateShopInfo);
orderRows?.addEventListener('change', function (e) {
if (e.target.closest('.bag-code-select') || e.target.closest('.item-qty-input')) {
recalcAllRows();
}
});
orderRows?.addEventListener('input', function (e) {
if (e.target.closest('.item-qty-input')) {
recalcAllRows();
}
});
orderRows?.addEventListener('click', function (e) {
const removeButton = e.target.closest('.remove-order-row');
if (!removeButton) return;
const row = removeButton.closest('.order-row');
if (!row) return;
if (orderRows.querySelectorAll('.order-row').length <= 1) {
alert('최소 1개 행은 유지해야 합니다.');
return;
}
row.remove();
recalcAllRows();
});
addRowButton?.addEventListener('click', function () {
if (!rowTemplate || !orderRows) return;
const fragment = rowTemplate.content.cloneNode(true);
orderRows.appendChild(fragment);
recalcAllRows();
});
form?.addEventListener('submit', function (e) {
let hasItem = false;
document.querySelectorAll('.order-row').forEach((row) => {
const code = row.querySelector('.bag-code-select').value;
const qty = parseInt(row.querySelector('.item-qty-input').value || '0', 10) || 0;
if (code && qty > 0) hasItem = true;
});
if (!hasItem) {
e.preventDefault();
alert('주문 품목과 수량을 1개 이상 입력해 주세요.');
}
});
updateShopInfo();
recalcAllRows();
})();
</script>
<?= view('bag/_dev_all_sales_panel') ?>

View File

@@ -0,0 +1,261 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">전화 주문 접수 관리</span>
<a href="<?= base_url('bag/order/phone') ?>" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm shadow hover:opacity-90">전화 주문 접수</a>
</div>
</section>
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3 mt-2">
<section class="xl:col-span-2 border border-gray-300 bg-white">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">접수 리스트(전화)</div>
<div class="max-h-[72vh] overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-14">번호</th>
<th>판매소</th>
<th class="w-28">접수일</th>
<th class="w-24">상태</th>
</tr>
</thead>
<tbody id="order-list-body">
<?php foreach (($orders ?? []) as $row): ?>
<?php $isCancelled = (($row['so_status'] ?? 'normal') === 'cancelled'); ?>
<tr class="order-list-row cursor-pointer hover:bg-blue-50 <?= $isCancelled ? 'bg-gray-50 text-gray-400' : '' ?>" data-order-id="<?= esc((string) ($row['so_idx'] ?? 0), 'attr') ?>">
<td class="text-center"><?= esc((string) ($row['so_idx'] ?? 0)) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['so_ds_name'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['so_order_date'] ?? '')) ?></td>
<td class="text-center"><?= $isCancelled ? '취소' : '정상' ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($orders)): ?>
<tr>
<td colspan="4" class="text-center py-8 text-gray-400">전화 주문 데이터가 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="xl:col-span-3 border border-gray-300 bg-white">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">상세 정보</div>
<form id="order-detail-form" action="<?= base_url('bag/order/phone/manage/update') ?>" method="POST" class="p-3 space-y-3">
<?= csrf_field() ?>
<input type="hidden" name="so_idx" id="detail-so-idx" value=""/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
<div class="border border-gray-200 p-2 bg-gray-50">
<div><span class="font-semibold text-gray-700">접수번호:</span> <span id="detail-so-no">-</span></div>
<div><span class="font-semibold text-gray-700">판매소:</span> <span id="detail-shop-name">-</span></div>
<div><span class="font-semibold text-gray-700">결제구분:</span> <span id="detail-payment">-</span></div>
</div>
<div class="border border-gray-200 p-2 bg-gray-50">
<div><span class="font-semibold text-gray-700">접수일:</span> <span id="detail-order-date">-</span></div>
<div><span class="font-semibold text-gray-700">배달일:</span> <span id="detail-delivery-date">-</span></div>
<div><span class="font-semibold text-gray-700">상태:</span> <span id="detail-status">-</span></div>
</div>
</div>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-14">번호</th>
<th>품목</th>
<th class="w-24">단가</th>
<th class="w-24">접수량</th>
<th class="w-28">접수금액</th>
<th class="w-40">포장단위(박스/팩/낱장)</th>
</tr>
</thead>
<tbody id="detail-items-body">
<tr>
<td colspan="6" class="text-center py-6 text-gray-400">왼쪽 리스트에서 주문을 선택해 주세요.</td>
</tr>
</tbody>
<tfoot>
<tr class="font-semibold bg-gray-50">
<td colspan="3" class="text-right px-2 py-1">합계</td>
<td class="text-right px-2 py-1" id="detail-sum-qty">0</td>
<td class="text-right px-2 py-1" id="detail-sum-amount">0</td>
<td class="text-right px-2 py-1" id="detail-sum-pack">박스=0, 팩=0, 낱장=0</td>
</tr>
</tfoot>
</table>
</div>
<div class="flex gap-2">
<button type="submit" id="btn-save" class="bg-btn-search text-white px-5 py-1.5 rounded-sm text-sm shadow hover:opacity-90" disabled>주문 수정 저장</button>
</div>
</form>
<form id="order-cancel-form" method="POST" class="px-3 pb-3">
<?= csrf_field() ?>
<button type="submit" id="btn-cancel-order" class="border border-red-300 text-red-600 px-5 py-1.5 rounded-sm text-sm hover:bg-red-50" disabled>주문 취소</button>
</form>
</section>
</div>
<script>
(() => {
const orders = <?= json_encode($orders ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const orderMap = new Map(orders.map((o) => [String(o.so_idx), o]));
const listBody = document.getElementById('order-list-body');
const form = document.getElementById('order-detail-form');
const cancelForm = document.getElementById('order-cancel-form');
const detailBody = document.getElementById('detail-items-body');
const inputSoIdx = document.getElementById('detail-so-idx');
const btnSave = document.getElementById('btn-save');
const btnCancel = document.getElementById('btn-cancel-order');
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
function setHeader(order) {
document.getElementById('detail-so-no').textContent = order ? String(order.so_idx || '-') : '-';
document.getElementById('detail-shop-name').textContent = order ? (order.so_ds_name || '-') : '-';
document.getElementById('detail-payment').textContent = order ? (order.so_payment_type || '-') : '-';
document.getElementById('detail-order-date').textContent = order ? (order.so_order_date || '-') : '-';
document.getElementById('detail-delivery-date').textContent = order ? (order.so_delivery_date || '-') : '-';
document.getElementById('detail-status').textContent = order ? ((order.so_status === 'cancelled') ? '취소' : '정상') : '-';
}
function calcRow(tr) {
const qtyInput = tr.querySelector('.item-qty-input');
const qty = Math.max(0, parseInt(qtyInput.value || '0', 10) || 0);
qtyInput.value = String(qty);
const unitPrice = parseInt(tr.dataset.unitPrice || '0', 10) || 0;
const boxSheets = parseInt(tr.dataset.boxSheets || '0', 10) || 0;
const packSheets = parseInt(tr.dataset.packSheets || '0', 10) || 0;
let box = 0;
let pack = 0;
let sheet = qty;
if (boxSheets > 0) {
box = Math.floor(qty / boxSheets);
const remain = qty % boxSheets;
if (packSheets > 0) {
pack = Math.floor(remain / packSheets);
sheet = remain % packSheets;
} else {
sheet = remain;
}
} else if (packSheets > 0) {
pack = Math.floor(qty / packSheets);
sheet = qty % packSheets;
}
const amount = qty * unitPrice;
tr.querySelector('.item-amount-cell').textContent = nf(amount);
tr.querySelector('.item-pack-cell').textContent = `박스=${nf(box)}, 팩=${nf(pack)}, 낱장=${nf(sheet)}`;
return { qty, amount, box, pack, sheet };
}
function recalcTotals() {
let sumQty = 0;
let sumAmount = 0;
let sumBox = 0;
let sumPack = 0;
let sumSheet = 0;
detailBody.querySelectorAll('tr.order-item-row').forEach((tr) => {
const r = calcRow(tr);
sumQty += r.qty;
sumAmount += r.amount;
sumBox += r.box;
sumPack += r.pack;
sumSheet += r.sheet;
});
document.getElementById('detail-sum-qty').textContent = nf(sumQty);
document.getElementById('detail-sum-amount').textContent = nf(sumAmount);
document.getElementById('detail-sum-pack').textContent = `박스=${nf(sumBox)}, 팩=${nf(sumPack)}, 낱장=${nf(sumSheet)}`;
}
function renderDetail(orderId) {
const order = orderMap.get(String(orderId));
if (!order) return;
inputSoIdx.value = String(order.so_idx || '');
setHeader(order);
cancelForm.action = `<?= base_url('bag/order/phone/manage/cancel') ?>/${order.so_idx}`;
const isCancelled = order.so_status === 'cancelled';
btnSave.disabled = isCancelled;
btnCancel.disabled = isCancelled;
const items = Array.isArray(order.items) ? order.items : [];
if (items.length === 0) {
detailBody.innerHTML = '<tr><td colspan="6" class="text-center py-6 text-gray-400">품목 정보가 없습니다.</td></tr>';
recalcTotals();
return;
}
detailBody.innerHTML = items.map((item, idx) => {
const itemId = String(item.soi_idx || '');
const bagName = `${item.soi_bag_code || ''} ${item.soi_bag_name || ''}`.trim();
const qty = parseInt(item.soi_qty || 0, 10) || 0;
const unitPrice = parseInt(item.soi_unit_price || 0, 10) || 0;
const amount = parseInt(item.soi_amount || 0, 10) || 0;
const box = parseInt(item.soi_box_count || 0, 10) || 0;
const pack = parseInt(item.soi_pack_count || 0, 10) || 0;
const sheet = parseInt(item.soi_sheet_count || 0, 10) || 0;
const boxSheets = parseInt(item.box_sheets || 0, 10) || 0;
const packSheets = parseInt(item.pack_sheets || 0, 10) || 0;
return `
<tr class="order-item-row" data-unit-price="${unitPrice}" data-box-sheets="${boxSheets}" data-pack-sheets="${packSheets}">
<td class="text-center">${idx + 1}</td>
<td class="text-left pl-2">${bagName}</td>
<td class="text-right pr-2">${nf(unitPrice)}</td>
<td class="text-right pr-2">
<input type="number" min="0" class="item-qty-input border border-gray-300 rounded px-2 py-1 w-24 text-right" name="item_qty[${itemId}]" value="${qty}" ${isCancelled ? 'disabled' : ''}/>
</td>
<td class="text-right pr-2 item-amount-cell">${nf(amount)}</td>
<td class="text-right pr-2 item-pack-cell">박스=${nf(box)}, 팩=${nf(pack)}, 낱장=${nf(sheet)}</td>
</tr>
`;
}).join('');
recalcTotals();
}
listBody?.addEventListener('click', (e) => {
const tr = e.target.closest('.order-list-row');
if (!tr) return;
listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100'));
tr.classList.add('bg-blue-100');
renderDetail(tr.dataset.orderId);
});
detailBody?.addEventListener('input', (e) => {
if (e.target.closest('.item-qty-input')) {
recalcTotals();
}
});
cancelForm?.addEventListener('submit', (e) => {
if (!confirm('해당 주문을 취소 처리하시겠습니까? (삭제되지 않고 상태만 취소로 변경됩니다)')) {
e.preventDefault();
}
});
form?.addEventListener('submit', (e) => {
if (!inputSoIdx.value) {
e.preventDefault();
alert('수정할 주문을 먼저 선택해 주세요.');
}
});
const firstRow = listBody?.querySelector('.order-list-row');
if (firstRow) {
firstRow.classList.add('bg-blue-100');
renderDetail(firstRow.dataset.orderId);
}
})();
</script>
<?= view('bag/_dev_all_sales_panel') ?>

View File

@@ -1,4 +1,4 @@
<?= view('components/print_header', ['printTitle' => '봉투 입고 현황', 'printShowApproval' => false]) ?>
<?= view('components/print_header', ['printTitle' => '봉투 입고 현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">입고 현황</span>

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>종량제 시스템 봉투 수불 현황</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style data-purpose="base-typography">
body {

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
/** @var string $href Brand link target */
$href = $href ?? base_url();
/** @var string $linkClass Anchor + inner flex typography */
$linkClass = $linkClass ?? 'flex items-center gap-2 shrink-0 text-base font-semibold text-gray-800 tracking-tight hover:text-blue-600';
$linkClass = $linkClass ?? 'app-brand flex items-center gap-2 shrink-0 text-base font-semibold text-gray-800 tracking-tight hover:text-blue-600';
?>
<a href="<?= esc($href) ?>" class="<?= esc($linkClass, 'attr') ?>" title="종량제 시스템">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-blue-900 translate-y-[1px] shrink-0" aria-hidden="true" focusable="false">

View File

@@ -7,6 +7,7 @@
* $printLgName - 지자체명 (선택, 미지정 시 세션에서 조회)
* $printDate - 날짜 (선택, 기본 오늘)
* $printExtraLines - 조회조건 등 추가 줄 (선택, 문자열 배열)
* $printShowApproval - false 로 명시할 때만 결재란 숨김. 기본 true(담당·계장·과장).
*/
if (! isset($printLgName)) {
@@ -21,12 +22,13 @@ if (! isset($printLgName)) {
$printDate = $printDate ?? date('Y-m-d');
$printTitle = $printTitle ?? '';
$printExtraLines = $printExtraLines ?? [];
$printShowApproval = ($printShowApproval ?? true);
?>
<div class="print-header" style="display:none;">
<table style="width:100%; border-collapse:collapse; margin-bottom:10px;">
<tr>
<td style="width:60%; vertical-align:bottom;">
<td style="width:55%; vertical-align:top;">
<div style="font-size:12px; color:#666; margin-bottom:4px;"><?= esc($printLgName) ?></div>
<div style="font-size:20px; font-weight:bold; letter-spacing:2px;"><?= esc($printTitle) ?></div>
<div style="font-size:11px; color:#888; margin-top:4px;">출력일: <?= esc($printDate) ?></div>
@@ -34,20 +36,24 @@ $printExtraLines = $printExtraLines ?? [];
<div style="font-size:11px; color:#555; margin-top:2px;"><?= esc($line) ?></div>
<?php endforeach; ?>
</td>
<td style="width:40%; vertical-align:top;">
<table style="border-collapse:collapse; float:right; font-size:11px;">
<?php if ($printShowApproval): ?>
<td style="width:45%; vertical-align:top; text-align:right;">
<table style="border-collapse:collapse; margin-left:auto; font-size:11px; width:220px;">
<tr>
<th style="border:1px solid #333; padding:4px 12px; background:#f0f0f0; text-align:center;">담당</th>
<th style="border:1px solid #333; padding:4px 12px; background:#f0f0f0; text-align:center;">장</th>
<th style="border:1px solid #333; padding:4px 12px; background:#f0f0f0; text-align:center;">과장</th>
<td style="border:1px solid #333; padding:6px 8px; text-align:center; width:33%; height:36px;">담당</td>
<td style="border:1px solid #333; padding:6px 8px; text-align:center; width:33%; height:36px;">장</td>
<td style="border:1px solid #333; padding:6px 8px; text-align:center; width:33%; height:36px;">과장</td>
</tr>
<tr>
<td style="border:1px solid #333; padding:8px 12px; height:40px; min-width:60px;">&nbsp;</td>
<td style="border:1px solid #333; padding:8px 12px; height:40px; min-width:60px;">&nbsp;</td>
<td style="border:1px solid #333; padding:8px 12px; height:40px; min-width:60px;">&nbsp;</td>
<td style="border:1px solid #333; height:44px;">&nbsp;</td>
<td style="border:1px solid #333;">&nbsp;</td>
<td style="border:1px solid #333;">&nbsp;</td>
</tr>
</table>
</td>
<?php else: ?>
<td style="width:45%;"></td>
<?php endif; ?>
</tr>
</table>
<hr style="border:1px solid #333; margin-bottom:10px;"/>

View File

@@ -5,6 +5,7 @@
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title> - 종량제 시스템</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {

View File

@@ -5,6 +5,7 @@
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>종량제 시스템</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<style data-purpose="global-font-scale">html{font-size:18px}.app-brand,.app-brand *{font-size:16px}</style>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {