지정판매소 현황·바코드 출력 기능을 전용 화면으로 확장

지정판매소 신규/취소 현황을 사용자 지자체 기준으로 고정 조회하도록 정리하고, 동별 요약과 컬럼 설명 툴팁을 추가했습니다.
또한 지정판매소 바코드 출력 메뉴를 전용 URL로 분리하고 선택 인쇄/출력 레이아웃을 GBMS 형태에 맞춰 구현했습니다.
This commit is contained in:
taekyoungc
2026-04-14 00:14:53 +09:00
parent 72578f200c
commit 734a55833b
13 changed files with 2160 additions and 167 deletions

View File

@@ -83,7 +83,13 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->get('designated-shops/export', 'Admin\DesignatedShop::export');
$routes->get('designated-shops/map', 'Admin\DesignatedShop::map');
$routes->get('designated-shops/status/export', 'Admin\DesignatedShop::statusExport');
$routes->get('designated-shops/status', 'Admin\DesignatedShop::status');
$routes->get('designated-shops/barcode', 'Admin\DesignatedShop::barcode');
$routes->post('designated-shops/barcode/print', 'Admin\DesignatedShop::barcodePrint');
$routes->get('designated-shops/district-new-cancel/export', 'Admin\DesignatedShop::districtNewCancelExport');
$routes->get('designated-shops/district-new-cancel', 'Admin\DesignatedShop::districtNewCancel');
$routes->get('designated-shops/browse', 'Admin\DesignatedShop::browse');
$routes->get('designated-shops', 'Admin\DesignatedShop::index');
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 바코드 출력']) ?>
<style>
.ds-bc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.ds-bc-table th, .ds-bc-table td { border: 1px solid #ccc; padding: 4px 6px; }
.ds-bc-table th { background: #e9ecef; color: #2d3748; }
.ds-bc-table td { background: #fff; }
.ds-bc-table td.name-cell { max-width: 14rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-bc-table td.addr-cell { max-width: 24rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-bc-check { width: 14px; height: 14px; }
</style>
<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">지정판매소 바코드 출력</span>
<div class="flex items-center gap-2">
<button type="button" id="ds-bc-print-btn" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">인쇄</button>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form id="ds-bc-filter-form" method="get" action="<?= mgmt_url('designated-shops/barcode') ?>" class="flex flex-wrap items-end gap-3">
<div class="min-w-[12rem]">
<label class="block text-xs text-gray-600 mb-0.5">군·구</label>
<div class="border border-gray-300 rounded px-3 py-1 text-sm bg-gray-50 text-gray-800 font-medium pointer-events-none select-none">
<?= esc($fixedGugunLabel ?? '현재 지자체') ?>
</div>
</div>
<div>
<label class="block text-xs text-gray-600 mb-0.5">읍·면·동</label>
<select name="ds_zone_code" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="">전체</option>
<?php foreach (($zones ?? []) as $z): ?>
<?php $zc = trim((string) ($z->zone_code ?? '')); ?>
<option value="<?= esc($zc) ?>" <?= ($zoneFilter ?? '') === $zc ? 'selected' : '' ?>><?= esc($zc) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-xs text-gray-600 mb-0.5">조회순서</label>
<select name="order_by" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem]">
<option value="shop_no" <?= ($orderBy ?? 'shop_no') === 'shop_no' ? 'selected' : '' ?>>판매소 코드</option>
<option value="name" <?= ($orderBy ?? '') === 'name' ? 'selected' : '' ?>>판매소명</option>
</select>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
</section>
<section class="mx-2 mt-2 mb-2">
<form id="ds-bc-print-form" method="post" action="<?= mgmt_url('designated-shops/barcode/print') ?>" target="ds-bc-print-frame">
<?= csrf_field() ?>
<input type="hidden" name="zone_label" value="<?= esc(($zoneFilter ?? '') !== '' ? (string) $zoneFilter : '전체') ?>">
<div class="mb-1 text-xs text-gray-600">
<label class="inline-flex items-center gap-1 cursor-pointer"><input type="checkbox" id="ds-bc-check-all" class="ds-bc-check"> 전체선택</label>
<span class="ml-3">선택 건수: <strong id="ds-bc-selected-count">0</strong></span>
</div>
<div class="overflow-auto border border-gray-300 bg-white">
<table class="ds-bc-table">
<thead>
<tr>
<th class="w-14">출력</th>
<th class="w-36">판매소 코드</th>
<th>판매소명</th>
<th class="w-24">대표자명</th>
<th class="w-32">사업자번호</th>
<th>사업장 주소</th>
<th class="w-16">상태</th>
</tr>
</thead>
<tbody>
<?php foreach (($list ?? []) as $row): ?>
<?php
$st = (int) ($row->ds_state ?? 1);
$stLabel = $st === 1 ? '사용' : '정지';
?>
<tr>
<td class="text-center"><input class="ds-bc-row-check ds-bc-check" type="checkbox" name="ds_idx[]" value="<?= (int) $row->ds_idx ?>"></td>
<td class="text-center text-blue-700"><?= esc((string) ($row->ds_shop_no ?? '')) ?></td>
<td class="name-cell text-blue-700" title="<?= esc((string) ($row->ds_name ?? '')) ?>"><?= esc((string) ($row->ds_name ?? '')) ?></td>
<td><?= esc((string) ($row->ds_rep_name ?? '')) ?></td>
<td><?= esc((string) ($row->ds_biz_no ?? '')) ?></td>
<td class="addr-cell" title="<?= esc((string) ($row->ds_addr ?? '')) ?>"><?= esc((string) ($row->ds_addr ?? '')) ?></td>
<td class="<?= $st === 1 ? 'text-blue-700' : 'text-red-600' ?>"><?= esc($stLabel) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($list)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-8">조회된 지정판매소가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</form>
<iframe name="ds-bc-print-frame" class="hidden" style="display:none;width:0;height:0;border:0;" aria-hidden="true"></iframe>
</section>
<?php if (isset($pager)): ?>
<div class="mt-2 mb-2 mx-2 no-print"><?= $pager->links() ?></div>
<?php endif; ?>
<script>
(function () {
var all = document.getElementById('ds-bc-check-all');
var countEl = document.getElementById('ds-bc-selected-count');
var printBtn = document.getElementById('ds-bc-print-btn');
var printForm = document.getElementById('ds-bc-print-form');
var rows = Array.prototype.slice.call(document.querySelectorAll('.ds-bc-row-check'));
if (!all || !countEl || !rows.length) return;
function refreshCount() {
var n = rows.filter(function (el) { return el.checked; }).length;
countEl.textContent = String(n);
all.checked = n > 0 && n === rows.length;
all.indeterminate = n > 0 && n < rows.length;
}
all.addEventListener('change', function () {
rows.forEach(function (el) { el.checked = all.checked; });
refreshCount();
});
rows.forEach(function (el) { el.addEventListener('change', refreshCount); });
if (printBtn && printForm) {
printBtn.addEventListener('click', function () {
var selected = rows.filter(function (el) { return el.checked; }).length;
if (selected < 1) {
window.alert('출력할 지정판매소를 1개 이상 선택해 주세요.');
return;
}
printForm.action = "<?= esc(mgmt_url('designated-shops/barcode/print')) ?>?autoprint=1";
printForm.submit();
});
}
refreshCount();
})();
</script>

View File

@@ -0,0 +1,94 @@
<?php
$rows = $rows ?? [];
$zoneLabel = trim((string) ($zoneLabel ?? '전체'));
$printedAt = trim((string) ($printedAt ?? date('Y.m.d')));
$chunks = array_chunk($rows, 12);
$totalPages = count($chunks);
?>
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>지정판매소 바코드</title>
<style>
body { margin: 0; font-family: Arial, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; color: #222; background: #fff; }
.page { width: 210mm; min-height: 297mm; margin: 0 auto; padding: 14mm 12mm 12mm; box-sizing: border-box; }
.title { text-align: center; font-size: 42px; letter-spacing: 1px; font-weight: 500; margin: 0 0 14px; }
.meta { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #555; padding-bottom: 4px; font-size: 13px; margin-bottom: 8px; }
.meta .center { font-weight: 700; }
.cards { display: flex; flex-wrap: wrap; align-content: flex-start; }
.card { width: 33.3333%; padding: 0 8px 12px; box-sizing: border-box; }
.barcode-wrap { min-height: 40px; }
.barcode-svg { width: 100%; max-width: 270px; height: 22px; }
.code-text { text-align: center; margin-top: 1px; font-size: 16px; letter-spacing: 0.35px; }
.name-text { text-align: center; margin-top: 5px; font-size: 14px; line-height: 1.2; word-break: keep-all; }
@media print {
@page { size: A4; margin: 0; }
.page { page-break-after: always; }
.page:last-child { page-break-after: auto; }
}
</style>
</head>
<body>
<?php if ($rows === []): ?>
<div class="page">
<h1 class="title">지정판매소 바코드</h1>
<p style="text-align:center; margin-top:30px; color:#666;">출력할 지정판매소가 없습니다.</p>
</div>
<?php else: ?>
<?php foreach ($chunks as $pageIndex => $pageRows): ?>
<section class="page">
<h1 class="title">지정판매소 바코드</h1>
<div class="meta">
<span>출 력 일 자: <?= esc($printedAt) ?></span>
<span class="center"><?= esc($zoneLabel) ?></span>
<span>페&nbsp;&nbsp;이&nbsp;&nbsp;지: <?= (int) ($pageIndex + 1) ?> / <?= (int) $totalPages ?></span>
</div>
<div class="cards">
<?php foreach ($pageRows as $row): ?>
<?php
$code = trim((string) ($row->ds_shop_no ?? ''));
$nm = trim((string) ($row->ds_name ?? ''));
$rep = trim((string) ($row->ds_rep_name ?? ''));
$label = trim($nm . ($rep !== '' ? ('-' . $rep) : ''));
?>
<div class="card">
<div class="barcode-wrap">
<svg class="barcode-svg" data-barcode="<?= esc($code, 'attr') ?>"></svg>
</div>
<div class="code-text"><?= esc($code) ?></div>
<div class="name-text"><?= esc($label) ?></div>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
<?php endif; ?>
<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.6/dist/JsBarcode.all.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var svgs = document.querySelectorAll('svg[data-barcode]');
svgs.forEach(function (svg) {
var code = (svg.getAttribute('data-barcode') || '').trim();
if (!code) return;
try {
JsBarcode(svg, code, {
format: 'CODE128',
displayValue: false,
margin: 0,
height: 16,
width: 1.28
});
} catch (e) {
svg.outerHTML = '<div style="font-size:12px;color:#b91c1c;">바코드 생성 실패: ' + code + '</div>';
}
});
if (window.location.search.indexOf('autoprint=1') >= 0) {
setTimeout(function () { window.print(); }, 200);
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,210 @@
<?php
$ry = (int) ($reportYear ?? (int) date('Y'));
$lg = $currentLg ?? null;
$lgSido = $lg !== null ? trim((string) ($lg->lg_sido ?? '')) : '';
$lgGugun = $lg !== null ? trim((string) ($lg->lg_gugun ?? '')) : '';
$lgName = $lg !== null ? trim((string) ($lg->lg_name ?? '')) : '';
$scopeLabel = $lgSido !== '' && $lgGugun !== ''
? $lgSido . ' ' . $lgGugun
: ($lgName !== '' ? $lgName : '—');
$exportUrl = mgmt_url('designated-shops/district-new-cancel/export') . '?' . http_build_query(['year' => $ry]);
?>
<?= view('components/print_header', ['printTitle' => '지정 판매소 신규/취소 현황 (' . $ry . '년)']) ?>
<style>
.gbms-dnc-wrap { max-width: 100%; }
.gbms-dnc-table { border-collapse: collapse; width: 100%; font-size: 13px; }
.gbms-dnc-table th,
.gbms-dnc-table td {
border: 1px solid #7a8aa0;
padding: 6px 10px;
text-align: center;
}
.gbms-dnc-table thead th {
background: linear-gradient(180deg, #e8eef6 0%, #d4dee9 100%);
font-weight: 700;
color: #1a2a3a;
}
.gbms-dnc-table thead th.gbms-sub {
background: #dce6f0;
font-weight: 600;
}
.gbms-dnc-table tbody td.text-left { text-align: left; }
.gbms-dnc-table tbody tr.gbms-total td {
font-weight: 700;
border: 2px solid #c62828;
background: #fff8f8;
}
.gbms-dnc-caption {
font-size: 13px;
font-weight: 700;
margin: 8px 0 6px;
color: #1a2a3a;
}
.gbms-unit-pill {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: #0d47a1;
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 2px;
}
.gbms-tip {
position: relative;
display: inline-flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
}
.gbms-help {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
height: 1rem;
padding: 0 2px;
border: 1px solid #5c6f85;
border-radius: 999px;
background: #fef9c3;
color: #111827;
font-size: 11px;
font-weight: 700;
line-height: 1;
font-family: Arial, sans-serif;
user-select: none;
cursor: help;
}
.gbms-help::after {
content: attr(data-tip);
position: absolute;
left: 50%;
top: calc(100% + 6px);
transform: translateX(-50%);
display: none;
min-width: 12rem;
max-width: 14rem;
padding: 6px 8px;
border: 1px solid #8fa0b3;
border-radius: 4px;
background: #1f2937;
color: #fff;
font-size: 11px;
font-weight: 500;
line-height: 1.35;
text-align: left;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
z-index: 30;
}
.gbms-help:hover::after,
.gbms-help:focus::after {
display: block;
}
@media print {
.gbms-dnc-table { font-size: 11px; }
.gbms-help { display: none !important; }
}
</style>
<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-800">[지정 판매소 신규/취소 현황]</span>
<div class="flex items-center gap-2">
<a href="<?= esc($exportUrl) ?>" class="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="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= mgmt_url('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록</a>
</div>
</div>
</section>
<section class="p-3 bg-white border-b border-gray-200 no-print">
<form method="get" action="<?= mgmt_url('designated-shops/district-new-cancel') ?>" class="flex flex-wrap items-end gap-4">
<div>
<label class="block text-xs text-gray-600 mb-0.5">조회년도</label>
<select name="year" class="border border-gray-400 rounded px-2 py-1.5 text-sm min-w-[7rem] bg-white">
<?php foreach (($yearChoices ?? []) as $y): ?>
<option value="<?= (int) $y ?>" <?= $ry === (int) $y ? 'selected' : '' ?>><?= (int) $y ?>년</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex-1 min-w-[12rem]">
<span class="block text-xs text-gray-600 mb-0.5">군·구 (소속 지자체)</span>
<div class="border border-gray-300 rounded px-3 py-1.5 text-sm bg-gray-50 text-gray-800 font-medium">
<?= esc($scopeLabel) ?>
</div>
</div>
<span class="gbms-unit-pill self-end mb-0.5">단위: 판매소</span>
<button type="submit" class="bg-btn-search text-white px-5 py-1.5 rounded-sm text-sm font-medium shadow-sm hover:opacity-90">조회</button>
</form>
<p class="text-xs text-gray-500 mt-2">
종전·현행은 각각 <?= $ry - 1 ?>-12-31·<?= $ry ?>-12-31 기준 정상(지정일 이전·비정상 전환일 이후) 건수이며, 지정·취소는 <?= $ry ?>년 달력연도 기준입니다. 구·군 행은 효과 지자체의 기본코드(구 코드) 순서로 표시됩니다.
</p>
</section>
<div class="mx-2 mt-3 mb-4 gbms-dnc-wrap">
<div class="gbms-dnc-caption">지정 판매소 신규/취소 현황 조회 내역</div>
<div class="overflow-x-auto border border-gray-400 bg-white">
<table class="gbms-dnc-table">
<thead>
<tr>
<th rowspan="2" class="min-w-[6rem]">군·구</th>
<th rowspan="2">
<span class="gbms-tip">
종전
<span class="gbms-help" tabindex="0" aria-label="종전 설명" data-tip="전년도 12월 31일 기준 정상 상태 판매소 수">?</span>
</span>
<br><span class="text-xs font-normal">(전년도말)</span>
</th>
<th colspan="2">사용</th>
<th rowspan="2">
<span class="gbms-tip">
현행
<span class="gbms-help" tabindex="0" aria-label="현행 설명" data-tip="조회년도 12월 31일 기준 정상 상태 판매소 수">?</span>
</span>
<br><span class="text-xs font-normal">(금년도말)</span>
</th>
</tr>
<tr>
<th class="gbms-sub">
<span class="gbms-tip">
지정
<span class="gbms-help" tabindex="0" aria-label="지정 설명" data-tip="조회년도 내 지정일이 속한 신규 지정 건수">?</span>
</span>
</th>
<th class="gbms-sub">
<span class="gbms-tip">
취소
<span class="gbms-help" tabindex="0" aria-label="취소 설명" data-tip="조회년도 내 폐업/해지 전환일이 속한 건수">?</span>
</span>
</th>
</tr>
</thead>
<tbody>
<?php foreach (($districtRows ?? []) as $row): ?>
<tr>
<td class="text-left font-medium"><?= esc($row->region_label) ?></td>
<td><?= number_format((int) $row->prev_end) ?></td>
<td><?= number_format((int) $row->designated_y) ?></td>
<td><?= number_format((int) $row->cancelled_y) ?></td>
<td><?= number_format((int) $row->curr_end) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($districtRows)): ?>
<tr>
<td colspan="5" class="text-center text-gray-500 py-8">표시할 구·군 또는 지정판매소 데이터가 없습니다.</td>
</tr>
<?php endif; ?>
<?php if (! empty($districtRows) && isset($districtTotal)): ?>
<tr class="gbms-total">
<td class="text-left"><?= esc($districtTotal->region_label) ?></td>
<td><?= number_format((int) $districtTotal->prev_end) ?></td>
<td><?= number_format((int) $districtTotal->designated_y) ?></td>
<td><?= number_format((int) $districtTotal->cancelled_y) ?></td>
<td><?= number_format((int) $districtTotal->curr_end) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>

View File

@@ -1,80 +1,387 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<?php
$ry = (int) ($reportYear ?? (int) date('Y'));
$exportUrl = mgmt_url('designated-shops/status/export') . '?' . http_build_query([
'year' => $ry,
]);
$fixedGugunLabel = trim((string) ($fixedGugunLabel ?? ''));
$regionColLabel = '군·구';
$sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
?>
<style>
.ds-status-x-scroll {
overflow-x: auto;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
max-width: 100%;
}
@media print {
.ds-status-x-scroll { overflow: visible !important; border: none; }
}
.ds-status-x-scroll .ds-status-table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
}
.ds-status-x-scroll .ds-status-table th,
.ds-status-x-scroll .ds-status-table td {
white-space: nowrap;
padding: 6px 10px;
font-size: 12px;
}
.ds-status-x-scroll .ds-status-table thead th {
background: #e9ecef;
border: 1px solid #ccc;
}
.ds-status-x-scroll .ds-status-table tbody td {
border: 1px solid #ccc;
}
.ds-status-x-scroll th.sticky-num,
.ds-status-x-scroll td.sticky-num {
position: sticky;
left: 0;
z-index: 3;
min-width: 3rem;
max-width: 3rem;
width: 3rem;
box-sizing: border-box;
background: #e9ecef;
border-right: 1px solid #bbb;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.06);
}
.ds-status-x-scroll td.sticky-num {
background: #fff;
text-align: center;
}
.ds-status-x-scroll tr.sum-row td.sticky-num {
background: #f3f4f6;
}
.ds-status-x-scroll th.sticky-region,
.ds-status-x-scroll td.sticky-region {
position: sticky;
left: 3rem;
z-index: 2;
background: #e9ecef;
border-right: 1px solid #bbb;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.06);
max-width: 16rem;
text-align: left;
}
.ds-status-x-scroll td.sticky-region {
background: #fff;
overflow: hidden;
text-overflow: ellipsis;
}
.ds-status-x-scroll tr.sum-row td.sticky-region {
background: #f3f4f6;
}
.ds-help {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
}
.ds-help-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
height: 1rem;
padding: 0 2px;
border: 1px solid #64748b;
border-radius: 999px;
background: #fef9c3;
color: #111827;
font-size: 11px;
font-weight: 700;
line-height: 1;
font-family: Arial, sans-serif;
cursor: help;
user-select: none;
}
.ds-floating-tip {
position: fixed;
left: 0;
top: 0;
display: none;
max-width: min(22rem, calc(100vw - 16px));
padding: 6px 8px;
border: 1px solid #8fa0b3;
border-radius: 4px;
background: #1f2937;
color: #fff;
font-size: 11px;
font-weight: 500;
line-height: 1.35;
text-align: left;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
z-index: 9999;
pointer-events: none;
}
</style>
<?= view('components/print_header', ['printTitle' => '지정판매소 신규·취소 현황 (' . $ry . '년)']) ?>
<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 items-center gap-2">
<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="<?= esc($exportUrl) ?>" class="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="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= mgmt_url('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록으로</a>
</div>
</div>
</section>
<!-- 전체 현황 요약 -->
<div class="flex gap-4 mt-2 mb-2">
<div class="border border-gray-300 p-3 flex-1 text-center">
<div class="text-sm text-gray-500">활성 판매소</div>
<div class="text-2xl font-bold text-green-600"><?= number_format($totalActive) ?></div>
</div>
<div class="border border-gray-300 p-3 flex-1 text-center">
<div class="text-sm text-gray-500">비활성/취소 판매소</div>
<div class="text-2xl font-bold text-red-600"><?= number_format($totalInactive) ?></div>
</div>
<div class="border border-gray-300 p-3 flex-1 text-center">
<div class="text-sm text-gray-500">전체</div>
<div class="text-2xl font-bold text-gray-700"><?= number_format($totalActive + $totalInactive) ?></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<!-- 연도별 신규등록 -->
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="get" action="<?= mgmt_url('designated-shops/status') ?>" class="flex flex-wrap items-end gap-3">
<div>
<h3 class="text-sm font-bold text-gray-700 mb-1">연도별 신규등록 건수</h3>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<label class="block text-xs text-gray-600 mb-0.5">연도</label>
<select name="year" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[6rem]">
<?php foreach (($yearChoices ?? []) as $y): ?>
<option value="<?= (int) $y ?>" <?= $ry === (int) $y ? 'selected' : '' ?>><?= (int) $y ?>년</option>
<?php endforeach; ?>
</select>
</div>
<div class="min-w-[12rem]">
<label class="block text-xs text-gray-600 mb-0.5">군·구</label>
<div class="border border-gray-300 rounded px-3 py-1 text-sm bg-gray-50 text-gray-800 font-medium pointer-events-none select-none">
<?= esc($fixedGugunLabel !== '' ? $fixedGugunLabel : '현재 지자체 기준') ?>
</div>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
<p class="text-xs text-gray-500 mt-2">
종전·현행은 각각 <?= $ry - 1 ?>-12-31·<?= $ry ?>-12-31 기준 정상(지정일 이전·비정상 전환일 이후) 건수이며, 지정·취소는 <?= $ry ?>년 달력연도 기준입니다. 군·구는 현재 로그인 사용자의 지자체 기준으로 고정 표시됩니다.
</p>
</section>
<!-- 인쇄 시에도 보이는 본표 -->
<div class="mx-2 mt-2 mb-2 ds-status-x-scroll">
<table class="ds-status-table data-table">
<thead>
<tr>
<th>연도</th>
<th>신규등록 건수</th>
<th class="sticky-num text-center w-12">순번</th>
<th class="sticky-region"><?= esc($regionColLabel) ?></th>
<th class="text-left">
<span class="ds-help">구코드 <span class="ds-help-badge" tabindex="0" data-tip="지정판매소에 저장된 구·군 코드 값">?</span></span>
</th>
<th class="text-right">
<span class="ds-help">종전 <span class="ds-help-badge" tabindex="0" data-tip="전년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(전년도말)
</th>
<th class="text-right">
<span class="ds-help">지정 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 지정일이 속한 신규 지정 건수">?</span></span>(<?= $ry ?>년)
</th>
<th class="text-right">
<span class="ds-help">취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 폐업/해지 전환일이 속한 건수">?</span></span>(<?= $ry ?>년)
</th>
<th class="text-right">
<span class="ds-help">현행 <span class="ds-help-badge" tabindex="0" data-tip="조회년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(금년도말)
</th>
<th class="text-right">
<span class="ds-help">증감 <span class="ds-help-badge" tabindex="0" data-tip="현행에서 종전을 뺀 값 (현행−종전)">?</span></span>
<br/><span class="font-normal text-xs">(현행−종전)</span>
</th>
<th class="text-right">
<span class="ds-help">지정−취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 지정 건수에서 취소 건수를 뺀 값">?</span></span>
<br/><span class="font-normal text-xs">(<?= $ry ?>년)</span>
</th>
<th class="text-right">
<span class="ds-help">현행비중 <span class="ds-help-badge" tabindex="0" data-tip="전체 현행 합계 대비 해당 행 현행 건수의 비율(%)">?</span></span>
<br/><span class="font-normal text-xs">(%)</span>
</th>
<th class="text-right">
<span class="ds-help">전년대비 <span class="ds-help-badge ds-help-right" tabindex="0" data-tip="((현행−종전) / 종전) × 100, 종전이 0이면 표시 안함">?</span></span>
<br/><span class="font-normal text-xs">증감률(%)</span>
</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($newByYear as $row): ?>
<?php $rowNo = 0; ?>
<?php foreach (($districtRows ?? []) as $row): ?>
<?php
$rowNo++;
$curr = (int) $row->curr_end;
$prev = (int) $row->prev_end;
$pctShare = $sumCurrForPct > 0 ? round(($curr / $sumCurrForPct) * 100, 1) : 0.0;
$yoyPct = $prev > 0 ? round((($curr - $prev) / $prev) * 100, 1) : null;
?>
<tr>
<td class="text-center"><?= esc($row->yr) ?></td>
<td><?= number_format((int) $row->cnt) ?></td>
<td class="sticky-num"><?= $rowNo ?></td>
<td class="sticky-region" title="<?= esc($row->region_label) ?>"><?= esc($row->region_label) ?></td>
<td class="text-left text-xs"><?= esc((string) ($row->gugun_code ?? '')) ?></td>
<td><?= number_format($prev) ?></td>
<td><?= number_format((int) $row->designated_y) ?></td>
<td><?= number_format((int) $row->cancelled_y) ?></td>
<td><?= number_format($curr) ?></td>
<td><?= number_format((int) ($row->delta_curr_prev ?? 0)) ?></td>
<td><?= number_format((int) ($row->delta_des_cancel ?? 0)) ?></td>
<td><?= $pctShare ?></td>
<td><?= $yoyPct !== null ? $yoyPct : '—' ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($districtRows)): ?>
<tr><td colspan="11" class="text-center text-gray-400 py-6">조건에 맞는 데이터가 없습니다.</td></tr>
<?php endif; ?>
<?php if (! empty($districtRows) && isset($districtTotal)): ?>
<tr class="font-bold bg-gray-50 sum-row">
<td class="sticky-num">—</td>
<td class="sticky-region"><?= esc($districtTotal->region_label) ?></td>
<td class="text-left">—</td>
<td><?= number_format((int) $districtTotal->prev_end) ?></td>
<td><?= number_format((int) $districtTotal->designated_y) ?></td>
<td><?= number_format((int) $districtTotal->cancelled_y) ?></td>
<td><?= number_format((int) $districtTotal->curr_end) ?></td>
<td><?= number_format((int) ($districtTotal->delta_curr_prev ?? 0)) ?></td>
<td><?= number_format((int) ($districtTotal->delta_des_cancel ?? 0)) ?></td>
<td>100</td>
<td>
<?php
$tPrev = (int) $districtTotal->prev_end;
$tCurr = (int) $districtTotal->curr_end;
echo $tPrev > 0 ? round((($tCurr - $tPrev) / $tPrev) * 100, 1) : '—';
?>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php $zoneRows = $zoneSummaryRows ?? []; ?>
<section class="mx-2 mb-3 no-print">
<div class="text-xs font-semibold text-gray-700 mb-1">동별 현행 요약 (<?= esc($fixedGugunLabel ?? '군·구') ?>)</div>
<?php if (! empty($zoneRows)): ?>
<div class="flex flex-wrap gap-1 mb-2">
<?php foreach ($zoneRows as $z): ?>
<span class="inline-flex items-center px-2 py-0.5 text-xs rounded border border-gray-300 bg-gray-50 text-gray-700">
<?= esc((string) $z->zone_label) ?> <?= number_format((int) $z->curr_end) ?>
</span>
<?php endforeach; ?>
</div>
<div class="border border-gray-300 bg-white overflow-auto max-h-56">
<table class="w-full data-table text-xs">
<thead>
<tr>
<th class="text-left">동</th>
<th class="text-right">종전</th>
<th class="text-right">지정</th>
<th class="text-right">취소</th>
<th class="text-right">현행</th>
<th class="text-right">증감</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($zoneRows as $z): ?>
<tr>
<td class="text-left"><?= esc((string) $z->zone_label) ?></td>
<td><?= number_format((int) $z->prev_end) ?></td>
<td><?= number_format((int) $z->designated_y) ?></td>
<td><?= number_format((int) $z->cancelled_y) ?></td>
<td><?= number_format((int) $z->curr_end) ?></td>
<td><?= number_format((int) $z->delta_curr_prev) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<p class="text-xs text-gray-500">동별 집계 데이터가 없습니다.</p>
<?php endif; ?>
</section>
<div id="ds-floating-tip" class="ds-floating-tip no-print" aria-hidden="true"></div>
<script>
(function () {
var tipEl = document.getElementById('ds-floating-tip');
if (!tipEl) return;
var badges = Array.prototype.slice.call(document.querySelectorAll('.ds-help-badge'));
if (!badges.length) return;
function placeTip(target) {
var text = String(target.getAttribute('data-tip') || '').trim();
if (!text) return;
tipEl.textContent = text;
tipEl.style.display = 'block';
tipEl.setAttribute('aria-hidden', 'false');
var rect = target.getBoundingClientRect();
var tipRect = tipEl.getBoundingClientRect();
var vw = window.innerWidth || document.documentElement.clientWidth;
var vh = window.innerHeight || document.documentElement.clientHeight;
var gap = 8;
var left = rect.left + (rect.width / 2) - (tipRect.width / 2);
var top = rect.bottom + gap;
if (left < gap) left = gap;
if (left + tipRect.width > vw - gap) left = vw - gap - tipRect.width;
if (top + tipRect.height > vh - gap) top = rect.top - gap - tipRect.height;
if (top < gap) top = gap;
tipEl.style.left = Math.round(left) + 'px';
tipEl.style.top = Math.round(top) + 'px';
}
function hideTip() {
tipEl.style.display = 'none';
tipEl.setAttribute('aria-hidden', 'true');
tipEl.textContent = '';
}
badges.forEach(function (badge) {
badge.addEventListener('mouseenter', function () { placeTip(badge); });
badge.addEventListener('focus', function () { placeTip(badge); });
badge.addEventListener('mouseleave', hideTip);
badge.addEventListener('blur', hideTip);
});
window.addEventListener('scroll', hideTip, true);
window.addEventListener('resize', hideTip);
})();
</script>
<details class="mx-2 mb-4 no-print text-sm">
<summary class="cursor-pointer text-gray-600 hover:text-gray-800">연도별 요약 (참고)</summary>
<div class="flex gap-4 mt-2">
<div class="border border-gray-300 p-2 flex-1">
<div class="text-xs font-bold text-gray-700 mb-1">활성 / 비활성 / 전체</div>
<div class="text-sm">활성 <?= number_format((int) ($totalActive ?? 0)) ?> · 비활성 <?= number_format((int) ($totalInactive ?? 0)) ?> · 합 <?= number_format((int) ($totalActive ?? 0) + (int) ($totalInactive ?? 0)) ?></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<div>
<h3 class="text-xs font-bold text-gray-700 mb-1">연도별 신규등록 (지정일)</h3>
<div class="border border-gray-300 overflow-auto max-h-48">
<table class="w-full data-table text-xs">
<thead><tr><th>연도</th><th>건수</th></tr></thead>
<tbody class="text-right">
<?php foreach (($newByYear ?? []) as $row): ?>
<tr><td class="text-center"><?= esc($row->yr) ?>년</td><td><?= number_format((int) $row->cnt) ?></td></tr>
<?php endforeach; ?>
<?php if (empty($newByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<tr><td colspan="2" class="text-center text-gray-400 py-2">없음</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- 연도별 취소/비활성 -->
<div>
<h3 class="text-sm font-bold text-gray-700 mb-1">연도별 취소/비활성 건수</h3>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>연도</th>
<th>취소/비활성 건수</th>
</tr>
</thead>
<h3 class="text-xs font-bold text-gray-700 mb-1">연도별 취소/비활성 (등록일 기준)</h3>
<div class="border border-gray-300 overflow-auto max-h-48">
<table class="w-full data-table text-xs">
<thead><tr><th>연도</th><th>건수</th></tr></thead>
<tbody class="text-right">
<?php foreach ($cancelByYear as $row): ?>
<tr>
<td class="text-center"><?= esc($row->yr) ?>년</td>
<td><?= number_format((int) $row->cnt) ?></td>
</tr>
<?php foreach (($cancelByYear ?? []) as $row): ?>
<tr><td class="text-center"><?= esc($row->yr) ?>년</td><td><?= number_format((int) $row->cnt) ?></td></tr>
<?php endforeach; ?>
<?php if (empty($cancelByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<tr><td colspan="2" class="text-center text-gray-400 py-2">없음</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</details>

View File

@@ -60,7 +60,12 @@ const BAG_PATHS = [
'/bag/free-recipients',
'/bag/free-recipients/create',
'/bag/designated-shops',
'/bag/designated-shops/browse',
'/bag/designated-shops/status',
'/bag/designated-shops/status/export?year=2026&ds_gugun_code=&granularity=gugun',
'/bag/designated-shops/district-new-cancel',
'/bag/designated-shops/district-new-cancel/export?year=2026',
'/bag/designated-shops/barcode',
'/bag/bag-prices',
'/bag/bag-prices/create',
'/bag/packaging-units/manage',

View File

@@ -85,13 +85,15 @@ test.describe('P2-15: 지정판매소 다조건 조회', () => {
await loginAsLocal(page);
await page.goto('/bag/designated-shops?ds_name=CU');
await expect(page).toHaveURL(/ds_name=CU/);
await expect(page.locator('table.data-table')).toBeVisible();
await expect(page.locator('input[name="ds_name"]')).toHaveValue('CU');
await expect(page.locator('#ds-list-body')).toBeAttached();
});
test('상태 필터', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/bag/designated-shops?ds_state=1');
await expect(page.locator('table.data-table')).toBeVisible();
await expect(page.locator('select[name="ds_state"]')).toHaveValue('1');
await expect(page.locator('#ds-list-body')).toBeAttached();
});
test('검색 폼에서 이름 입력 후 조회', async ({ page }) => {
@@ -126,6 +128,17 @@ test.describe('P2-18: 지정판매소 현황', () => {
await page.goto('/bag/designated-shops/status');
await expect(page).toHaveURL(/\/status/);
await expect(page.locator('table.data-table').first()).toBeVisible();
await expect(page.getByRole('columnheader', { name: '종전(전년도말)' })).toBeVisible();
await expect(page.getByRole('link', { name: '엑셀저장' })).toBeVisible();
});
test('GBMS형 신규/취소 현황(구·군 고정)', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/bag/designated-shops/district-new-cancel');
await expect(page).toHaveURL(/district-new-cancel/);
await expect(page.locator('.gbms-dnc-table')).toBeVisible();
await expect(page.getByRole('columnheader', { name: '군·구' })).toBeVisible();
await expect(page.getByRole('link', { name: '엑셀저장' })).toBeVisible();
});
});
@@ -279,9 +292,9 @@ test.describe('사이트 메뉴 CRUD 동작', () => {
test.describe('엑셀 내보내기 다운로드', () => {
test('지정판매소 엑셀', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/bag/designated-shops');
await page.goto('/bag/designated-shops/browse');
const downloadPromise = page.waitForEvent('download', { timeout: 10000 });
await page.locator('a[href*="export"]').first().click();
await page.locator('a[href*="designated-shops/export"]').first().click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('.csv');
});

View File

@@ -0,0 +1,7 @@
-- 기존 DB: '지정판매소 바코드 출력' 메뉴를 전용 URL로 변경
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
UPDATE `menu` m
INNER JOIN `menu_type` t ON t.mt_idx = m.mt_idx AND t.mt_code = 'site'
SET m.mm_link = 'bag/designated-shops/barcode'
WHERE m.mm_name = '지정판매소 바코드 출력';

View File

@@ -0,0 +1,10 @@
-- 기존 DB: 지정 판매소 신규/취소 현황 메뉴를 GBMS형 전용 URL로 변경
-- UTF-8: mysql --default-character-set=utf8mb4 ...
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
UPDATE `menu` m
INNER JOIN `menu_type` t ON t.mt_idx = m.mt_idx AND t.mt_code = 'site'
SET m.mm_link = 'bag/designated-shops/district-new-cancel'
WHERE m.mm_name IN ('지정 판매소 신규/취소 현황', '지정 판매소 현황')
AND m.mm_link IN ('bag/designated-shops/status', '');

View File

@@ -19,12 +19,12 @@ SET m.mm_link = CASE m.mm_name
WHEN '업체 관리' THEN 'bag/companies'
WHEN '무료용 대상자 관리' THEN 'bag/free-recipients'
WHEN '지정 판매소 관리' THEN 'bag/designated-shops'
WHEN '지정 판매소 조회' THEN 'bag/designated-shops'
WHEN '지정 판매소 신규/취소 현황' THEN 'bag/designated-shops/status'
WHEN '지정판매소 바코드 출력' THEN 'bag/designated-shops'
WHEN '지정 판매소 조회' THEN 'bag/designated-shops/browse'
WHEN '지정 판매소 신규/취소 현황' THEN 'bag/designated-shops/district-new-cancel'
WHEN '지정판매소 바코드 출력' THEN 'bag/designated-shops/barcode'
WHEN 'PASSWORD 변경' THEN 'bag/password-change'
WHEN '환경 설정' THEN 'dashboard'
WHEN '지정 판매소 현황' THEN 'bag/designated-shops/status'
WHEN '지정 판매소 현황' THEN 'bag/designated-shops/district-new-cancel'
WHEN '발주 등록' THEN 'bag/order/create'
WHEN '발주 변경' THEN 'bag/bag-orders'
WHEN '발주 현황' THEN 'bag/bag-orders'

View File

@@ -33,12 +33,12 @@ SELECT @mt_site, 1, t.mm_name,
WHEN '업체 관리' THEN 'bag/companies'
WHEN '무료용 대상자 관리' THEN 'bag/free-recipients'
WHEN '지정 판매소 관리' THEN 'bag/designated-shops'
WHEN '지정 판매소 조회' THEN 'bag/designated-shops'
WHEN '지정 판매소 신규/취소 현황' THEN 'bag/designated-shops/status'
WHEN '지정판매소 바코드 출력' THEN 'bag/designated-shops'
WHEN '지정 판매소 조회' THEN 'bag/designated-shops/browse'
WHEN '지정 판매소 신규/취소 현황' THEN 'bag/designated-shops/district-new-cancel'
WHEN '지정판매소 바코드 출력' THEN 'bag/designated-shops/barcode'
WHEN 'PASSWORD 변경' THEN 'bag/password-change'
WHEN '환경 설정' THEN 'dashboard'
WHEN '지정 판매소 현황' THEN 'bag/designated-shops/status'
WHEN '지정 판매소 현황' THEN 'bag/designated-shops/district-new-cancel'
ELSE ''
END,
@parent_basic, 1, t.mm_num, 0, '', 'Y'

View File

@@ -0,0 +1,64 @@
-- 지정판매소 신규/취소 현황(연도별) 테스트 데이터
-- 목적: district-new-cancel/status 화면에서 2022~2025 연도 전환 테스트
-- 기본값: ds_lg_idx=1, ds_gugun_code=110209(북구). 환경에 맞게 값 변경 후 실행하세요.
-- 실행 예:
-- mysql -h 127.0.0.1 -u jongryangje -p jongryangje_dev < writable/database/seed_designated_shops_status_multi_years.sql
SET NAMES utf8mb4;
-- 재실행 가능하도록 테스트 prefix 데이터만 정리
DELETE FROM `designated_shop` WHERE `ds_shop_no` LIKE 'ZZSTAT-%';
INSERT INTO `designated_shop` (
`ds_lg_idx`, `ds_mb_idx`, `ds_shop_no`, `ds_name`, `ds_biz_no`, `ds_rep_name`,
`ds_va_number`, `ds_zip`, `ds_addr`, `ds_addr_jibun`, `ds_addr_detail`,
`ds_tel`, `ds_rep_phone`, `ds_email`,
`ds_gugun_code`, `ds_zone_code`,
`ds_designated_at`, `ds_state`, `ds_state_changed_at`, `ds_change_reason`, `ds_regdate`
) VALUES
-- 2022 지정 2건
(1, NULL, 'ZZSTAT-2201', '현황테스트 A', '901-22-00001', '홍길동', '', '41590', '대구광역시 북구 테스트로 2201', '대구 북구 테스트동 2201', '101호', '053-220-0001', '01022000001', 'zzstat2201@test.local', '110209', '북구-A', '2022-03-01', 1, NULL, '', '2022-03-01 09:00:00'),
(1, NULL, 'ZZSTAT-2202', '현황테스트 J', '901-22-00002', '김현황', '', '41590', '대구광역시 북구 테스트로 2202', '대구 북구 테스트동 2202', '102호', '053-220-0002', '01022000002', 'zzstat2202@test.local', '110209', '북구-A', '2022-08-08', 2, '2023-12-20', '테스트 취소(2023)', '2022-08-08 09:00:00'),
-- 2023 지정 2건 (이 중 1건은 2024에 취소)
(1, NULL, 'ZZSTAT-2301', '현황테스트 B', '901-23-00001', '이현황', '', '41590', '대구광역시 북구 테스트로 2301', '대구 북구 테스트동 2301', '201호', '053-230-0001', '01023000001', 'zzstat2301@test.local', '110209', '북구-B', '2023-04-10', 1, NULL, '', '2023-04-10 09:00:00'),
(1, NULL, 'ZZSTAT-2302', '현황테스트 C', '901-23-00002', '박현황', '', '41590', '대구광역시 북구 테스트로 2302', '대구 북구 테스트동 2302', '202호', '053-230-0002', '01023000002', 'zzstat2302@test.local', '110209', '북구-B', '2023-07-01', 2, '2024-05-02', '테스트 취소(2024)', '2023-07-01 09:00:00'),
-- 2024 지정 4건 (이 중 1건은 2024에 취소)
(1, NULL, 'ZZSTAT-2401', '현황테스트 D', '901-24-00001', '최현황', '', '41590', '대구광역시 북구 테스트로 2401', '대구 북구 테스트동 2401', '301호', '053-240-0001', '01024000001', 'zzstat2401@test.local', '110209', '북구-C', '2024-01-15', 1, NULL, '', '2024-01-15 09:00:00'),
(1, NULL, 'ZZSTAT-2402', '현황테스트 E', '901-24-00002', '정현황', '', '41590', '대구광역시 북구 테스트로 2402', '대구 북구 테스트동 2402', '302호', '053-240-0002', '01024000002', 'zzstat2402@test.local', '110209', '북구-C', '2024-06-20', 1, NULL, '', '2024-06-20 09:00:00'),
(1, NULL, 'ZZSTAT-2403', '현황테스트 F', '901-24-00003', '강현황', '', '41590', '대구광역시 북구 테스트로 2403', '대구 북구 테스트동 2403', '303호', '053-240-0003', '01024000003', 'zzstat2403@test.local', '110209', '북구-D', '2024-09-01', 3, '2024-12-01', '테스트 해지(2024)', '2024-09-01 09:00:00'),
(1, NULL, 'ZZSTAT-2404', '현황테스트 G', '901-24-00004', '신현황', '', '41590', '대구광역시 북구 테스트로 2404', '대구 북구 테스트동 2404', '304호', '053-240-0004', '01024000004', 'zzstat2404@test.local', '110209', '북구-D', '2024-11-12', 1, NULL, '', '2024-11-12 09:00:00'),
-- 2025 지정 2건 (이 중 1건은 2025에 취소)
(1, NULL, 'ZZSTAT-2501', '현황테스트 H', '901-25-00001', '오현황', '', '41590', '대구광역시 북구 테스트로 2501', '대구 북구 테스트동 2501', '401호', '053-250-0001', '01025000001', 'zzstat2501@test.local', '110209', '북구-E', '2025-02-01', 1, NULL, '', '2025-02-01 09:00:00'),
(1, NULL, 'ZZSTAT-2502', '현황테스트 I', '901-25-00002', '유현황', '', '41590', '대구광역시 북구 테스트로 2502', '대구 북구 테스트동 2502', '402호', '053-250-0002', '01025000002', 'zzstat2502@test.local', '110209', '북구-E', '2025-03-20', 2, '2025-10-15', '테스트 취소(2025)', '2025-03-20 09:00:00');
-- 참고: 추가된 "지정" 건수(연도별)
-- 2022: 2건 / 2023: 2건 / 2024: 4건 / 2025: 2건
-- 검증 1) prefix 데이터의 연도별 지정/취소 건수
-- SELECT
-- YEAR(ds_designated_at) AS yr,
-- COUNT(*) AS designated_cnt,
-- SUM(CASE WHEN ds_state IN (2,3) AND ds_state_changed_at IS NOT NULL THEN YEAR(ds_state_changed_at)=YEAR(ds_designated_at) ELSE 0 END) AS same_year_cancel_cnt
-- FROM designated_shop
-- WHERE ds_shop_no LIKE 'ZZSTAT-%'
-- GROUP BY YEAR(ds_designated_at)
-- ORDER BY yr;
-- 검증 2) 2024 현황 기대값(prefix 데이터만)
-- 종전(2023말)=3, 지정(2024)=4, 취소(2024)=2, 현행(2024말)=5
-- SELECT
-- SUM(CASE
-- WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= '2023-12-31'
-- AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > '2023-12-31'))
-- THEN 1 ELSE 0 END) AS prev_end_2023,
-- SUM(CASE WHEN YEAR(COALESCE(ds_designated_at, DATE(ds_regdate))) = 2024 THEN 1 ELSE 0 END) AS designated_2024,
-- SUM(CASE WHEN ds_state IN (2,3) AND YEAR(COALESCE(ds_state_changed_at, DATE(ds_regdate))) = 2024 THEN 1 ELSE 0 END) AS cancelled_2024,
-- SUM(CASE
-- WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= '2024-12-31'
-- AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > '2024-12-31'))
-- THEN 1 ELSE 0 END) AS curr_end_2024
-- FROM designated_shop
-- WHERE ds_shop_no LIKE 'ZZSTAT-%';