Compare commits
8 Commits
794d34635b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
215d87f991 | ||
|
|
dc5c38d242 | ||
|
|
6c421153e4 | ||
|
|
5e8e81f404 | ||
|
|
c5df784f91 | ||
|
|
a3f3e9cd64 | ||
|
|
f624c13b5b | ||
|
|
b814892352 |
86
app/Commands/ProtectDesignatedShops.php
Normal file
86
app/Commands/ProtectDesignatedShops.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
use Config\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지정판매소 PII 일괄 암호화 + 블라인드 인덱스 백필 (설계안 P3·P4).
|
||||||
|
*
|
||||||
|
* ⚠️ 운영 데이터를 변경하는 비가역적 작업. 반드시 DB 백업 후 실행.
|
||||||
|
* php spark pii:protect-designated-shops --dry (미리보기: 변경 안 함)
|
||||||
|
* php spark pii:protect-designated-shops --force (실제 실행)
|
||||||
|
*
|
||||||
|
* - 멱등: 이미 ENC: 인 필드는 건너뜀. 두 번 돌려도 안전.
|
||||||
|
* - 앱 encrypter(.env encryption.key) 사용 → 반드시 키가 설정돼 있어야 함.
|
||||||
|
*/
|
||||||
|
class ProtectDesignatedShops extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'PII';
|
||||||
|
protected $name = 'pii:protect-designated-shops';
|
||||||
|
protected $description = '지정판매소 PII를 암호화하고 블라인드 인덱스를 백필합니다(비가역적, 백업 필수).';
|
||||||
|
protected $usage = 'pii:protect-designated-shops [--dry|--force]';
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
helper('pii_encryption');
|
||||||
|
$dry = array_key_exists('dry', $params) || in_array('--dry', $_SERVER['argv'] ?? [], true);
|
||||||
|
$force = array_key_exists('force', $params) || in_array('--force', $_SERVER['argv'] ?? [], true);
|
||||||
|
|
||||||
|
if (! $dry && ! $force) {
|
||||||
|
CLI::error('안전을 위해 --dry(미리보기) 또는 --force(실행) 중 하나가 필요합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = config('Encryption');
|
||||||
|
if ((string) $cfg->key === '') {
|
||||||
|
CLI::error('encryption.key 가 비어 있습니다. .env 설정 후 실행하세요. (키 없이 암호화 불가)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
$piiFields = ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
|
||||||
|
$hasTelBidx = $db->fieldExists('ds_tel_bidx', 'designated_shop');
|
||||||
|
$hasNameBidx = $db->fieldExists('ds_rep_name_bidx', 'designated_shop');
|
||||||
|
|
||||||
|
$rows = $db->table('designated_shop')->select('ds_idx, ' . implode(', ', $piiFields))->get()->getResultArray();
|
||||||
|
CLI::write('대상 행: ' . count($rows) . ($dry ? ' [DRY-RUN: 변경 없음]' : ' [실제 실행]'), $dry ? 'yellow' : 'green');
|
||||||
|
|
||||||
|
$encCount = 0; $rowCount = 0;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$update = [];
|
||||||
|
foreach ($piiFields as $f) {
|
||||||
|
$v = (string) ($r[$f] ?? '');
|
||||||
|
if ($v !== '' && strpos($v, 'ENC:') !== 0) {
|
||||||
|
$update[$f] = pii_encrypt($v);
|
||||||
|
$encCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 블라인드 인덱스(원본값 기준)
|
||||||
|
if ($hasTelBidx) {
|
||||||
|
$update['ds_tel_bidx'] = pii_blind_index((string) ($r['ds_tel'] ?? ''));
|
||||||
|
}
|
||||||
|
if ($hasNameBidx) {
|
||||||
|
$update['ds_rep_name_bidx'] = pii_blind_index((string) ($r['ds_rep_name'] ?? ''));
|
||||||
|
}
|
||||||
|
if ($update === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rowCount++;
|
||||||
|
if (! $dry) {
|
||||||
|
$db->table('designated_shop')->where('ds_idx', (int) $r['ds_idx'])->update($update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write('변경 대상 행: ' . $rowCount . ' / 암호화 필드: ' . $encCount, 'green');
|
||||||
|
if ($dry) {
|
||||||
|
CLI::write('DRY-RUN 이었습니다. 실제 반영하려면 --force 로 다시 실행하세요.', 'yellow');
|
||||||
|
} else {
|
||||||
|
CLI::write('완료. (멱등: 재실행해도 이미 암호화된 값은 건너뜁니다)', 'green');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Config/Pii.php
Normal file
32
app/Config/Pii.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Config;
|
||||||
|
|
||||||
|
use CodeIgniter\Config\BaseConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개인정보 비식별화·암호화 설정.
|
||||||
|
* 마스터 플래그가 OFF이면 현재 동작(원문 그대로) 유지 — 준비 완료 후 .env에서 켠다.
|
||||||
|
* .env: pii.designatedShopProtection = true
|
||||||
|
*/
|
||||||
|
class Pii extends BaseConfig
|
||||||
|
{
|
||||||
|
/** 지정판매소 PII 보호(마스킹·암호화·게이트) 마스터 스위치. 기본 OFF(안전). */
|
||||||
|
public bool $designatedShopProtection = false;
|
||||||
|
|
||||||
|
/** 지정판매소 PII 필드 (암호화 저장 + 마스킹 대상) */
|
||||||
|
public array $designatedShopPiiFields = [
|
||||||
|
'ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 저장 계층 암호화 사용 여부 (P3). 마이그레이션·백업 후 .env에서 켠다. */
|
||||||
|
public bool $encryptAtRest = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 블라인드 인덱스 HMAC 키 (.env: pii.blindIndexKey).
|
||||||
|
* encryption.key 와 별도 키 권장. 비어 있으면 검색 인덱스 생성/사용 안 함.
|
||||||
|
*/
|
||||||
|
public string $blindIndexKey = '';
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ $routes->get('bag/issue', 'Bag::issueLegacy');
|
|||||||
$routes->get('bag/issue/cancel', 'Bag::issue');
|
$routes->get('bag/issue/cancel', 'Bag::issue');
|
||||||
$routes->get('bag/inventory', 'Bag::inventory');
|
$routes->get('bag/inventory', 'Bag::inventory');
|
||||||
$routes->get('bag/inventory/export', 'Bag::inventoryExport');
|
$routes->get('bag/inventory/export', 'Bag::inventoryExport');
|
||||||
|
$routes->get('bag/inventory/stock-barcode-list', 'Bag::stockBarcodeList'); // 실사 재고 바코드 리스트(창고에 있어야 하는 번호)
|
||||||
$routes->get('bag/inventory/inspection-select', 'Bag::inspectionSelect');
|
$routes->get('bag/inventory/inspection-select', 'Bag::inspectionSelect');
|
||||||
$routes->get('bag/inventory/inspection-work', 'Bag::inspectionWork');
|
$routes->get('bag/inventory/inspection-work', 'Bag::inspectionWork');
|
||||||
$routes->post('bag/inventory/inspection-run', 'Bag::inspectionRun');
|
$routes->post('bag/inventory/inspection-run', 'Bag::inspectionRun');
|
||||||
@@ -64,6 +65,7 @@ $routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void
|
|||||||
$routes->get('manual/(:segment)', 'Bag::manualPage/$1');
|
$routes->get('manual/(:segment)', 'Bag::manualPage/$1');
|
||||||
$routes->post('activity/print-log', 'Bag::printLog'); // 인쇄 활동 기록(beacon)
|
$routes->post('activity/print-log', 'Bag::printLog'); // 인쇄 활동 기록(beacon)
|
||||||
$routes->post('pref/font-scale', 'Bag::saveFontScale'); // 계정별·메뉴별 글자크기 저장
|
$routes->post('pref/font-scale', 'Bag::saveFontScale'); // 계정별·메뉴별 글자크기 저장
|
||||||
|
$routes->post('pref/workspace-tabs', 'Bag::saveWorkspaceTabs'); // 계정별 워크스페이스 탭 상태 저장(자동 복원)
|
||||||
});
|
});
|
||||||
|
|
||||||
$routes->get('bag/number-lookup', 'Bag::numberLookup');
|
$routes->get('bag/number-lookup', 'Bag::numberLookup');
|
||||||
@@ -80,6 +82,7 @@ $routes->get('bag/order/phone', 'Bag::phoneOrderCreate');
|
|||||||
$routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage');
|
$routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage');
|
||||||
$routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate');
|
$routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate');
|
||||||
$routes->post('bag/order/phone/manage/scan', 'Bag::phoneOrderManageScan');
|
$routes->post('bag/order/phone/manage/scan', 'Bag::phoneOrderManageScan');
|
||||||
|
$routes->post('bag/order/phone/manage/paid', 'Bag::phoneOrderMarkPaid');
|
||||||
$routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1');
|
$routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1');
|
||||||
$routes->get('bag/order/phone/receipt/(:num)', 'Bag::phoneOrderReceipt/$1');
|
$routes->get('bag/order/phone/receipt/(:num)', 'Bag::phoneOrderReceipt/$1');
|
||||||
$routes->get('bag/order/change', 'Bag::orderChange');
|
$routes->get('bag/order/change', 'Bag::orderChange');
|
||||||
@@ -159,6 +162,7 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
|
|||||||
$routes->get('designated-shops', 'Admin\DesignatedShop::index');
|
$routes->get('designated-shops', 'Admin\DesignatedShop::index');
|
||||||
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
|
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
|
||||||
$routes->post('designated-shops/resolve-address-codes', 'Admin\DesignatedShop::resolveAddressCodes');
|
$routes->post('designated-shops/resolve-address-codes', 'Admin\DesignatedShop::resolveAddressCodes');
|
||||||
|
$routes->post('designated-shops/reveal-pii', 'Admin\DesignatedShop::revealPii');
|
||||||
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store');
|
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store');
|
||||||
$routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1');
|
$routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1');
|
||||||
$routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1');
|
$routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1');
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ class DesignatedShop extends BaseController
|
|||||||
/**
|
/**
|
||||||
* 목록 검색과 동일한 조건을 모델 쿼리에 적용한다.
|
* 목록 검색과 동일한 조건을 모델 쿼리에 적용한다.
|
||||||
*/
|
*/
|
||||||
private function applyDesignatedShopListFilters(DesignatedShopModel $model, int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState): void
|
private function applyDesignatedShopListFilters(DesignatedShopModel $model, int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState, ?string $dsRep = null, ?string $dsTel = null): void
|
||||||
{
|
{
|
||||||
$model->where('ds_lg_idx', $lgIdx);
|
$model->where('ds_lg_idx', $lgIdx);
|
||||||
if ($dsName !== null && $dsName !== '') {
|
if ($dsName !== null && $dsName !== '') {
|
||||||
@@ -186,12 +186,47 @@ class DesignatedShop extends BaseController
|
|||||||
if ($dsState !== null && $dsState !== '') {
|
if ($dsState !== null && $dsState !== '') {
|
||||||
$model->where('ds_state', (int) $dsState);
|
$model->where('ds_state', (int) $dsState);
|
||||||
}
|
}
|
||||||
|
$this->applyPiiSearchFilter($model, $dsRep, $dsTel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대표자명·전화 검색(정확일치). 블라인드 인덱스 컬럼+키가 있으면 HMAC 정확일치,
|
||||||
|
* 없으면(플래그 OFF/미마이그레이션) 평문 정확일치로 폴백.
|
||||||
|
*
|
||||||
|
* @param DesignatedShopModel|\CodeIgniter\Database\BaseBuilder $q
|
||||||
|
*/
|
||||||
|
private function applyPiiSearchFilter($q, ?string $dsRep, ?string $dsTel): void
|
||||||
|
{
|
||||||
|
$dsRep = $dsRep !== null ? trim($dsRep) : '';
|
||||||
|
$dsTel = $dsTel !== null ? trim($dsTel) : '';
|
||||||
|
if ($dsRep === '' && $dsTel === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
helper('pii_encryption');
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
if ($dsRep !== '') {
|
||||||
|
$bidx = pii_blind_index($dsRep);
|
||||||
|
if ($bidx !== '' && $db->fieldExists('ds_rep_name_bidx', 'designated_shop')) {
|
||||||
|
$q->where('ds_rep_name_bidx', $bidx);
|
||||||
|
} else {
|
||||||
|
$q->where('ds_rep_name', $dsRep); // 평문 폴백(정확일치)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($dsTel !== '') {
|
||||||
|
$bidx = pii_blind_index($dsTel);
|
||||||
|
if ($bidx !== '' && $db->fieldExists('ds_tel_bidx', 'designated_shop')) {
|
||||||
|
$q->where('ds_tel_bidx', $bidx);
|
||||||
|
} else {
|
||||||
|
$q->where('ds_tel', $dsTel);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{1: int, 2: int, 3: int, total: int}
|
* @return array{1: int, 2: int, 3: int, total: int}
|
||||||
*/
|
*/
|
||||||
private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState): array
|
private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState, ?string $dsRep = null, ?string $dsTel = null): array
|
||||||
{
|
{
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$builder = $db->table('designated_shop');
|
$builder = $db->table('designated_shop');
|
||||||
@@ -205,6 +240,7 @@ class DesignatedShop extends BaseController
|
|||||||
if ($dsState !== null && $dsState !== '') {
|
if ($dsState !== null && $dsState !== '') {
|
||||||
$builder->where('ds_state', (int) $dsState);
|
$builder->where('ds_state', (int) $dsState);
|
||||||
}
|
}
|
||||||
|
$this->applyPiiSearchFilter($builder, $dsRep, $dsTel);
|
||||||
$rows = $builder->select('ds_state, COUNT(*) AS cnt', false)
|
$rows = $builder->select('ds_state, COUNT(*) AS cnt', false)
|
||||||
->groupBy('ds_state')
|
->groupBy('ds_state')
|
||||||
->get()
|
->get()
|
||||||
@@ -229,7 +265,7 @@ class DesignatedShop extends BaseController
|
|||||||
*/
|
*/
|
||||||
private function buildDesignatedShopDetailPayload(array $list, array $lgMap, array $dongMap = []): array
|
private function buildDesignatedShopDetailPayload(array $list, array $lgMap, array $dongMap = []): array
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper(['admin', 'pii_encryption']);
|
||||||
$lgIdx = admin_effective_lg_idx() ?? 0;
|
$lgIdx = admin_effective_lg_idx() ?? 0;
|
||||||
$gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : [];
|
$gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : [];
|
||||||
$payload = [];
|
$payload = [];
|
||||||
@@ -258,7 +294,7 @@ class DesignatedShop extends BaseController
|
|||||||
$dongDisplay = $gugunName;
|
$dongDisplay = $gugunName;
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload[] = [
|
$p = [
|
||||||
'ds_idx' => (int) $row->ds_idx,
|
'ds_idx' => (int) $row->ds_idx,
|
||||||
'ds_shop_no' => $sn,
|
'ds_shop_no' => $sn,
|
||||||
'shop_no_display' => $shortNo,
|
'shop_no_display' => $shortNo,
|
||||||
@@ -291,7 +327,18 @@ class DesignatedShop extends BaseController
|
|||||||
'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'),
|
'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'),
|
||||||
'ds_regdate' => (string) ($row->ds_regdate ?? ''),
|
'ds_regdate' => (string) ($row->ds_regdate ?? ''),
|
||||||
'lg_name' => $lgMap[(int) ($row->ds_lg_idx ?? 0)] ?? '',
|
'lg_name' => $lgMap[(int) ($row->ds_lg_idx ?? 0)] ?? '',
|
||||||
|
'pii_masked' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 개인정보 마스킹(표시 계층 단일 지점) — 열람 권한 없으면 PII 필드 마스킹
|
||||||
|
if (! can_view_shop_pii((int) ($row->ds_lg_idx ?? 0))) {
|
||||||
|
foreach (['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account', 'ds_va_number'] as $f) {
|
||||||
|
$p[$f] = mask_shop_field($f, (string) $p[$f]);
|
||||||
|
}
|
||||||
|
$p['pii_masked'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload[] = $p;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $payload;
|
return $payload;
|
||||||
@@ -311,11 +358,13 @@ class DesignatedShop extends BaseController
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다조건 검색 (P2-15)
|
// 다조건 검색 (P2-15) + 대표자·전화 정확일치(블라인드 인덱스)
|
||||||
$dsName = $this->request->getGet('ds_name');
|
$dsName = $this->request->getGet('ds_name');
|
||||||
$dsGugunCode = $this->request->getGet('ds_gugun_code');
|
$dsGugunCode = $this->request->getGet('ds_gugun_code');
|
||||||
$dsState = $this->request->getGet('ds_state');
|
$dsState = $this->request->getGet('ds_state');
|
||||||
$this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState);
|
$dsRep = $this->request->getGet('ds_rep');
|
||||||
|
$dsTel = $this->request->getGet('ds_tel');
|
||||||
|
$this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel);
|
||||||
|
|
||||||
// 페이징 대신 전체 목록을 한 번에 로드 — 리스트 패널 내부 스크롤로 표시
|
// 페이징 대신 전체 목록을 한 번에 로드 — 리스트 패널 내부 스크롤로 표시
|
||||||
$list = $this->shopModel->orderBy('ds_idx', 'DESC')->findAll();
|
$list = $this->shopModel->orderBy('ds_idx', 'DESC')->findAll();
|
||||||
@@ -327,7 +376,7 @@ class DesignatedShop extends BaseController
|
|||||||
$lgMap[$lg->lg_idx] = $lg->lg_name;
|
$lgMap[$lg->lg_idx] = $lg->lg_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState);
|
$stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel);
|
||||||
$gugunNameMap = $this->gugunCodeNameMap($lgIdx);
|
$gugunNameMap = $this->gugunCodeNameMap($lgIdx);
|
||||||
|
|
||||||
// 각 판매소의 동코드를 주소로 산출 → (컬럼이 있으면) 저장 → 상세 표시에 사용
|
// 각 판매소의 동코드를 주소로 산출 → (컬럼이 있으면) 저장 → 상세 표시에 사용
|
||||||
@@ -356,6 +405,8 @@ class DesignatedShop extends BaseController
|
|||||||
'dsName' => $dsName ?? '',
|
'dsName' => $dsName ?? '',
|
||||||
'dsGugunCode' => $dsGugunCode ?? '',
|
'dsGugunCode' => $dsGugunCode ?? '',
|
||||||
'dsState' => $dsState ?? '',
|
'dsState' => $dsState ?? '',
|
||||||
|
'dsRep' => $dsRep ?? '',
|
||||||
|
'dsTel' => $dsTel ?? '',
|
||||||
'gugunCodes' => $gugunCodes,
|
'gugunCodes' => $gugunCodes,
|
||||||
'stateCounts' => $stateCounts,
|
'stateCounts' => $stateCounts,
|
||||||
'gugunNameMap' => $gugunNameMap,
|
'gugunNameMap' => $gugunNameMap,
|
||||||
@@ -508,6 +559,7 @@ class DesignatedShop extends BaseController
|
|||||||
return redirect()->to(mgmt_url('designated-shops'))->with('error', '지자체를 선택해 주세요.');
|
return redirect()->to(mgmt_url('designated-shops'))->with('error', '지자체를 선택해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
helper('pii_encryption');
|
||||||
$list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll();
|
$list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll();
|
||||||
|
|
||||||
// 동코드(구·군 동코드) 산출
|
// 동코드(구·군 동코드) 산출
|
||||||
@@ -522,19 +574,30 @@ class DesignatedShop extends BaseController
|
|||||||
$dongDisp = $dCode !== '' ? ($dCode . ($dName !== '' ? ' (' . $dName . ')' : '')) : '';
|
$dongDisp = $dCode !== '' ? ($dCode . ($dName !== '' ? ' (' . $dName . ')' : '')) : '';
|
||||||
$da = $row->ds_designated_at ?? null;
|
$da = $row->ds_designated_at ?? null;
|
||||||
$daDisp = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : '';
|
$daDisp = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : '';
|
||||||
|
|
||||||
|
// 엑셀에도 마스킹 적용(열람 권한 없으면)
|
||||||
|
$canView = can_view_shop_pii((int) ($row->ds_lg_idx ?? 0));
|
||||||
|
$repName = (string) ($row->ds_rep_name ?? '');
|
||||||
|
$telVal = (string) ($row->ds_tel ?? '');
|
||||||
|
$vaAccount = $this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : (string) ($row->ds_va_number ?? '');
|
||||||
|
if (! $canView) {
|
||||||
|
$repName = mask_shop_field('ds_rep_name', $repName);
|
||||||
|
$telVal = mask_shop_field('ds_tel', $telVal);
|
||||||
|
$vaAccount = mask_shop_field('ds_va_account', $vaAccount);
|
||||||
|
}
|
||||||
$rows[] = [
|
$rows[] = [
|
||||||
$row->ds_idx,
|
$row->ds_idx,
|
||||||
$row->ds_shop_no,
|
$row->ds_shop_no,
|
||||||
$row->ds_name,
|
$row->ds_name,
|
||||||
$row->ds_rep_name,
|
$repName,
|
||||||
$dongDisp,
|
$dongDisp,
|
||||||
$daDisp,
|
$daDisp,
|
||||||
$row->ds_biz_no,
|
$row->ds_biz_no,
|
||||||
$this->designatedShopScalar($row, 'ds_biz_type'),
|
$this->designatedShopScalar($row, 'ds_biz_type'),
|
||||||
$this->designatedShopScalar($row, 'ds_biz_kind'),
|
$this->designatedShopScalar($row, 'ds_biz_kind'),
|
||||||
$this->designatedShopScalar($row, 'ds_va_bank'),
|
$this->designatedShopScalar($row, 'ds_va_bank'),
|
||||||
$this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : ($row->ds_va_number ?? ''),
|
$vaAccount,
|
||||||
$row->ds_tel ?? '',
|
$telVal,
|
||||||
$row->ds_addr ?? '',
|
$row->ds_addr ?? '',
|
||||||
$this->designatedShopScalar($row, 'ds_zone_code'),
|
$this->designatedShopScalar($row, 'ds_zone_code'),
|
||||||
$this->designatedShopScalar($row, 'ds_branch_no'),
|
$this->designatedShopScalar($row, 'ds_branch_no'),
|
||||||
@@ -1675,6 +1738,51 @@ class DesignatedShop extends BaseController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [AJAX] 개인정보 원문 보기 — step-up 재인증(TOTP) + 열람사유 → 세션 열람 승인 + 감사기록.
|
||||||
|
* 조건 미충족(IP밖·Super Admin 등) 사용자가 마스킹된 PII를 열람할 때 사용.
|
||||||
|
*/
|
||||||
|
public function revealPii()
|
||||||
|
{
|
||||||
|
helper(['admin', 'pii_encryption']);
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
if (! $lgIdx) {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'error' => '지자체가 선택되지 않았습니다.']);
|
||||||
|
}
|
||||||
|
$mbIdx = (int) session()->get('mb_idx');
|
||||||
|
$code = trim((string) $this->request->getPost('totp_code'));
|
||||||
|
$reason = trim((string) $this->request->getPost('reason'));
|
||||||
|
if ($code === '' || ! preg_match('/^\d{6}$/', $code)) {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'error' => '6자리 인증코드를 입력해 주세요.']);
|
||||||
|
}
|
||||||
|
if ($reason === '') {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'error' => '열람 사유를 입력해 주세요.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$member = model(\App\Models\MemberModel::class)->find($mbIdx);
|
||||||
|
$secret = $member ? pii_decrypt((string) ($member->mb_totp_secret ?? '')) : '';
|
||||||
|
if ($secret === '' || (int) ($member->mb_totp_enabled ?? 0) !== 1) {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'error' => '2단계 인증(TOTP)이 등록돼 있지 않아 원문 열람을 승인할 수 없습니다.']);
|
||||||
|
}
|
||||||
|
$totp = new \App\Libraries\TotpService();
|
||||||
|
if (! $totp->verify($secret, $code)) {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'error' => '인증코드가 올바르지 않습니다.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이 세션에서 해당 지자체 PII 원문 열람 승인(10분)
|
||||||
|
session()->set('pii_stepup_grant', ['lg_idx' => (int) $lgIdx, 'until' => time() + 600]);
|
||||||
|
|
||||||
|
// 감사기록 — 누가/언제/사유
|
||||||
|
helper('audit');
|
||||||
|
audit_log('pii_view', 'designated_shop', 0, null, [
|
||||||
|
'lg_idx' => (int) $lgIdx,
|
||||||
|
'reason' => mb_substr($reason, 0, 200),
|
||||||
|
'ip' => (string) $this->request->getIPAddress(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->response->setJSON(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* designated_shop 테이블에 ds_dong_code 컬럼이 존재하는지(1회 조회 후 캐시).
|
* designated_shop 테이블에 ds_dong_code 컬럼이 존재하는지(1회 조회 후 캐시).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ class SalesReport extends BaseController
|
|||||||
$hasBsFee
|
$hasBsFee
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 대표자명 PII: 암호화 대비 복호화 + 권한 없으면 마스킹 (표시 계층)
|
||||||
|
helper('pii_encryption');
|
||||||
|
$canViewPii = can_view_shop_pii((int) $lgIdx);
|
||||||
|
foreach ($detailRows as $row) {
|
||||||
|
if (isset($row->ds_rep_name) && $row->ds_rep_name !== '') {
|
||||||
|
$rep = pii_decrypt((string) $row->ds_rep_name);
|
||||||
|
$row->ds_rep_name = $canViewPii ? $rep : mask_shop_field('ds_rep_name', $rep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$filtered = [];
|
$filtered = [];
|
||||||
foreach ($detailRows as $row) {
|
foreach ($detailRows as $row) {
|
||||||
if ($this->ledgerRowMatchesCategories($row, $cats)) {
|
if ($this->ledgerRowMatchesCategories($row, $cats)) {
|
||||||
@@ -3556,6 +3566,38 @@ class SalesReport extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 엑셀 다운로드 — 현재 컬럼·필터·제목 그대로
|
||||||
|
if ((string) ($this->request->getGet('export') ?? '') === '1') {
|
||||||
|
$headers = array_map(static fn ($k) => self::CUSTOM_REPORT_COLUMNS[$k]['label'], $selected);
|
||||||
|
$exportRows = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$line = [];
|
||||||
|
foreach ($selected as $key) {
|
||||||
|
$isNum = (self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num';
|
||||||
|
$line[] = $isNum ? number_format((int) ($row[$key] ?? 0)) : (string) ($row[$key] ?? '');
|
||||||
|
}
|
||||||
|
$exportRows[] = $line;
|
||||||
|
}
|
||||||
|
// 합계 행
|
||||||
|
$totalLine = [];
|
||||||
|
foreach ($selected as $i => $key) {
|
||||||
|
$isNum = (self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num';
|
||||||
|
if ($i === 0) {
|
||||||
|
$totalLine[] = '합계';
|
||||||
|
} else {
|
||||||
|
$totalLine[] = $isNum ? number_format((int) ($totals[$key] ?? 0)) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$exportRows[] = $totalLine;
|
||||||
|
|
||||||
|
$fileTitle = $title !== '' ? $title : '맞춤집계표';
|
||||||
|
$topRows = [
|
||||||
|
[$fileTitle],
|
||||||
|
['조회기간: ' . $startDate . ' ~ ' . $endDate . ' / 품목구분: ' . $this->customReportCategoryLabel($category)],
|
||||||
|
];
|
||||||
|
export_excel_2003_xml($fileTitle . '_' . $startDate . '_' . $endDate, mb_substr($fileTitle, 0, 31), $headers, $exportRows, $topRows);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->renderWorkPage('맞춤 집계표', 'admin/sales_report/custom_report', [
|
return $this->renderWorkPage('맞춤 집계표', 'admin/sales_report/custom_report', [
|
||||||
'columnCatalog' => self::CUSTOM_REPORT_COLUMNS,
|
'columnCatalog' => self::CUSTOM_REPORT_COLUMNS,
|
||||||
'selected' => $selected,
|
'selected' => $selected,
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ class Auth extends BaseController
|
|||||||
return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
|
return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
|
return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function showTotpSetup()
|
public function showTotpSetup()
|
||||||
@@ -329,7 +329,7 @@ class Auth extends BaseController
|
|||||||
return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.');
|
return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx));
|
return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function logout()
|
public function logout()
|
||||||
@@ -573,7 +573,7 @@ class Auth extends BaseController
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $logData
|
* @param array<string, mixed> $logData
|
||||||
*/
|
*/
|
||||||
private function completeLogin(object $member, array $logData): RedirectResponse
|
private function completeLogin(object $member, array $logData, bool $twofaVerified = false): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->clearPending2faSession();
|
$this->clearPending2faSession();
|
||||||
// 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨
|
// 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨
|
||||||
@@ -586,6 +586,8 @@ class Auth extends BaseController
|
|||||||
'mb_lg_idx' => $member->mb_lg_idx ?? null,
|
'mb_lg_idx' => $member->mb_lg_idx ?? null,
|
||||||
'logged_in' => true,
|
'logged_in' => true,
|
||||||
'session_token' => $sessionToken,
|
'session_token' => $sessionToken,
|
||||||
|
// 지정판매소 PII 자동 원문 열람 조건에 사용(TOTP 실제 통과 시에만 true)
|
||||||
|
'auth_2fa_verified' => $twofaVerified,
|
||||||
];
|
];
|
||||||
session()->set($sessionData);
|
session()->set($sessionData);
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,70 @@ SQL);
|
|||||||
return $this->response->setJSON(['ok' => true, 'csrf' => csrf_hash()]);
|
return $this->response->setJSON(['ok' => true, 'csrf' => csrf_hash()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** member_workspace_tabs 테이블 보장(없으면 생성) */
|
||||||
|
private function ensureWorkspaceTabsTable(\CodeIgniter\Database\BaseConnection $db): void
|
||||||
|
{
|
||||||
|
if ($db->tableExists('member_workspace_tabs')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$db->query(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `member_workspace_tabs` (
|
||||||
|
`mwt_mb_idx` INT UNSIGNED NOT NULL,
|
||||||
|
`mwt_state` MEDIUMTEXT NULL,
|
||||||
|
`mwt_updated` DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (`mwt_mb_idx`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 계정별 워크스페이스 탭 상태 조회(자동 복원용). 없으면 'null'. */
|
||||||
|
public function loadWorkspaceTabsState(int $mbIdx): string
|
||||||
|
{
|
||||||
|
if ($mbIdx <= 0) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
if (! $db->tableExists('member_workspace_tabs')) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
$row = $db->table('member_workspace_tabs')->select('mwt_state')->where('mwt_mb_idx', $mbIdx)->get()->getRowArray();
|
||||||
|
$s = (string) ($row['mwt_state'] ?? '');
|
||||||
|
return $s !== '' ? $s : 'null';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 계정별 워크스페이스 탭 상태 저장 (자동 복원) */
|
||||||
|
public function saveWorkspaceTabs(): ResponseInterface
|
||||||
|
{
|
||||||
|
$mbIdx = (int) (session()->get('mb_idx') ?? 0);
|
||||||
|
if ($mbIdx <= 0) {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]);
|
||||||
|
}
|
||||||
|
$state = (string) ($this->request->getPost('state') ?? '');
|
||||||
|
// 크기 제한 + JSON 유효성
|
||||||
|
if (strlen($state) > 30000) {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]);
|
||||||
|
}
|
||||||
|
if ($state !== '') {
|
||||||
|
json_decode($state);
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$this->ensureWorkspaceTabsTable($db);
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$exists = $db->table('member_workspace_tabs')->where('mwt_mb_idx', $mbIdx)->countAllResults();
|
||||||
|
if ($exists > 0) {
|
||||||
|
$db->table('member_workspace_tabs')->where('mwt_mb_idx', $mbIdx)
|
||||||
|
->update(['mwt_state' => $state, 'mwt_updated' => $now]);
|
||||||
|
} else {
|
||||||
|
$db->table('member_workspace_tabs')->insert(['mwt_mb_idx' => $mbIdx, 'mwt_state' => $state, 'mwt_updated' => $now]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->response->setJSON(['ok' => true, 'csrf' => csrf_hash()]);
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// 기본정보관리 (단가·포장 단위 진입 허브)
|
// 기본정보관리 (단가·포장 단위 진입 허브)
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@@ -1312,6 +1376,142 @@ SQL);
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실사 재고 바코드 리스트 — 현재 창고에 있어야 하는(재고로 남아있는) 봉투 번호(바코드) 목록.
|
||||||
|
* bag_receiving_pack_code 중 brpc_state='in_stock' 인 팩/박스 코드를 품목·박스·팩·시트번호(시작~끝)·매수로 뽑아
|
||||||
|
* 실사 전 대조용으로 조회/엑셀/인쇄한다. (기존 실사 스냅샷과 동일한 in_stock 기준)
|
||||||
|
*/
|
||||||
|
public function stockBarcodeList(): string|ResponseInterface|RedirectResponse
|
||||||
|
{
|
||||||
|
$lgIdx = $this->lgIdx();
|
||||||
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
|
||||||
|
$keyword = trim((string) ($this->request->getGet('keyword') ?? ''));
|
||||||
|
$pageSize = 200; // 레이지 로딩 청크 크기
|
||||||
|
|
||||||
|
// 공통 필터(재고 상태 + 품목/키워드)를 빌더에 적용
|
||||||
|
$applyFilter = static function ($builder) use ($lgIdx, $bagCode, $keyword) {
|
||||||
|
$builder->where('brpc_lg_idx', $lgIdx)->where('brpc_state', 'in_stock');
|
||||||
|
if ($bagCode !== '') {
|
||||||
|
$builder->where('brpc_bag_code', $bagCode);
|
||||||
|
}
|
||||||
|
if ($keyword !== '') {
|
||||||
|
$builder->groupStart()
|
||||||
|
->like('brpc_box_code', $keyword)
|
||||||
|
->orLike('brpc_pack_code', $keyword)
|
||||||
|
->orLike('brpc_sheet_start_code', $keyword)
|
||||||
|
->orLike('brpc_sheet_end_code', $keyword)
|
||||||
|
->orLike('brpc_lot_no', $keyword)
|
||||||
|
->groupEnd();
|
||||||
|
}
|
||||||
|
return $builder;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 적용된 행을 offset/limit(0=전체)로 조회
|
||||||
|
$fetchRows = function (int $offset, int $limit) use ($db, $applyFilter): array {
|
||||||
|
$builder = $applyFilter($db->table('bag_receiving_pack_code'))
|
||||||
|
->select('brpc_bag_code, brpc_bag_name, brpc_lot_no, brpc_box_code, brpc_pack_code, brpc_sheet_start_code, brpc_sheet_end_code, brpc_sheet_qty')
|
||||||
|
->orderBy('brpc_bag_code', 'ASC')
|
||||||
|
->orderBy('brpc_box_code', 'ASC')
|
||||||
|
->orderBy('brpc_pack_code', 'ASC');
|
||||||
|
if ($limit > 0) {
|
||||||
|
$builder->limit($limit, max(0, $offset));
|
||||||
|
}
|
||||||
|
return $builder->get()->getResultArray();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 합계 — 전체 기준 SQL 집계(전체 행을 메모리에 올리지 않음)
|
||||||
|
$agg = $applyFilter($db->table('bag_receiving_pack_code'))
|
||||||
|
->select('COUNT(*) AS total_packs, COALESCE(SUM(brpc_sheet_qty),0) AS total_sheets, COUNT(DISTINCT brpc_box_code) AS total_boxes', false)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
$totalPacks = (int) ($agg['total_packs'] ?? 0);
|
||||||
|
$totalSheets = (int) ($agg['total_sheets'] ?? 0);
|
||||||
|
$totalBoxes = (int) ($agg['total_boxes'] ?? 0);
|
||||||
|
|
||||||
|
// AJAX 레이지 로딩 — 다음 청크만 JSON 으로 반환
|
||||||
|
if ((string) ($this->request->getGet('ajax') ?? '') === '1') {
|
||||||
|
$offset = max(0, (int) ($this->request->getGet('offset') ?? 0));
|
||||||
|
$limit = (int) ($this->request->getGet('limit') ?? $pageSize);
|
||||||
|
$limit = $limit <= 0 ? $pageSize : min($limit, 5000);
|
||||||
|
$chunk = $fetchRows($offset, $limit);
|
||||||
|
$out = [];
|
||||||
|
foreach ($chunk as $r) {
|
||||||
|
$out[] = [
|
||||||
|
'name' => ($r['brpc_bag_name'] ?? '') !== '' ? (string) $r['brpc_bag_name'] : (string) ($r['brpc_bag_code'] ?? ''),
|
||||||
|
'lot' => (string) ($r['brpc_lot_no'] ?? ''),
|
||||||
|
'box' => (string) ($r['brpc_box_code'] ?? ''),
|
||||||
|
'pack' => (string) ($r['brpc_pack_code'] ?? ''),
|
||||||
|
'start' => (string) ($r['brpc_sheet_start_code'] ?? ''),
|
||||||
|
'end' => (string) ($r['brpc_sheet_end_code'] ?? ''),
|
||||||
|
'qty' => (int) ($r['brpc_sheet_qty'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$next = $offset + count($chunk);
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'rows' => $out,
|
||||||
|
'offset' => $next,
|
||||||
|
'hasMore' => $next < $totalPacks,
|
||||||
|
'total' => $totalPacks,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 엑셀 다운로드 — 현재 필터 전체
|
||||||
|
if ((string) ($this->request->getGet('export') ?? '') === '1') {
|
||||||
|
helper('export');
|
||||||
|
$exportRows = [];
|
||||||
|
foreach ($fetchRows(0, 0) as $r) {
|
||||||
|
$exportRows[] = [
|
||||||
|
($r['brpc_bag_name'] ?? '') !== '' ? (string) $r['brpc_bag_name'] : (string) ($r['brpc_bag_code'] ?? ''),
|
||||||
|
(string) ($r['brpc_lot_no'] ?? ''),
|
||||||
|
(string) ($r['brpc_box_code'] ?? ''),
|
||||||
|
(string) ($r['brpc_pack_code'] ?? ''),
|
||||||
|
(string) ($r['brpc_sheet_start_code'] ?? ''),
|
||||||
|
(string) ($r['brpc_sheet_end_code'] ?? ''),
|
||||||
|
number_format((int) ($r['brpc_sheet_qty'] ?? 0)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$exportRows[] = ['합계', '', '박스 ' . number_format($totalBoxes), '팩 ' . number_format($totalPacks), '', '', number_format($totalSheets)];
|
||||||
|
export_xlsx(
|
||||||
|
'실사재고바코드리스트_' . date('Ymd') . '.xlsx',
|
||||||
|
'창고재고바코드',
|
||||||
|
['품목', 'LOT', '박스코드', '팩코드', '시작번호', '끝번호', '매수'],
|
||||||
|
$exportRows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 품목 선택 옵션 — 현재 재고로 남아있는 품목만
|
||||||
|
$itemOptions = $db->table('bag_receiving_pack_code')
|
||||||
|
->select('brpc_bag_code, MAX(brpc_bag_name) AS bag_name, COUNT(*) AS pack_cnt, SUM(brpc_sheet_qty) AS sheet_qty', false)
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_state', 'in_stock')
|
||||||
|
->where('brpc_bag_code !=', '')
|
||||||
|
->groupBy('brpc_bag_code')
|
||||||
|
->orderBy('brpc_bag_code', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
// 초기 1페이지만 렌더 — 나머지는 스크롤 시 레이지 로딩
|
||||||
|
$rows = $fetchRows(0, $pageSize);
|
||||||
|
|
||||||
|
return $this->render('실사 재고 바코드 리스트', 'bag/inventory_stock_barcode_list', [
|
||||||
|
'bagCode' => $bagCode,
|
||||||
|
'keyword' => $keyword,
|
||||||
|
'itemOptions' => $itemOptions,
|
||||||
|
'rows' => $rows,
|
||||||
|
'totalBoxes' => $totalBoxes,
|
||||||
|
'totalPacks' => $totalPacks,
|
||||||
|
'totalSheets' => $totalSheets,
|
||||||
|
'pageSize' => $pageSize,
|
||||||
|
'loadedCount' => count($rows),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* rows: list<array{group:string,name:string,total_qty:int,gugun_qty:int,agency_qty:int}>,
|
* rows: list<array{group:string,name:string,total_qty:int,gugun_qty:int,agency_qty:int}>,
|
||||||
@@ -7480,16 +7680,25 @@ SQL;
|
|||||||
$shopMap = [];
|
$shopMap = [];
|
||||||
$dsIdxs = array_values(array_unique(array_filter(array_map(static fn ($o): int => (int) ($o->so_ds_idx ?? 0), $orders), static fn ($v): bool => $v > 0)));
|
$dsIdxs = array_values(array_unique(array_filter(array_map(static fn ($o): int => (int) ($o->so_ds_idx ?? 0), $orders), static fn ($v): bool => $v > 0)));
|
||||||
if ($dsIdxs !== [] && $db->tableExists('designated_shop')) {
|
if ($dsIdxs !== [] && $db->tableExists('designated_shop')) {
|
||||||
|
helper('pii_encryption');
|
||||||
|
$canViewPii = can_view_shop_pii((int) $lgIdx); // 이 화면은 현재 지자체 판매소만
|
||||||
$shopRows = $db->table('designated_shop')
|
$shopRows = $db->table('designated_shop')
|
||||||
->select('ds_idx, ds_shop_no, ds_name, ds_rep_name, ds_tel, ds_addr, ds_addr_detail')
|
->select('ds_idx, ds_shop_no, ds_name, ds_rep_name, ds_tel, ds_addr, ds_addr_detail')
|
||||||
->whereIn('ds_idx', $dsIdxs)
|
->whereIn('ds_idx', $dsIdxs)
|
||||||
->get()->getResultArray();
|
->get()->getResultArray();
|
||||||
foreach ($shopRows as $s) {
|
foreach ($shopRows as $s) {
|
||||||
|
// 암호화 저장 대비 복호화 후, 권한 없으면 마스킹
|
||||||
|
$rep = pii_decrypt((string) ($s['ds_rep_name'] ?? ''));
|
||||||
|
$tel = pii_decrypt((string) ($s['ds_tel'] ?? ''));
|
||||||
|
if (! $canViewPii) {
|
||||||
|
$rep = mask_shop_field('ds_rep_name', $rep);
|
||||||
|
$tel = mask_shop_field('ds_tel', $tel);
|
||||||
|
}
|
||||||
$shopMap[(int) $s['ds_idx']] = [
|
$shopMap[(int) $s['ds_idx']] = [
|
||||||
'shop_no' => (string) ($s['ds_shop_no'] ?? ''),
|
'shop_no' => (string) ($s['ds_shop_no'] ?? ''),
|
||||||
'name' => (string) ($s['ds_name'] ?? ''),
|
'name' => (string) ($s['ds_name'] ?? ''),
|
||||||
'rep_name' => (string) ($s['ds_rep_name'] ?? ''),
|
'rep_name' => $rep,
|
||||||
'tel' => (string) ($s['ds_tel'] ?? ''),
|
'tel' => $tel,
|
||||||
'addr' => trim((string) ($s['ds_addr'] ?? '') . ' ' . (string) ($s['ds_addr_detail'] ?? '')),
|
'addr' => trim((string) ($s['ds_addr'] ?? '') . ' ' . (string) ($s['ds_addr_detail'] ?? '')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -7751,6 +7960,37 @@ SQL;
|
|||||||
return redirect()->to(site_url('bag/order/phone/manage'))->with('success', '주문 수정 저장이 완료되었습니다.');
|
return redirect()->to(site_url('bag/order/phone/manage'))->with('success', '주문 수정 저장이 완료되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주문접수 관리 — 입금확인(so_paid) 수동 토글(JSON 응답).
|
||||||
|
* 입금확인이 완료되어야 포장(스캔)이 가능하다.
|
||||||
|
*/
|
||||||
|
public function phoneOrderMarkPaid()
|
||||||
|
{
|
||||||
|
$lgIdx = $this->lgIdx();
|
||||||
|
if (! $lgIdx) {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.', 'csrf' => csrf_hash()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$soIdx = (int) ($this->request->getPost('so_idx') ?? 0);
|
||||||
|
$paid = ((int) ($this->request->getPost('paid') ?? 0)) === 1 ? 1 : 0;
|
||||||
|
if ($soIdx <= 0) {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'message' => '주문 값이 올바르지 않습니다.', 'csrf' => csrf_hash()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderModel = model(ShopOrderModel::class);
|
||||||
|
$order = $orderModel->where('so_idx', $soIdx)->where('so_lg_idx', $lgIdx)->first();
|
||||||
|
if (! $order) {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'message' => '선택한 주문을 찾을 수 없습니다.', 'csrf' => csrf_hash()]);
|
||||||
|
}
|
||||||
|
if ((string) ($order->so_status ?? '') === 'cancelled') {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'message' => '취소된 주문은 입금상태를 변경할 수 없습니다.', 'csrf' => csrf_hash()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderModel->update($soIdx, ['so_paid' => $paid]);
|
||||||
|
|
||||||
|
return $this->response->setJSON(['ok' => true, 'so_idx' => $soIdx, 'paid' => $paid, 'csrf' => csrf_hash()]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 전화접수 관리 — 봉투코드 스캔으로 즉시 판매·포장 처리(JSON 응답).
|
* 전화접수 관리 — 봉투코드 스캔으로 즉시 판매·포장 처리(JSON 응답).
|
||||||
* 선택된 주문의 판매소로 판매 기록(bag_sale) + 재고 차감 + 팩코드 sold 전환 +
|
* 선택된 주문의 판매소로 판매 기록(bag_sale) + 재고 차감 + 팩코드 sold 전환 +
|
||||||
@@ -7776,6 +8016,11 @@ SQL;
|
|||||||
return $this->response->setJSON(['ok' => false, 'message' => '선택한 주문을 사용할 수 없습니다.', 'csrf' => csrf_hash()]);
|
return $this->response->setJSON(['ok' => false, 'message' => '선택한 주문을 사용할 수 없습니다.', 'csrf' => csrf_hash()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 입금확인(so_paid=1)이 안 된 주문은 포장(판매) 스캔 불가.
|
||||||
|
if ((int) ($order->so_paid ?? 0) !== 1) {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'message' => '입금확인이 완료되지 않은 주문입니다. 입금확인 후 포장하세요.', 'csrf' => csrf_hash()]);
|
||||||
|
}
|
||||||
|
|
||||||
$scan = $this->resolveDesignatedSaleBarcode($lgIdx, $barcode);
|
$scan = $this->resolveDesignatedSaleBarcode($lgIdx, $barcode);
|
||||||
if (! ($scan['ok'] ?? false)) {
|
if (! ($scan['ok'] ?? false)) {
|
||||||
return $this->response->setJSON(array_merge($scan, ['csrf' => csrf_hash()]));
|
return $this->response->setJSON(array_merge($scan, ['csrf' => csrf_hash()]));
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ class Home extends BaseController
|
|||||||
}
|
}
|
||||||
helper('admin');
|
helper('admin');
|
||||||
|
|
||||||
return view('bag/layout/workspace');
|
// 계정별 저장된 탭 상태(자동 복원). 없으면 'null'.
|
||||||
|
$savedWorkspaceJson = (new \App\Controllers\Bag())
|
||||||
|
->loadWorkspaceTabsState((int) (session()->get('mb_idx') ?? 0));
|
||||||
|
|
||||||
|
return view('bag/layout/workspace', ['savedWorkspaceJson' => $savedWorkspaceJson]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -70,3 +70,246 @@ if (! function_exists('pii_decrypt')) {
|
|||||||
if (! defined('PII_ENCRYPTED_FIELDS')) {
|
if (! defined('PII_ENCRYPTED_FIELDS')) {
|
||||||
define('PII_ENCRYPTED_FIELDS', ['mb_phone', 'mb_email']);
|
define('PII_ENCRYPTED_FIELDS', ['mb_phone', 'mb_email']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
* 개인정보 마스킹(비식별화) 헬퍼 — 표시 계층에서만 사용(원본 데이터는 그대로).
|
||||||
|
* ======================================================================= */
|
||||||
|
|
||||||
|
if (! function_exists('pii_mask_name')) {
|
||||||
|
/** 이름 마스킹: 홍길동→홍*동, 홍길→홍*, 홍→홍 */
|
||||||
|
function pii_mask_name(?string $v): string
|
||||||
|
{
|
||||||
|
$v = trim((string) $v);
|
||||||
|
if ($v === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$len = mb_strlen($v, 'UTF-8');
|
||||||
|
if ($len === 1) {
|
||||||
|
return $v;
|
||||||
|
}
|
||||||
|
if ($len === 2) {
|
||||||
|
return mb_substr($v, 0, 1, 'UTF-8') . '*';
|
||||||
|
}
|
||||||
|
return mb_substr($v, 0, 1, 'UTF-8') . str_repeat('*', $len - 2) . mb_substr($v, $len - 1, 1, 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_mask_phone')) {
|
||||||
|
/** 전화 마스킹: 앞 국번 + 마지막 4자리만 노출, 가운데 마스킹. 010-1234-5678→010-****-5678 */
|
||||||
|
function pii_mask_phone(?string $v): string
|
||||||
|
{
|
||||||
|
$v = trim((string) $v);
|
||||||
|
if ($v === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// 하이픈 형식이면 가운데 그룹만 마스킹
|
||||||
|
if (preg_match('/^(\d{2,4})-(\d{3,4})-(\d{4})$/', $v, $m) === 1) {
|
||||||
|
return $m[1] . '-' . str_repeat('*', strlen($m[2])) . '-' . $m[3];
|
||||||
|
}
|
||||||
|
// 그 외: 숫자만 추출해 앞3·뒤4 노출
|
||||||
|
$digits = preg_replace('/\D/', '', $v);
|
||||||
|
$n = strlen($digits);
|
||||||
|
if ($n <= 4) {
|
||||||
|
return str_repeat('*', $n);
|
||||||
|
}
|
||||||
|
$head = substr($digits, 0, 3);
|
||||||
|
$tail = substr($digits, -4);
|
||||||
|
return $head . str_repeat('*', max(1, $n - 7)) . $tail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_mask_email')) {
|
||||||
|
/** 이메일 마스킹: 앞부분 첫 글자만 노출. hong@a.com→h***@a.com */
|
||||||
|
function pii_mask_email(?string $v): string
|
||||||
|
{
|
||||||
|
$v = trim((string) $v);
|
||||||
|
if ($v === '' || strpos($v, '@') === false) {
|
||||||
|
return $v === '' ? '' : pii_mask_name($v);
|
||||||
|
}
|
||||||
|
[$local, $domain] = explode('@', $v, 2);
|
||||||
|
$llen = mb_strlen($local, 'UTF-8');
|
||||||
|
$maskedLocal = $llen <= 1 ? '*' : mb_substr($local, 0, 1, 'UTF-8') . str_repeat('*', $llen - 1);
|
||||||
|
return $maskedLocal . '@' . $domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_mask_account')) {
|
||||||
|
/** 계좌/번호 마스킹: 마지막 4자리만 노출 */
|
||||||
|
function pii_mask_account(?string $v): string
|
||||||
|
{
|
||||||
|
$v = trim((string) $v);
|
||||||
|
if ($v === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$len = mb_strlen($v, 'UTF-8');
|
||||||
|
if ($len <= 4) {
|
||||||
|
return str_repeat('*', $len);
|
||||||
|
}
|
||||||
|
return str_repeat('*', $len - 4) . mb_substr($v, $len - 4, 4, 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_blind_index')) {
|
||||||
|
/**
|
||||||
|
* 블라인드 인덱스(정확일치 검색용): HMAC-SHA256(정규화값, 별도 인덱스키).
|
||||||
|
* 키(pii.blindIndexKey)가 없으면 '' 반환(검색 인덱스 비활성).
|
||||||
|
*/
|
||||||
|
function pii_blind_index(?string $value): string
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
if ($value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$key = (string) (config('Pii')->blindIndexKey ?? '');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$key = '';
|
||||||
|
}
|
||||||
|
if ($key === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// 정규화: 공백 제거 + 소문자 (전화는 숫자만)
|
||||||
|
$norm = preg_replace('/\s+/u', '', $value);
|
||||||
|
if (preg_match('/^[\d\-()+ ]+$/', $value) === 1) {
|
||||||
|
$norm = preg_replace('/\D/', '', $value);
|
||||||
|
} else {
|
||||||
|
$norm = mb_strtolower($norm, 'UTF-8');
|
||||||
|
}
|
||||||
|
return hash_hmac('sha256', $norm, $key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
* 지정판매소 PII 열람 게이트 — canViewShopPii (단일 판별 지점).
|
||||||
|
* ======================================================================= */
|
||||||
|
|
||||||
|
if (! function_exists('pii_shop_protection_enabled')) {
|
||||||
|
/** 지정판매소 PII 보호 마스터 플래그 (OFF면 현재 동작=항상 원문) */
|
||||||
|
function pii_shop_protection_enabled(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return (bool) (config('Pii')->designatedShopProtection ?? false);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_ip_in_cidr')) {
|
||||||
|
/** IP가 CIDR/단일IP 목록(쉼표·공백 구분)에 포함되는지 (IPv4) */
|
||||||
|
function pii_ip_in_cidr(string $ip, string $list): bool
|
||||||
|
{
|
||||||
|
$ip = trim($ip);
|
||||||
|
if ($ip === '' || $list === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$ipLong = ip2long($ip);
|
||||||
|
if ($ipLong === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (preg_split('/[\s,]+/', $list, -1, PREG_SPLIT_NO_EMPTY) as $entry) {
|
||||||
|
if (strpos($entry, '/') !== false) {
|
||||||
|
[$subnet, $bits] = explode('/', $entry, 2);
|
||||||
|
$subnetLong = ip2long(trim($subnet));
|
||||||
|
$bits = (int) $bits;
|
||||||
|
if ($subnetLong === false || $bits < 0 || $bits > 32) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$mask = $bits === 0 ? 0 : (-1 << (32 - $bits)) & 0xFFFFFFFF;
|
||||||
|
if (($ipLong & $mask) === ($subnetLong & $mask)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} elseif (ip2long(trim($entry)) === $ipLong) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_request_ip_in_lg_range')) {
|
||||||
|
/** 현재 요청 IP가 해당 지자체 허용 IP 대역에 속하는지 (local_government.lg_allow_ips) */
|
||||||
|
function pii_request_ip_in_lg_range(int $lgIdx): bool
|
||||||
|
{
|
||||||
|
if ($lgIdx <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
if (! $db->fieldExists('lg_allow_ips', 'local_government')) {
|
||||||
|
return false; // 컬럼 없으면 대역 미설정 → 자동 원문 불가(마스킹+step-up)
|
||||||
|
}
|
||||||
|
$row = $db->table('local_government')->select('lg_allow_ips')->where('lg_idx', $lgIdx)->get()->getRowArray();
|
||||||
|
$list = (string) ($row['lg_allow_ips'] ?? '');
|
||||||
|
if ($list === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return pii_ip_in_cidr((string) service('request')->getIPAddress(), $list);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_stepup_granted')) {
|
||||||
|
/** 이 세션에서 step-up 재인증으로 원문 열람이 승인됐는지 (P2에서 세팅) */
|
||||||
|
function pii_stepup_granted(int $dsLgIdx): bool
|
||||||
|
{
|
||||||
|
$g = session('pii_stepup_grant');
|
||||||
|
if (! is_array($g)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$until = (int) ($g['until'] ?? 0);
|
||||||
|
$lg = (int) ($g['lg_idx'] ?? -1);
|
||||||
|
return $until > time() && ($lg === 0 || $lg === $dsLgIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('can_view_shop_pii')) {
|
||||||
|
/**
|
||||||
|
* 지정판매소 개인정보 원문 열람 가능 여부(단일 판별 지점).
|
||||||
|
* 보호 OFF → 항상 true(현재 동작). 보호 ON → 조건 판별.
|
||||||
|
*/
|
||||||
|
function can_view_shop_pii(int $dsLgIdx): bool
|
||||||
|
{
|
||||||
|
if (! pii_shop_protection_enabled()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$level = (int) session('mb_level');
|
||||||
|
$adminLg = (int) session('admin_lg_idx');
|
||||||
|
$twofa = (bool) session('auth_2fa_verified');
|
||||||
|
|
||||||
|
// 정상(자동 원문): 지자체관리자 + 본인 지자체 + 2FA + 지자체 IP대역
|
||||||
|
if ($level === \Config\Roles::LEVEL_LOCAL_ADMIN
|
||||||
|
&& $adminLg === $dsLgIdx && $adminLg > 0
|
||||||
|
&& $twofa
|
||||||
|
&& pii_request_ip_in_lg_range($dsLgIdx)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그 외(Super Admin·본부 포함, IP 밖 등): step-up 재인증으로 승인된 경우만
|
||||||
|
return pii_stepup_granted($dsLgIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('mask_shop_field')) {
|
||||||
|
/** 필드명 기준 마스킹 라우팅 (원문 표시 불가일 때 사용) */
|
||||||
|
function mask_shop_field(string $field, ?string $value): string
|
||||||
|
{
|
||||||
|
$value = (string) $value;
|
||||||
|
switch ($field) {
|
||||||
|
case 'ds_rep_name':
|
||||||
|
return pii_mask_name($value);
|
||||||
|
case 'ds_tel':
|
||||||
|
case 'ds_rep_phone':
|
||||||
|
return pii_mask_phone($value);
|
||||||
|
case 'ds_email':
|
||||||
|
return pii_mask_email($value);
|
||||||
|
case 'ds_va_account':
|
||||||
|
case 'ds_va_number':
|
||||||
|
return pii_mask_account($value);
|
||||||
|
default:
|
||||||
|
return $value === '' ? '' : pii_mask_name($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,118 @@ class DesignatedShopModel extends Model
|
|||||||
'ds_state_changed_at',
|
'ds_state_changed_at',
|
||||||
'ds_change_reason',
|
'ds_change_reason',
|
||||||
'ds_regdate',
|
'ds_regdate',
|
||||||
|
// 블라인드 인덱스(정확일치 검색용). 컬럼이 있을 때만 실제로 기록됨.
|
||||||
|
'ds_tel_bidx',
|
||||||
|
'ds_rep_name_bidx',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// PII 암복호화·블라인드인덱스 콜백 (설계안 P3·P4)
|
||||||
|
protected $beforeInsert = ['piiBlindIndex', 'piiEncrypt'];
|
||||||
|
protected $beforeUpdate = ['piiBlindIndex', 'piiEncrypt'];
|
||||||
|
protected $afterFind = ['piiDecrypt'];
|
||||||
|
|
||||||
|
private ?bool $telBidxExists = null;
|
||||||
|
private ?bool $nameBidxExists = null;
|
||||||
|
|
||||||
|
/** 저장 계층 암호화 사용 여부 (Config\Pii.encryptAtRest) */
|
||||||
|
private function encryptAtRest(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return (bool) (config('Pii')->encryptAtRest ?? false);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> 암호화·마스킹 대상 PII 필드 */
|
||||||
|
private function piiFields(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$f = config('Pii')->designatedShopPiiFields ?? [];
|
||||||
|
return is_array($f) && $f !== [] ? $f : ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 저장 전 PII 필드 암호화 (encryptAtRest ON일 때만, 이미 ENC:면 건너뜀) */
|
||||||
|
protected function piiEncrypt(array $data): array
|
||||||
|
{
|
||||||
|
if (! $this->encryptAtRest() || empty($data['data']) || ! is_array($data['data'])) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
helper('pii_encryption');
|
||||||
|
foreach ($this->piiFields() as $f) {
|
||||||
|
if (array_key_exists($f, $data['data']) && $data['data'][$f] !== null && $data['data'][$f] !== '') {
|
||||||
|
$v = (string) $data['data'][$f];
|
||||||
|
if (strpos($v, 'ENC:') !== 0) {
|
||||||
|
$data['data'][$f] = pii_encrypt($v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 저장 전 블라인드 인덱스(전화·이름) 기록 — 컬럼과 인덱스키가 있을 때만 */
|
||||||
|
protected function piiBlindIndex(array $data): array
|
||||||
|
{
|
||||||
|
if (empty($data['data']) || ! is_array($data['data'])) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
helper('pii_encryption');
|
||||||
|
$db = $this->db;
|
||||||
|
if ($this->telBidxExists === null) {
|
||||||
|
$this->telBidxExists = $db->fieldExists('ds_tel_bidx', $this->table);
|
||||||
|
}
|
||||||
|
if ($this->nameBidxExists === null) {
|
||||||
|
$this->nameBidxExists = $db->fieldExists('ds_rep_name_bidx', $this->table);
|
||||||
|
}
|
||||||
|
// 원본값이 넘어온 경우에만 인덱스 갱신(암호화 콜백이 먼저 돌면 ENC:라 인덱스 불가 → piiBlindIndex를 piiEncrypt보다 먼저 두지 않음에 주의)
|
||||||
|
if ($this->telBidxExists && array_key_exists('ds_tel', $data['data'])) {
|
||||||
|
$raw = (string) $data['data']['ds_tel'];
|
||||||
|
$data['data']['ds_tel_bidx'] = strpos($raw, 'ENC:') === 0 ? '' : pii_blind_index($raw);
|
||||||
|
}
|
||||||
|
if ($this->nameBidxExists && array_key_exists('ds_rep_name', $data['data'])) {
|
||||||
|
$raw = (string) $data['data']['ds_rep_name'];
|
||||||
|
$data['data']['ds_rep_name_bidx'] = strpos($raw, 'ENC:') === 0 ? '' : pii_blind_index($raw);
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 조회 후 PII 필드 복호화 (ENC: 값만; 평문은 그대로) */
|
||||||
|
protected function piiDecrypt(array $data): array
|
||||||
|
{
|
||||||
|
if (empty($data['data'])) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
helper('pii_encryption');
|
||||||
|
$fields = $this->piiFields();
|
||||||
|
$decodeOne = static function ($row) use ($fields) {
|
||||||
|
if (is_object($row)) {
|
||||||
|
foreach ($fields as $f) {
|
||||||
|
if (isset($row->$f) && is_string($row->$f) && strpos($row->$f, 'ENC:') === 0) {
|
||||||
|
$row->$f = pii_decrypt($row->$f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif (is_array($row)) {
|
||||||
|
foreach ($fields as $f) {
|
||||||
|
if (isset($row[$f]) && is_string($row[$f]) && strpos($row[$f], 'ENC:') === 0) {
|
||||||
|
$row[$f] = pii_decrypt($row[$f]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $row;
|
||||||
|
};
|
||||||
|
if (is_array($data['data'])) {
|
||||||
|
// findAll 등: 결과 배열
|
||||||
|
foreach ($data['data'] as $i => $row) {
|
||||||
|
$data['data'][$i] = $decodeOne($row);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// find/first: 단일
|
||||||
|
$data['data'] = $decodeOne($data['data']);
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
helper('admin');
|
helper(['admin', 'pii_encryption']);
|
||||||
|
$repDisp = static function ($row): string {
|
||||||
|
$v = (string) ($row->ds_rep_name ?? '');
|
||||||
|
return can_view_shop_pii((int) ($row->ds_lg_idx ?? 0)) ? $v : mask_shop_field('ds_rep_name', $v);
|
||||||
|
};
|
||||||
$currentPath = current_nav_request_path();
|
$currentPath = current_nav_request_path();
|
||||||
if ($currentPath === 'bag/designated-shops') {
|
if ($currentPath === 'bag/designated-shops') {
|
||||||
$readOnly = false;
|
$readOnly = false;
|
||||||
@@ -213,6 +217,10 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
|
|||||||
<option value="<?= esc($gCode) ?>" <?= ($dsGugunCode ?? '') === $gCode ? 'selected' : '' ?>><?= esc((string) (($gugunNameMap[$gCode] ?? '') !== '' ? $gugunNameMap[$gCode] : $gCode)) ?></option>
|
<option value="<?= esc($gCode) ?>" <?= ($dsGugunCode ?? '') === $gCode ? 'selected' : '' ?>><?= esc((string) (($gugunNameMap[$gCode] ?? '') !== '' ? $gugunNameMap[$gCode] : $gCode)) ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
|
<label class="text-sm text-gray-600">대표자명</label>
|
||||||
|
<input type="text" name="ds_rep" value="<?= esc($dsRep ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-28" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
|
||||||
|
<label class="text-sm text-gray-600">전화</label>
|
||||||
|
<input type="text" name="ds_tel" value="<?= esc($dsTel ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-32" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
|
||||||
<label class="text-sm text-gray-600">상태</label>
|
<label class="text-sm text-gray-600">상태</label>
|
||||||
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
|
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
@@ -282,14 +290,14 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
|||||||
<tr class="ds-list-row cursor-pointer border-b border-gray-200 last:border-0 hover:bg-blue-50/60"
|
<tr class="ds-list-row cursor-pointer border-b border-gray-200 last:border-0 hover:bg-blue-50/60"
|
||||||
data-row-index="<?= (int) $i ?>" role="button" tabindex="0"
|
data-row-index="<?= (int) $i ?>" role="button" tabindex="0"
|
||||||
data-sort-no="<?= esc((string) (preg_match('/\d+/', $shortNo, $mn) ? (int) $mn[0] : 0), 'attr') ?>"
|
data-sort-no="<?= esc((string) (preg_match('/\d+/', $shortNo, $mn) ? (int) $mn[0] : 0), 'attr') ?>"
|
||||||
data-sort-rep="<?= esc($row->ds_rep_name ?? '', 'attr') ?>"
|
data-sort-rep="<?= esc($repDisp($row), 'attr') ?>"
|
||||||
data-sort-name="<?= esc($row->ds_name ?? '', 'attr') ?>"
|
data-sort-name="<?= esc($row->ds_name ?? '', 'attr') ?>"
|
||||||
data-sort-dong="<?= esc($dongDisp, 'attr') ?>"
|
data-sort-dong="<?= esc($dongDisp, 'attr') ?>"
|
||||||
data-sort-designated="<?= esc($daDisp, 'attr') ?>"
|
data-sort-designated="<?= esc($daDisp, 'attr') ?>"
|
||||||
data-sort-zone="<?= esc($zone, 'attr') ?>"
|
data-sort-zone="<?= esc($zone, 'attr') ?>"
|
||||||
data-sort-state="<?= (int) $st ?>">
|
data-sort-state="<?= (int) $st ?>">
|
||||||
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($shortNo) ?></td>
|
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($shortNo) ?></td>
|
||||||
<td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td>
|
<td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($repDisp($row)) ?>"><?= esc($repDisp($row)) ?></td>
|
||||||
<td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
|
<td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
|
||||||
<td class="py-2.5 px-2 text-gray-600 font-mono" title="<?= esc($dongDisp) ?>"><?= esc($dongDisp) ?></td>
|
<td class="py-2.5 px-2 text-gray-600 font-mono" title="<?= esc($dongDisp) ?>"><?= esc($dongDisp) ?></td>
|
||||||
<td class="py-2.5 px-2 text-left text-gray-500 text-[12px]"><?= esc($daDisp) ?></td>
|
<td class="py-2.5 px-2 text-left text-gray-500 text-[12px]"><?= esc($daDisp) ?></td>
|
||||||
@@ -312,6 +320,24 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
|||||||
<div class="ds-panel-title shrink-0">지정판매소 정보</div>
|
<div class="ds-panel-title shrink-0">지정판매소 정보</div>
|
||||||
<div class="ds-detail-inner" id="ds-detail-box">
|
<div class="ds-detail-inner" id="ds-detail-box">
|
||||||
<p id="ds-detail-placeholder" class="text-sm text-gray-500 py-6 text-center">위 목록에서 행을 선택하세요.</p>
|
<p id="ds-detail-placeholder" class="text-sm text-gray-500 py-6 text-center">위 목록에서 행을 선택하세요.</p>
|
||||||
|
|
||||||
|
<!-- 개인정보 마스킹 안내 + 원문 보기(2단계 인증) -->
|
||||||
|
<div id="ds-pii-reveal" class="hidden mb-2 border border-amber-300 bg-amber-50 rounded p-2 text-[12px] no-print">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-amber-800">개인정보가 마스킹되어 있습니다.</span>
|
||||||
|
<button type="button" id="ds-pii-reveal-btn" class="border border-amber-500 text-amber-800 px-2 py-0.5 rounded hover:bg-amber-100">원문 보기</button>
|
||||||
|
</div>
|
||||||
|
<div id="ds-pii-reveal-form" class="hidden mt-2 space-y-1">
|
||||||
|
<input type="text" id="ds-pii-otp" inputmode="numeric" maxlength="6" placeholder="인증코드 6자리(OTP)" class="border border-gray-300 rounded px-2 py-1 w-full"/>
|
||||||
|
<input type="text" id="ds-pii-reason" maxlength="200" placeholder="열람 사유" class="border border-gray-300 rounded px-2 py-1 w-full"/>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button type="button" id="ds-pii-reveal-submit" class="bg-[#243a5e] text-white px-3 py-1 rounded text-[12px]">인증 후 열람</button>
|
||||||
|
<button type="button" id="ds-pii-reveal-cancel" class="bg-gray-200 text-gray-700 px-3 py-1 rounded text-[12px]">취소</button>
|
||||||
|
</div>
|
||||||
|
<p id="ds-pii-reveal-msg" class="text-red-600"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="ds-detail-fields" class="hidden">
|
<div id="ds-detail-fields" class="hidden">
|
||||||
<!-- 제목 왼쪽 · 내용 오른쪽 (라벨/값) 폼 형태 -->
|
<!-- 제목 왼쪽 · 내용 오른쪽 (라벨/값) 폼 형태 -->
|
||||||
<div class="ds-detail-form" id="ds-detail-info-table" aria-label="지정판매소 상세">
|
<div class="ds-detail-form" id="ds-detail-info-table" aria-label="지정판매소 상세">
|
||||||
@@ -478,6 +504,14 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
|||||||
fieldsWrap.classList.remove('hidden');
|
fieldsWrap.classList.remove('hidden');
|
||||||
fillDetailInfoTable(d);
|
fillDetailInfoTable(d);
|
||||||
|
|
||||||
|
// 마스킹된 행이면 "원문 보기" 안내 노출
|
||||||
|
var revealBox = document.getElementById('ds-pii-reveal');
|
||||||
|
if (revealBox) {
|
||||||
|
revealBox.classList.toggle('hidden', !d.pii_masked);
|
||||||
|
var rf = document.getElementById('ds-pii-reveal-form');
|
||||||
|
if (rf) rf.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
if (!readOnly && editLink && delForm && delBtn) {
|
if (!readOnly && editLink && delForm && delBtn) {
|
||||||
var id = d.ds_idx;
|
var id = d.ds_idx;
|
||||||
editLink.href = editBase + '/' + id;
|
editLink.href = editBase + '/' + id;
|
||||||
@@ -569,6 +603,38 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
|||||||
delBtn.disabled = true;
|
delBtn.disabled = true;
|
||||||
delBtn.classList.add('pointer-events-none', 'opacity-40');
|
delBtn.classList.add('pointer-events-none', 'opacity-40');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 개인정보 원문 보기(2단계 인증 step-up) ──
|
||||||
|
(function () {
|
||||||
|
var box = document.getElementById('ds-pii-reveal');
|
||||||
|
if (!box) return;
|
||||||
|
var btn = document.getElementById('ds-pii-reveal-btn');
|
||||||
|
var form = document.getElementById('ds-pii-reveal-form');
|
||||||
|
var otp = document.getElementById('ds-pii-otp');
|
||||||
|
var reason = document.getElementById('ds-pii-reason');
|
||||||
|
var submit = document.getElementById('ds-pii-reveal-submit');
|
||||||
|
var cancel = document.getElementById('ds-pii-reveal-cancel');
|
||||||
|
var msg = document.getElementById('ds-pii-reveal-msg');
|
||||||
|
var endpoint = new URL('<?= mgmt_url('designated-shops/reveal-pii') ?>', window.location.href).pathname;
|
||||||
|
var csrfName = '<?= csrf_token() ?>';
|
||||||
|
btn.addEventListener('click', function () { form.classList.remove('hidden'); otp.focus(); });
|
||||||
|
cancel.addEventListener('click', function () { form.classList.add('hidden'); msg.textContent = ''; });
|
||||||
|
submit.addEventListener('click', function () {
|
||||||
|
msg.textContent = '';
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
body.set('totp_code', (otp.value || '').trim());
|
||||||
|
body.set('reason', (reason.value || '').trim());
|
||||||
|
var t = document.querySelector('meta[name="' + csrfName + '"]');
|
||||||
|
body.set(csrfName, '<?= csrf_hash() ?>');
|
||||||
|
fetch(endpoint, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: body })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d && d.ok) { location.reload(); }
|
||||||
|
else { msg.textContent = (d && d.error) || '열람 승인 실패'; }
|
||||||
|
})
|
||||||
|
.catch(function () { msg.textContent = '통신 오류'; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -630,7 +696,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
|||||||
<td class="text-left"><?= esc($dongDispP) ?></td>
|
<td class="text-left"><?= esc($dongDispP) ?></td>
|
||||||
<td class="text-center"><?= esc($daDispP) ?></td>
|
<td class="text-center"><?= esc($daDispP) ?></td>
|
||||||
<td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td>
|
<td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td>
|
||||||
<td class="text-left"><?= esc($row->ds_rep_name ?? '') ?></td>
|
<td class="text-left"><?= esc($repDisp($row)) ?></td>
|
||||||
<td class="text-left"><?= esc($row->ds_name ?? '') ?></td>
|
<td class="text-left"><?= esc($row->ds_name ?? '') ?></td>
|
||||||
<td class="text-left"><?= esc($zipP) ?></td>
|
<td class="text-left"><?= esc($zipP) ?></td>
|
||||||
<td class="text-left"><?= esc($addrCombinedP) ?></td>
|
<td class="text-left"><?= esc($addrCombinedP) ?></td>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ $nf = static fn ($n): string => number_format((int) $n);
|
|||||||
<section class="p-3 bg-white border-b border-gray-200 no-print">
|
<section class="p-3 bg-white border-b border-gray-200 no-print">
|
||||||
<form method="get" action="<?= mgmt_url('reports/custom') ?>" id="custom-report-form" class="space-y-3">
|
<form method="get" action="<?= mgmt_url('reports/custom') ?>" id="custom-report-form" class="space-y-3">
|
||||||
<input type="hidden" name="applied" value="1"/>
|
<input type="hidden" name="applied" value="1"/>
|
||||||
|
<input type="hidden" name="export" id="cr-export" value="0"/>
|
||||||
<div class="flex flex-wrap items-end gap-3 text-sm">
|
<div class="flex flex-wrap items-end gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-bold text-gray-600 mb-1">표 제목</label>
|
<label class="block text-xs font-bold text-gray-600 mb-1">표 제목</label>
|
||||||
@@ -60,7 +61,8 @@ $nf = static fn ($n): string => number_format((int) $n);
|
|||||||
<option value="summary" <?= $mode === 'summary' ? 'selected' : '' ?>>품목별 집계</option>
|
<option value="summary" <?= $mode === 'summary' ? 'selected' : '' ?>>품목별 집계</option>
|
||||||
</select>
|
</select>
|
||||||
</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>
|
<button type="submit" onclick="document.getElementById('cr-export').value='0'" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">조회</button>
|
||||||
|
<button type="submit" onclick="document.getElementById('cr-export').value='1'" class="border border-btn-excel-border text-btn-excel-text px-4 py-1.5 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ if (window.top !== window.self) { try { window.top.location.href = <?= json_enco
|
|||||||
<header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow">
|
<header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow">
|
||||||
<a href="<?= base_url() ?>" class="flex items-center gap-2 shrink-0 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)">
|
<a href="<?= base_url() ?>" class="flex items-center gap-2 shrink-0 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-white shrink-0" aria-hidden="true" focusable="false">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-white shrink-0" aria-hidden="true" focusable="false">
|
||||||
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
|
<path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="leading-none flex flex-col">
|
<span class="leading-none flex flex-col">
|
||||||
<strong class="text-base font-extrabold tracking-wide">GBLS</strong>
|
<strong class="text-base font-extrabold tracking-wide">GBLS</strong>
|
||||||
|
|||||||
@@ -788,6 +788,16 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 박스수량 입력 중 Enter → 다음 봉투의 박스수량 입력칸으로 이동(#39)
|
||||||
|
selectedBody.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'Enter' || !event.target.classList.contains('item-qty-box')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const inputs = Array.from(selectedBody.querySelectorAll('.item-qty-box'));
|
||||||
|
const next = inputs[inputs.indexOf(event.target) + 1];
|
||||||
|
if (next) { next.focus(); next.select(); }
|
||||||
|
else { event.target.blur(); }
|
||||||
|
});
|
||||||
|
|
||||||
referenceRows.forEach((row) => {
|
referenceRows.forEach((row) => {
|
||||||
row.addEventListener('click', (event) => {
|
row.addEventListener('click', (event) => {
|
||||||
const button = event.target.closest('.js-toggle-bag');
|
const button = event.target.closest('.js-toggle-bag');
|
||||||
|
|||||||
271
app/Views/bag/inventory_stock_barcode_list.php
Normal file
271
app/Views/bag/inventory_stock_barcode_list.php
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 실사 재고 바코드 리스트 — 현재 창고에 있어야 하는 봉투 번호(바코드) 목록.
|
||||||
|
* 초기 1페이지만 렌더하고, 스크롤 시 AJAX 로 다음 청크를 이어 붙인다(레이지 로딩).
|
||||||
|
* @var string $bagCode
|
||||||
|
* @var string $keyword
|
||||||
|
* @var list<array<string,mixed>> $itemOptions
|
||||||
|
* @var list<array<string,mixed>> $rows 초기 청크
|
||||||
|
* @var int $totalBoxes
|
||||||
|
* @var int $totalPacks
|
||||||
|
* @var int $totalSheets
|
||||||
|
* @var int $pageSize
|
||||||
|
* @var int $loadedCount
|
||||||
|
*/
|
||||||
|
$bagCode = (string) ($bagCode ?? '');
|
||||||
|
$keyword = (string) ($keyword ?? '');
|
||||||
|
$itemOptions = is_array($itemOptions ?? null) ? $itemOptions : [];
|
||||||
|
$rows = is_array($rows ?? null) ? $rows : [];
|
||||||
|
$totalBoxes = (int) ($totalBoxes ?? 0);
|
||||||
|
$totalPacks = (int) ($totalPacks ?? 0);
|
||||||
|
$totalSheets = (int) ($totalSheets ?? 0);
|
||||||
|
$pageSize = (int) ($pageSize ?? 200);
|
||||||
|
$loadedCount = (int) ($loadedCount ?? count($rows));
|
||||||
|
$baseUrl = site_url('bag/inventory/stock-barcode-list');
|
||||||
|
$exportQs = http_build_query(['bag_code' => $bagCode, 'keyword' => $keyword, 'export' => 1]);
|
||||||
|
$hasMore = $loadedCount < $totalPacks;
|
||||||
|
?>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<section class="border border-gray-300 rounded-lg bg-white p-3 no-print">
|
||||||
|
<div class="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-bold text-gray-800">실사 재고 바코드 리스트</h2>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">현재 창고에 남아있어야 하는(재고 상태) 봉투 번호를 뽑아 실사 시 실물과 대조하세요.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<button type="button" id="sbl-print" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
|
||||||
|
<a href="<?= esc($baseUrl . '?' . $exportQs) ?>" class="border border-green-500 text-green-700 px-3 py-1.5 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" action="<?= esc($baseUrl) ?>" class="flex flex-wrap items-end gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-600 mb-1">품목</label>
|
||||||
|
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1 w-56">
|
||||||
|
<option value="">— 전체 품목 —</option>
|
||||||
|
<?php foreach ($itemOptions as $opt): ?>
|
||||||
|
<?php $oc = (string) ($opt['brpc_bag_code'] ?? ''); ?>
|
||||||
|
<option value="<?= esc($oc, 'attr') ?>" <?= $oc === $bagCode ? 'selected' : '' ?>>
|
||||||
|
<?= esc(($opt['bag_name'] ?? '') !== '' ? $opt['bag_name'] : $oc) ?>
|
||||||
|
(팩 <?= number_format((int) ($opt['pack_cnt'] ?? 0)) ?> · <?= number_format((int) ($opt['sheet_qty'] ?? 0)) ?>매)
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-600 mb-1">번호 검색</label>
|
||||||
|
<input type="text" name="keyword" value="<?= esc($keyword, 'attr') ?>" placeholder="박스/팩/시트번호/LOT" class="border border-gray-300 rounded px-2 py-1 w-56"/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="text-white px-4 py-1.5 rounded-sm text-sm hover:opacity-90 transition" style="background:#1f2b4d;">조회</button>
|
||||||
|
<?php if ($bagCode !== '' || $keyword !== ''): ?>
|
||||||
|
<a href="<?= esc($baseUrl) ?>" class="text-gray-500 hover:underline text-sm pb-1">초기화</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="border border-gray-300 rounded-lg bg-white p-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm mb-2 print-title">
|
||||||
|
<span class="font-bold text-gray-800">창고 재고 바코드</span>
|
||||||
|
<span class="text-gray-600">박스 <b class="text-gray-900"><?= number_format($totalBoxes) ?></b></span>
|
||||||
|
<span class="text-gray-600">팩 <b class="text-gray-900"><?= number_format($totalPacks) ?></b></span>
|
||||||
|
<span class="text-gray-600">총 매수 <b class="text-gray-900"><?= number_format($totalSheets) ?></b></span>
|
||||||
|
<span class="text-gray-400 text-xs ml-auto no-print"><?= esc(date('Y-m-d H:i')) ?> 기준 · 표시 <b id="sbl-shown"><?= number_format($loadedCount) ?></b>/<?= number_format($totalPacks) ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm border-collapse"
|
||||||
|
id="sbl-table"
|
||||||
|
data-endpoint="<?= esc($baseUrl, 'attr') ?>"
|
||||||
|
data-bag-code="<?= esc($bagCode, 'attr') ?>"
|
||||||
|
data-keyword="<?= esc($keyword, 'attr') ?>"
|
||||||
|
data-offset="<?= (int) $loadedCount ?>"
|
||||||
|
data-page-size="<?= (int) $pageSize ?>"
|
||||||
|
data-total="<?= (int) $totalPacks ?>"
|
||||||
|
data-has-more="<?= $hasMore ? '1' : '0' ?>">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-100 text-gray-700">
|
||||||
|
<th class="border border-gray-300 px-2 py-1 text-center w-12">No</th>
|
||||||
|
<th class="border border-gray-300 px-2 py-1 text-left">품목</th>
|
||||||
|
<th class="border border-gray-300 px-2 py-1 text-left w-24">LOT</th>
|
||||||
|
<th class="border border-gray-300 px-2 py-1 text-left">박스코드</th>
|
||||||
|
<th class="border border-gray-300 px-2 py-1 text-left">팩코드</th>
|
||||||
|
<th class="border border-gray-300 px-2 py-1 text-left">시작번호</th>
|
||||||
|
<th class="border border-gray-300 px-2 py-1 text-left">끝번호</th>
|
||||||
|
<th class="border border-gray-300 px-2 py-1 text-right w-20">매수</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sbl-tbody">
|
||||||
|
<?php if ($rows === []): ?>
|
||||||
|
<tr id="sbl-empty"><td colspan="8" class="border border-gray-300 px-2 py-6 text-center text-gray-400">재고로 남아있는 바코드가 없습니다.</td></tr>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($rows as $i => $r): ?>
|
||||||
|
<tr class="hover:bg-blue-50/40">
|
||||||
|
<td class="border border-gray-300 px-2 py-1 text-center text-gray-500"><?= $i + 1 ?></td>
|
||||||
|
<td class="border border-gray-300 px-2 py-1"><?= esc(($r['brpc_bag_name'] ?? '') !== '' ? $r['brpc_bag_name'] : ($r['brpc_bag_code'] ?? '')) ?></td>
|
||||||
|
<td class="border border-gray-300 px-2 py-1"><?= esc((string) ($r['brpc_lot_no'] ?? '')) ?></td>
|
||||||
|
<td class="border border-gray-300 px-2 py-1 font-mono text-xs"><?= esc((string) ($r['brpc_box_code'] ?? '')) ?></td>
|
||||||
|
<td class="border border-gray-300 px-2 py-1 font-mono text-xs"><?= esc((string) ($r['brpc_pack_code'] ?? '')) ?></td>
|
||||||
|
<td class="border border-gray-300 px-2 py-1 font-mono text-xs"><?= esc((string) ($r['brpc_sheet_start_code'] ?? '')) ?></td>
|
||||||
|
<td class="border border-gray-300 px-2 py-1 font-mono text-xs"><?= esc((string) ($r['brpc_sheet_end_code'] ?? '')) ?></td>
|
||||||
|
<td class="border border-gray-300 px-2 py-1 text-right"><?= number_format((int) ($r['brpc_sheet_qty'] ?? 0)) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
<?php if ($rows !== []): ?>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="bg-gray-50 font-bold text-gray-800">
|
||||||
|
<td class="border border-gray-300 px-2 py-1 text-center" colspan="3">합계</td>
|
||||||
|
<td class="border border-gray-300 px-2 py-1">박스 <?= number_format($totalBoxes) ?></td>
|
||||||
|
<td class="border border-gray-300 px-2 py-1" colspan="3">팩 <?= number_format($totalPacks) ?></td>
|
||||||
|
<td class="border border-gray-300 px-2 py-1 text-right"><?= number_format($totalSheets) ?></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
<?php endif; ?>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 레이지 로딩 감시 지점 + 수동 더보기 폴백 -->
|
||||||
|
<div id="sbl-sentinel" class="no-print py-3 text-center text-sm text-gray-400" <?= $hasMore ? '' : 'style="display:none;"' ?>>
|
||||||
|
<span id="sbl-loading" style="display:none;">불러오는 중…</span>
|
||||||
|
<button type="button" id="sbl-more" class="border border-gray-300 text-gray-600 px-4 py-1.5 rounded-sm hover:bg-gray-50 transition">더 보기</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
.print-title { margin-bottom: 6px; }
|
||||||
|
table { font-size: 11px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var table = document.getElementById('sbl-table');
|
||||||
|
if (!table) return;
|
||||||
|
var tbody = document.getElementById('sbl-tbody');
|
||||||
|
var sentinel = document.getElementById('sbl-sentinel');
|
||||||
|
var loading = document.getElementById('sbl-loading');
|
||||||
|
var moreBtn = document.getElementById('sbl-more');
|
||||||
|
var shownEl = document.getElementById('sbl-shown');
|
||||||
|
var printBtn = document.getElementById('sbl-print');
|
||||||
|
|
||||||
|
// baseURL(예: 로컬 :8080)과 실제 접속 origin(:8045 등)이 달라도 항상 현재 origin 기준으로 호출
|
||||||
|
var endpoint = table.getAttribute('data-endpoint');
|
||||||
|
try { endpoint = location.origin + new URL(endpoint, location.href).pathname; }
|
||||||
|
catch (e) { endpoint = location.pathname; }
|
||||||
|
var bagCode = table.getAttribute('data-bag-code') || '';
|
||||||
|
var keyword = table.getAttribute('data-keyword') || '';
|
||||||
|
var pageSize = parseInt(table.getAttribute('data-page-size'), 10) || 200;
|
||||||
|
var offset = parseInt(table.getAttribute('data-offset'), 10) || 0;
|
||||||
|
var total = parseInt(table.getAttribute('data-total'), 10) || 0;
|
||||||
|
var hasMore = table.getAttribute('data-has-more') === '1';
|
||||||
|
var busy = false;
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
function fmt(n) { return (n || 0).toLocaleString(); }
|
||||||
|
|
||||||
|
function rowHtml(r, no) {
|
||||||
|
return '<tr class="hover:bg-blue-50/40">'
|
||||||
|
+ '<td class="border border-gray-300 px-2 py-1 text-center text-gray-500">' + no + '</td>'
|
||||||
|
+ '<td class="border border-gray-300 px-2 py-1">' + esc(r.name) + '</td>'
|
||||||
|
+ '<td class="border border-gray-300 px-2 py-1">' + esc(r.lot) + '</td>'
|
||||||
|
+ '<td class="border border-gray-300 px-2 py-1 font-mono text-xs">' + esc(r.box) + '</td>'
|
||||||
|
+ '<td class="border border-gray-300 px-2 py-1 font-mono text-xs">' + esc(r.pack) + '</td>'
|
||||||
|
+ '<td class="border border-gray-300 px-2 py-1 font-mono text-xs">' + esc(r.start) + '</td>'
|
||||||
|
+ '<td class="border border-gray-300 px-2 py-1 font-mono text-xs">' + esc(r.end) + '</td>'
|
||||||
|
+ '<td class="border border-gray-300 px-2 py-1 text-right">' + fmt(r.qty) + '</td>'
|
||||||
|
+ '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(off, lim) {
|
||||||
|
var p = new URLSearchParams();
|
||||||
|
p.set('ajax', '1');
|
||||||
|
p.set('offset', off);
|
||||||
|
p.set('limit', lim);
|
||||||
|
if (bagCode) p.set('bag_code', bagCode);
|
||||||
|
if (keyword) p.set('keyword', keyword);
|
||||||
|
return endpoint + '?' + p.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRows(rows) {
|
||||||
|
if (!rows || !rows.length) return;
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
html += rowHtml(rows[i], offset + i + 1);
|
||||||
|
}
|
||||||
|
tbody.insertAdjacentHTML('beforeend', html);
|
||||||
|
offset += rows.length;
|
||||||
|
if (shownEl) shownEl.textContent = fmt(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음 청크 한 번 로드
|
||||||
|
function loadMore() {
|
||||||
|
if (busy || !hasMore) return Promise.resolve();
|
||||||
|
busy = true;
|
||||||
|
if (loading) loading.style.display = 'inline';
|
||||||
|
if (moreBtn) moreBtn.style.display = 'none';
|
||||||
|
return fetch(buildUrl(offset, pageSize), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function (res) { return res.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
appendRows(data.rows);
|
||||||
|
hasMore = !!data.hasMore;
|
||||||
|
if (!hasMore && sentinel) sentinel.style.display = 'none';
|
||||||
|
})
|
||||||
|
.catch(function () { /* 네트워크 오류 시 더보기 버튼 유지 */ })
|
||||||
|
.finally(function () {
|
||||||
|
busy = false;
|
||||||
|
if (loading) loading.style.display = 'none';
|
||||||
|
if (hasMore && moreBtn) moreBtn.style.display = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 남은 전체를 한 번에 로드(인쇄용)
|
||||||
|
function loadAll() {
|
||||||
|
if (!hasMore) return Promise.resolve();
|
||||||
|
busy = true;
|
||||||
|
if (loading) loading.style.display = 'inline';
|
||||||
|
if (moreBtn) moreBtn.style.display = 'none';
|
||||||
|
return fetch(buildUrl(offset, 5000), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function (res) { return res.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
appendRows(data.rows);
|
||||||
|
hasMore = !!data.hasMore;
|
||||||
|
busy = false;
|
||||||
|
if (hasMore) return loadAll();
|
||||||
|
if (sentinel) sentinel.style.display = 'none';
|
||||||
|
if (loading) loading.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크롤 자동 로딩(IntersectionObserver) — 미지원/iframe 대비 더보기 버튼 폴백 병행
|
||||||
|
if (hasMore && 'IntersectionObserver' in window) {
|
||||||
|
var io = new IntersectionObserver(function (entries) {
|
||||||
|
if (entries[0] && entries[0].isIntersecting) loadMore();
|
||||||
|
}, { rootMargin: '300px' });
|
||||||
|
io.observe(sentinel);
|
||||||
|
}
|
||||||
|
if (moreBtn) moreBtn.addEventListener('click', loadMore);
|
||||||
|
|
||||||
|
// 인쇄 — 전체를 먼저 불러온 뒤 출력(부분만 인쇄되는 것 방지)
|
||||||
|
if (printBtn) {
|
||||||
|
printBtn.addEventListener('click', function () {
|
||||||
|
if (!hasMore) { window.print(); return; }
|
||||||
|
printBtn.disabled = true;
|
||||||
|
var orig = printBtn.textContent;
|
||||||
|
printBtn.textContent = '전체 불러오는 중…';
|
||||||
|
loadAll().then(function () {
|
||||||
|
printBtn.disabled = false;
|
||||||
|
printBtn.textContent = orig;
|
||||||
|
window.print();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -191,9 +191,12 @@ if ($effectiveLgIdx) {
|
|||||||
|
|
||||||
var STORE_KEY = 'jrj_ws_tabs';
|
var STORE_KEY = 'jrj_ws_tabs';
|
||||||
var WS_OWNER = '<?= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별
|
var WS_OWNER = '<?= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별
|
||||||
function persist() {
|
var WS_SAVE_URL = '<?= site_url('bag/pref/workspace-tabs') ?>';
|
||||||
try {
|
var WS_CSRF_NAME = '<?= csrf_token() ?>';
|
||||||
sessionStorage.setItem(STORE_KEY, JSON.stringify({
|
var wsCsrfHash = '<?= csrf_hash() ?>';
|
||||||
|
var wsSaveTimer = null;
|
||||||
|
function wsStateObj() {
|
||||||
|
return {
|
||||||
owner: WS_OWNER,
|
owner: WS_OWNER,
|
||||||
tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }),
|
tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }),
|
||||||
layout: layout,
|
layout: layout,
|
||||||
@@ -201,8 +204,20 @@ if ($effectiveLgIdx) {
|
|||||||
vRatio: vRatio,
|
vRatio: vRatio,
|
||||||
hRatio: hRatio,
|
hRatio: hRatio,
|
||||||
slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; })
|
slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; })
|
||||||
}));
|
};
|
||||||
} catch (e) {}
|
}
|
||||||
|
function persist() {
|
||||||
|
var obj = wsStateObj();
|
||||||
|
var json = JSON.stringify(obj);
|
||||||
|
try { sessionStorage.setItem(STORE_KEY, json); } catch (e) {}
|
||||||
|
// 계정(DB)에 저장 → 다음 로그인/다른 기기에서도 자동 복원 (연속 변경은 디바운스)
|
||||||
|
clearTimeout(wsSaveTimer);
|
||||||
|
wsSaveTimer = setTimeout(function () {
|
||||||
|
var b = new URLSearchParams();
|
||||||
|
b.set(WS_CSRF_NAME, wsCsrfHash); b.set('state', json);
|
||||||
|
fetch(WS_SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
|
||||||
|
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) wsCsrfHash = d.csrf; }).catch(function () {});
|
||||||
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성
|
// 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성
|
||||||
@@ -560,11 +575,19 @@ if ($effectiveLgIdx) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 첫 화면: 세션 저장 복원(레이아웃·분할 배치 포함), 없으면 대시보드 1분할
|
// 첫 화면: ① 계정(DB) 저장 자동복원 → ② 세션 저장 → ③ 없으면 대시보드 1분할
|
||||||
|
var SERVER_STATE = <?= $savedWorkspaceJson ?? 'null' ?>;
|
||||||
(function restore() {
|
(function restore() {
|
||||||
var saved = null;
|
var saved = null;
|
||||||
|
// ① 계정 DB에 저장된 상태 우선(다음 로그인·다른 기기에서도 복원)
|
||||||
|
if (SERVER_STATE && SERVER_STATE.tabs && SERVER_STATE.tabs.length) {
|
||||||
|
saved = SERVER_STATE;
|
||||||
|
}
|
||||||
|
// ② 없으면 이 브라우저 세션 저장 사용
|
||||||
|
if (!saved) {
|
||||||
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {}
|
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {}
|
||||||
if (saved && saved.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; }
|
if (saved && saved.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; }
|
||||||
|
}
|
||||||
if (saved && saved.tabs && saved.tabs.length) {
|
if (saved && saved.tabs && saved.tabs.length) {
|
||||||
saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title, { noFocus: true }); });
|
saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title, { noFocus: true }); });
|
||||||
layout = (saved.layout === 'lr' || saved.layout === 'tb' || saved.layout === 'quad') ? saved.layout : 'single';
|
layout = (saved.layout === 'lr' || saved.layout === 'tb' || saved.layout === 'quad') ? saved.layout : 'single';
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ unset($g);
|
|||||||
<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>
|
<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>
|
||||||
<th class="text-center">B</th>
|
<th class="text-center">B</th>
|
||||||
<th class="text-center">P</th>
|
<th class="text-center">P</th>
|
||||||
@@ -212,7 +212,7 @@ unset($g);
|
|||||||
<td class="text-right px-2"><?= number_format($r['box']) ?></td>
|
<td class="text-right px-2"><?= number_format($r['box']) ?></td>
|
||||||
<td class="text-right px-2"><?= number_format($boxPrice) ?></td>
|
<td class="text-right px-2"><?= number_format($boxPrice) ?></td>
|
||||||
<td class="text-right px-2"><?= number_format($r['price']) ?></td>
|
<td class="text-right px-2"><?= number_format($r['price']) ?></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-qty-input item-pack-input" type="number" min="0" value="0"/><input type="hidden" name="item_qty[]" class="item-qty-hidden" value="0"/></td>
|
||||||
<td class="text-right px-2 item-amount-cell">0</td>
|
<td class="text-right px-2 item-amount-cell">0</td>
|
||||||
<td class="text-center item-box-cell">0</td>
|
<td class="text-center item-box-cell">0</td>
|
||||||
<td class="text-center item-pack-cell">0</td>
|
<td class="text-center item-pack-cell">0</td>
|
||||||
@@ -330,12 +330,18 @@ unset($g);
|
|||||||
}
|
}
|
||||||
|
|
||||||
function calcRow(row) {
|
function calcRow(row) {
|
||||||
const qtyInput = row.querySelector('.item-qty-input');
|
const qtyInput = row.querySelector('.item-pack-input');
|
||||||
const qty = parseInt(qtyInput.value || '0', 10) || 0;
|
const hiddenQty = row.querySelector('.item-qty-hidden');
|
||||||
|
const packQty = parseInt(qtyInput.value || '0', 10) || 0; // 입력값 = 팩 수
|
||||||
const unitPrice = parseInt(row.dataset.unitPrice || '0', 10) || 0;
|
const unitPrice = parseInt(row.dataset.unitPrice || '0', 10) || 0;
|
||||||
const boxSheets = parseInt(row.dataset.boxSheets || '0', 10) || 0;
|
const boxSheets = parseInt(row.dataset.boxSheets || '0', 10) || 0;
|
||||||
const packSheets = parseInt(row.dataset.packSheets || '0', 10) || 0;
|
const packSheets = parseInt(row.dataset.packSheets || '0', 10) || 0;
|
||||||
|
|
||||||
|
// 팩당 낱장 수(팩 매수 미설정 봉투는 1:1로 처리)
|
||||||
|
const mult = packSheets > 0 ? packSheets : 1;
|
||||||
|
const qty = packQty * mult; // 실제 낱장 수 → 서버로 전송
|
||||||
|
if (hiddenQty) hiddenQty.value = String(qty);
|
||||||
|
|
||||||
let box = 0;
|
let box = 0;
|
||||||
let pack = 0;
|
let pack = 0;
|
||||||
let sheet = qty;
|
let sheet = qty;
|
||||||
@@ -359,14 +365,14 @@ unset($g);
|
|||||||
row.querySelector('.item-pack-cell').textContent = pack ? nf(pack) : '';
|
row.querySelector('.item-pack-cell').textContent = pack ? nf(pack) : '';
|
||||||
row.querySelector('.item-sheet-cell').textContent = sheet ? nf(sheet) : '';
|
row.querySelector('.item-sheet-cell').textContent = sheet ? nf(sheet) : '';
|
||||||
|
|
||||||
return { qty, amount, box, pack, sheet };
|
return { packQty, amount, box, pack, sheet };
|
||||||
}
|
}
|
||||||
|
|
||||||
function recalcAllRows() {
|
function recalcAllRows() {
|
||||||
let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0;
|
let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0;
|
||||||
document.querySelectorAll('.order-row').forEach((row) => {
|
document.querySelectorAll('.order-row').forEach((row) => {
|
||||||
const r = calcRow(row);
|
const r = calcRow(row);
|
||||||
sumQty += r.qty;
|
sumQty += r.packQty;
|
||||||
sumAmount += r.amount;
|
sumAmount += r.amount;
|
||||||
sumBox += r.box;
|
sumBox += r.box;
|
||||||
sumPack += r.pack;
|
sumPack += r.pack;
|
||||||
|
|||||||
@@ -69,12 +69,14 @@
|
|||||||
<input type="date" lang="ko" name="so_delivery_date" id="detail-delivery-date-input" class="border border-gray-300 rounded px-2 py-0.5 text-sm"/>
|
<input type="date" lang="ko" name="so_delivery_date" id="detail-delivery-date-input" class="border border-gray-300 rounded px-2 py-0.5 text-sm"/>
|
||||||
</div>
|
</div>
|
||||||
<div><span class="font-semibold text-gray-700 inline-block w-20">상태</span> <span id="detail-status">-</span></div>
|
<div><span class="font-semibold text-gray-700 inline-block w-20">상태</span> <span id="detail-status">-</span></div>
|
||||||
|
<div><span class="font-semibold text-gray-700 inline-block w-20">입금상태</span> <span id="detail-paid-status" class="font-semibold">-</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 px-1">
|
<div class="flex flex-wrap items-center justify-between gap-2 px-1">
|
||||||
<span class="text-sm font-semibold text-gray-700">접수 품목 내역</span>
|
<span class="text-sm font-semibold text-gray-700">접수 품목 내역</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" id="btn-toggle-paid" class="border border-emerald-300 text-emerald-700 px-3 py-1 rounded-sm text-sm hover:bg-emerald-50" disabled>입금확인</button>
|
||||||
<button type="submit" id="btn-save" class="bg-btn-search text-white px-3 py-1 rounded-sm text-sm shadow hover:opacity-90" disabled>주문 수정 저장</button>
|
<button type="submit" id="btn-save" class="bg-btn-search text-white px-3 py-1 rounded-sm text-sm shadow hover:opacity-90" disabled>주문 수정 저장</button>
|
||||||
<button type="button" id="btn-receipt" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50" disabled>입금자 영수증 출력</button>
|
<button type="button" id="btn-receipt" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50" disabled>입금자 영수증 출력</button>
|
||||||
<button type="submit" id="btn-cancel-order" form="order-cancel-form" class="border border-red-300 text-red-600 px-3 py-1 rounded-sm text-sm hover:bg-red-50" disabled>주문 취소</button>
|
<button type="submit" id="btn-cancel-order" form="order-cancel-form" class="border border-red-300 text-red-600 px-3 py-1 rounded-sm text-sm hover:bg-red-50" disabled>주문 취소</button>
|
||||||
@@ -121,26 +123,25 @@
|
|||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<div class="flex items-center justify-between px-1 mb-1 text-sm">
|
<div class="flex items-center justify-between px-1 mb-1 text-sm">
|
||||||
<span class="font-semibold text-gray-700">포장 명세</span>
|
<span class="font-semibold text-gray-700">스캔 현황</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
<span class="text-gray-600">포장량: <span id="packing-total" class="font-bold text-blue-700">0</span> 개</span>
|
<span class="text-gray-600">포장량: <span id="packing-total" class="font-bold text-blue-700">0</span> 개</span>
|
||||||
|
<button type="button" id="btn-packing-modal" class="border border-gray-300 text-gray-600 px-2 py-0.5 rounded-sm text-xs hover:bg-gray-50 disabled:opacity-50" disabled>포장 명세 보기</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="packing-scroll" class="border border-gray-300 rounded-lg overflow-auto max-h-[179px]">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<table class="w-full data-table text-sm">
|
<!-- 최근 스캔 2건 -->
|
||||||
<thead>
|
<div class="border border-gray-300 rounded-lg overflow-hidden">
|
||||||
<tr>
|
<div class="px-2 py-1 bg-gray-50 border-b border-gray-200 text-xs font-semibold text-gray-600">최근 스캔</div>
|
||||||
<th class="w-10 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th>
|
<div id="recent-scans" class="p-2 text-xs text-gray-400 min-h-[3.5rem]">스캔 내역이 없습니다.</div>
|
||||||
<th class="w-40 text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
|
|
||||||
<th class="text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투코드</th>
|
|
||||||
<th class="w-16 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th>
|
|
||||||
<th class="w-16 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="packing-body">
|
|
||||||
<tr><td colspan="5" class="text-center py-6 text-gray-400">주문을 선택해 주세요.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div><!-- /포장 명세 -->
|
<!-- 앞으로 찍을 목록(품목별 남은 수량) -->
|
||||||
|
<div class="border border-gray-300 rounded-lg overflow-hidden">
|
||||||
|
<div class="px-2 py-1 bg-amber-50 border-b border-amber-200 text-xs font-semibold text-amber-800">앞으로 찍을 목록 (남은 수량)</div>
|
||||||
|
<div id="remaining-list" class="p-2 text-xs text-gray-400 max-h-[9rem] overflow-auto min-h-[3.5rem]">주문을 선택해 주세요.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><!-- /스캔 현황 -->
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- 주문 취소 폼 — 버튼은 접수 품목 내역 타이틀 옆(form 속성으로 연결) -->
|
<!-- 주문 취소 폼 — 버튼은 접수 품목 내역 타이틀 옆(form 속성으로 연결) -->
|
||||||
@@ -150,6 +151,36 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 포장 명세(전체 봉투코드) 팝업 -->
|
||||||
|
<div id="packing-modal-overlay" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/40 p-4">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col">
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-2">
|
||||||
|
<span class="text-sm font-bold text-gray-800">포장 명세 — <span id="packing-modal-title">-</span></span>
|
||||||
|
<button type="button" id="packing-modal-close" class="text-gray-400 hover:text-gray-700 text-lg leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 overflow-auto">
|
||||||
|
<table class="w-full data-table text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-10 text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th>
|
||||||
|
<th class="w-40 text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
|
||||||
|
<th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투코드</th>
|
||||||
|
<th class="w-16 text-right" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th>
|
||||||
|
<th class="w-16 text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="packing-modal-body">
|
||||||
|
<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 px-4 py-2 flex items-center justify-between">
|
||||||
|
<span class="text-xs text-gray-500">총 <span id="packing-modal-count">0</span>건 · 포장량 <span id="packing-modal-total">0</span></span>
|
||||||
|
<button type="button" id="packing-modal-close2" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">닫기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- [개발용 임시] 선택 주문 판매소 기준 스캔 가능 바코드 후보 — 페이지 맨 아래 배치 (개발 완료 후 이 블록과 API 제거) -->
|
<!-- [개발용 임시] 선택 주문 판매소 기준 스캔 가능 바코드 후보 — 페이지 맨 아래 배치 (개발 완료 후 이 블록과 API 제거) -->
|
||||||
<div id="dev-saleable-panel" class="mt-3 hidden border border-amber-400 bg-amber-50/50 p-2 rounded-sm">
|
<div id="dev-saleable-panel" class="mt-3 hidden border border-amber-400 bg-amber-50/50 p-2 rounded-sm">
|
||||||
<p class="text-[11px] text-amber-900 mb-1 leading-relaxed">
|
<p class="text-[11px] text-amber-900 mb-1 leading-relaxed">
|
||||||
@@ -209,6 +240,8 @@
|
|||||||
const btnSave = document.getElementById('btn-save');
|
const btnSave = document.getElementById('btn-save');
|
||||||
const btnCancel = document.getElementById('btn-cancel-order');
|
const btnCancel = document.getElementById('btn-cancel-order');
|
||||||
const btnReceipt = document.getElementById('btn-receipt');
|
const btnReceipt = document.getElementById('btn-receipt');
|
||||||
|
const btnTogglePaid = document.getElementById('btn-toggle-paid');
|
||||||
|
const paidApi = new URL('<?= base_url('bag/order/phone/manage/paid') ?>', window.location.href).pathname;
|
||||||
const receiptBase = '<?= base_url('bag/order/phone/receipt') ?>';
|
const receiptBase = '<?= base_url('bag/order/phone/receipt') ?>';
|
||||||
btnReceipt?.addEventListener('click', () => {
|
btnReceipt?.addEventListener('click', () => {
|
||||||
const id = inputSoIdx.value;
|
const id = inputSoIdx.value;
|
||||||
@@ -223,6 +256,43 @@
|
|||||||
|
|
||||||
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
|
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
|
||||||
|
|
||||||
|
function paintPaidButton(order) {
|
||||||
|
if (!btnTogglePaid) return;
|
||||||
|
const paid = order && Number(order.so_paid) === 1;
|
||||||
|
btnTogglePaid.textContent = paid ? '입금확인 취소' : '입금확인';
|
||||||
|
btnTogglePaid.className = paid
|
||||||
|
? 'border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50'
|
||||||
|
: 'border border-emerald-300 text-emerald-700 px-3 py-1 rounded-sm text-sm hover:bg-emerald-50';
|
||||||
|
}
|
||||||
|
|
||||||
|
btnTogglePaid?.addEventListener('click', async () => {
|
||||||
|
const id = inputSoIdx.value;
|
||||||
|
if (!id) return;
|
||||||
|
const o = orderMap.get(String(id));
|
||||||
|
if (!o) return;
|
||||||
|
const nextPaid = Number(o.so_paid) === 1 ? 0 : 1;
|
||||||
|
if (nextPaid === 0 && !confirm('입금확인을 취소하면 이 주문은 포장(스캔)이 차단됩니다.\n계속할까요?')) return;
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set(csrfName, csrfHash);
|
||||||
|
body.set('so_idx', String(id));
|
||||||
|
body.set('paid', String(nextPaid));
|
||||||
|
btnTogglePaid.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(paidApi, { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.csrf) csrfHash = data.csrf;
|
||||||
|
if (!data || !data.ok) { alert((data && data.message) || '처리에 실패했습니다.'); btnTogglePaid.disabled = false; return; }
|
||||||
|
o.so_paid = nextPaid;
|
||||||
|
setHeader(o);
|
||||||
|
paintPaidButton(o);
|
||||||
|
btnTogglePaid.disabled = false;
|
||||||
|
renderList(); // 리스트의 입금 표시 갱신
|
||||||
|
} catch (e) {
|
||||||
|
alert('네트워크 오류로 처리하지 못했습니다.');
|
||||||
|
btnTogglePaid.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function setHeader(order) {
|
function setHeader(order) {
|
||||||
var t = function (id, v) { document.getElementById(id).textContent = (v === undefined || v === null || v === '') ? '-' : v; };
|
var t = function (id, v) { document.getElementById(id).textContent = (v === undefined || v === null || v === '') ? '-' : v; };
|
||||||
t('detail-so-no', order ? displayNo(order) : '');
|
t('detail-so-no', order ? displayNo(order) : '');
|
||||||
@@ -234,6 +304,12 @@
|
|||||||
t('detail-shop-addr', order ? order.ds_addr : '');
|
t('detail-shop-addr', order ? order.ds_addr : '');
|
||||||
t('detail-order-date', order ? order.so_order_date : '');
|
t('detail-order-date', order ? order.so_order_date : '');
|
||||||
t('detail-status', order ? ((order.so_status === 'cancelled') ? '취소' : '정상') : '');
|
t('detail-status', order ? ((order.so_status === 'cancelled') ? '취소' : '정상') : '');
|
||||||
|
var ps = document.getElementById('detail-paid-status');
|
||||||
|
if (ps) {
|
||||||
|
var paid = order && Number(order.so_paid) === 1;
|
||||||
|
ps.textContent = order ? (paid ? '입금완료' : '미입금') : '-';
|
||||||
|
ps.style.color = order ? (paid ? '#059669' : '#e11d48') : '';
|
||||||
|
}
|
||||||
// 배달일 — 수정 가능한 date 입력
|
// 배달일 — 수정 가능한 date 입력
|
||||||
var dd = document.getElementById('detail-delivery-date-input');
|
var dd = document.getElementById('detail-delivery-date-input');
|
||||||
if (dd) {
|
if (dd) {
|
||||||
@@ -317,6 +393,7 @@
|
|||||||
btnSave.disabled = isCancelled;
|
btnSave.disabled = isCancelled;
|
||||||
btnCancel.disabled = isCancelled;
|
btnCancel.disabled = isCancelled;
|
||||||
if (btnReceipt) btnReceipt.disabled = false;
|
if (btnReceipt) btnReceipt.disabled = false;
|
||||||
|
if (btnTogglePaid) { btnTogglePaid.disabled = isCancelled; paintPaidButton(order); }
|
||||||
|
|
||||||
renderPacking(order); // 포장 명세 탭 동기화
|
renderPacking(order); // 포장 명세 탭 동기화
|
||||||
|
|
||||||
@@ -464,9 +541,6 @@
|
|||||||
// 현재 이 주문을 보고 있으면 상세·포장명세 즉시 갱신
|
// 현재 이 주문을 보고 있으면 상세·포장명세 즉시 갱신
|
||||||
if (String(selectedId) === String(data.so_idx)) {
|
if (String(selectedId) === String(data.so_idx)) {
|
||||||
renderPacking(order);
|
renderPacking(order);
|
||||||
// 새로 스캔한 봉투가 항상 맨 위(최신)에 보이도록 스크롤을 맨 위로 이동
|
|
||||||
const packingScrollEl = document.getElementById('packing-scroll');
|
|
||||||
if (packingScrollEl) packingScrollEl.scrollTop = 0;
|
|
||||||
const cell = detailBody.querySelector('.item-packed-cell[data-bag-code="' + (window.CSS && CSS.escape ? CSS.escape(String(data.bag_code)) : String(data.bag_code)) + '"]');
|
const cell = detailBody.querySelector('.item-packed-cell[data-bag-code="' + (window.CSS && CSS.escape ? CSS.escape(String(data.bag_code)) : String(data.bag_code)) + '"]');
|
||||||
if (cell) {
|
if (cell) {
|
||||||
cell.textContent = nf(Number(data.packed_qty || 0));
|
cell.textContent = nf(Number(data.packed_qty || 0));
|
||||||
@@ -580,32 +654,93 @@
|
|||||||
applySplit(currentPct());
|
applySplit(currentPct());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 포장 명세 탭: 주문 품목을 박스/팩/낱장 단위 행으로 펼침 ──────────
|
// ── 스캔 현황(최근 2건 + 앞으로 찍을 목록) + 포장 명세 팝업 ──────────
|
||||||
const packingBody = document.getElementById('packing-body');
|
|
||||||
const packingTotal = document.getElementById('packing-total');
|
const packingTotal = document.getElementById('packing-total');
|
||||||
|
const recentScansEl = document.getElementById('recent-scans');
|
||||||
|
const remainingListEl = document.getElementById('remaining-list');
|
||||||
|
const btnPackingModal = document.getElementById('btn-packing-modal');
|
||||||
|
const packingModalOverlay = document.getElementById('packing-modal-overlay');
|
||||||
|
const packingModalBody = document.getElementById('packing-modal-body');
|
||||||
|
const packingModalTitle = document.getElementById('packing-modal-title');
|
||||||
|
const packingModalCount = document.getElementById('packing-modal-count');
|
||||||
|
const packingModalTotal = document.getElementById('packing-modal-total');
|
||||||
|
let packingModalOrderId = null;
|
||||||
function packEsc(s) { return String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
function packEsc(s) { return String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
||||||
// 포장 명세 = 실제 스캔(=판매/포장)된 봉투코드 내역. 최근 스캔이 위로 오도록 역순 표시.
|
|
||||||
function renderPacking(order) {
|
// 스캔 코드 전체 → 표 행(HTML) + 건수/합계 (팝업용). 최근이 위로.
|
||||||
if (!packingBody) return;
|
function scanFullRows(order) {
|
||||||
const scans = Array.isArray(order.scans) ? order.scans.slice().reverse() : [];
|
const scans = Array.isArray(order.scans) ? order.scans.slice().reverse() : [];
|
||||||
let no = 0;
|
let no = 0, total = 0;
|
||||||
let totalQty = 0;
|
|
||||||
const rows = scans.map((s) => {
|
const rows = scans.map((s) => {
|
||||||
no++;
|
no++;
|
||||||
const code = s.bag_code || '';
|
const code = s.bag_code || '', name = s.bag_name || '', qty = parseInt(s.qty || 0, 10) || 0;
|
||||||
const name = s.bag_name || '';
|
total += qty;
|
||||||
const qty = parseInt(s.qty || 0, 10) || 0;
|
|
||||||
totalQty += qty;
|
|
||||||
return '<tr><td class="text-center">' + no + '</td>'
|
return '<tr><td class="text-center">' + no + '</td>'
|
||||||
+ '<td class="text-left pl-2 truncate" title="' + packEsc(code) + ' ' + packEsc(name) + '">' + packEsc(code) + ' ' + packEsc(name) + '</td>'
|
+ '<td class="text-left pl-2 truncate" title="' + packEsc(code) + ' ' + packEsc(name) + '">' + packEsc(code) + ' ' + packEsc(name) + '</td>'
|
||||||
+ '<td class="text-left pl-2 font-mono text-gray-600">' + packEsc(s.code || '') + '</td>'
|
+ '<td class="text-left pl-2 font-mono text-gray-600">' + packEsc(s.code || '') + '</td>'
|
||||||
+ '<td class="text-right pr-2">' + nf(qty) + '</td>'
|
+ '<td class="text-right pr-2">' + nf(qty) + '</td>'
|
||||||
+ '<td class="text-center">' + packEsc(s.unit || '') + '</td></tr>';
|
+ '<td class="text-center">' + packEsc(s.unit || '') + '</td></tr>';
|
||||||
});
|
});
|
||||||
packingBody.innerHTML = rows.length ? rows.join('')
|
return { html: rows.join(''), count: no, total: total };
|
||||||
: '<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>';
|
|
||||||
if (packingTotal) packingTotal.textContent = nf(totalQty);
|
|
||||||
}
|
}
|
||||||
|
function fillPackingModal(order) {
|
||||||
|
const full = scanFullRows(order);
|
||||||
|
if (packingModalBody) packingModalBody.innerHTML = full.count ? full.html
|
||||||
|
: '<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>';
|
||||||
|
if (packingModalCount) packingModalCount.textContent = nf(full.count);
|
||||||
|
if (packingModalTotal) packingModalTotal.textContent = nf(full.total);
|
||||||
|
}
|
||||||
|
// 앞으로 찍을 목록 = 품목별 남은(접수량 − 포장량)
|
||||||
|
function renderRemaining(order) {
|
||||||
|
if (!remainingListEl) return;
|
||||||
|
const items = Array.isArray(order.items) ? order.items : [];
|
||||||
|
const rows = items.map((it) => {
|
||||||
|
const qty = parseInt(it.soi_qty || 0, 10) || 0;
|
||||||
|
const packed = parseInt(it.soi_packed_qty || 0, 10) || 0;
|
||||||
|
if (qty <= 0) return '';
|
||||||
|
const remain = Math.max(0, qty - packed);
|
||||||
|
const name = (String(it.soi_bag_code || '') + ' ' + String(it.soi_bag_name || '')).trim();
|
||||||
|
if (remain <= 0) {
|
||||||
|
return '<div class="flex items-center justify-between text-emerald-600 py-0.5"><span class="truncate mr-2">' + packEsc(name) + '</span><span class="whitespace-nowrap">✓ 완료</span></div>';
|
||||||
|
}
|
||||||
|
return '<div class="flex items-center justify-between text-gray-700 py-0.5"><span class="truncate mr-2">' + packEsc(name) + '</span><span class="whitespace-nowrap font-semibold text-amber-700">' + nf(remain) + ' 매</span></div>';
|
||||||
|
}).filter(Boolean);
|
||||||
|
remainingListEl.innerHTML = rows.length ? rows.join('') : '<span class="text-gray-400">남은 수량이 없습니다.</span>';
|
||||||
|
}
|
||||||
|
// 스캔 현황 갱신(최근 2건 + 남은 목록 + 포장량 + 열린 팝업)
|
||||||
|
function renderPacking(order) {
|
||||||
|
const scans = Array.isArray(order.scans) ? order.scans : [];
|
||||||
|
const full = scanFullRows(order);
|
||||||
|
if (packingTotal) packingTotal.textContent = nf(full.total);
|
||||||
|
if (btnPackingModal) btnPackingModal.disabled = false;
|
||||||
|
if (recentScansEl) {
|
||||||
|
const last = scans.slice(-2).reverse();
|
||||||
|
recentScansEl.innerHTML = last.length
|
||||||
|
? last.map((s, i) => '<div class="flex items-center justify-between py-0.5 ' + (i === 0 ? 'font-semibold text-emerald-700' : 'text-gray-500') + '">'
|
||||||
|
+ '<span class="truncate mr-2 font-mono">' + (i === 0 ? '▶ ' : '') + packEsc(s.code || '') + '</span>'
|
||||||
|
+ '<span class="whitespace-nowrap text-gray-500">' + packEsc(s.bag_name || '') + ' · ' + nf(parseInt(s.qty || 0, 10) || 0) + '</span></div>').join('')
|
||||||
|
: '<span class="text-gray-400">스캔 내역이 없습니다.</span>';
|
||||||
|
}
|
||||||
|
renderRemaining(order);
|
||||||
|
if (packingModalOverlay && !packingModalOverlay.classList.contains('hidden') && String(packingModalOrderId) === String(order.so_idx)) {
|
||||||
|
fillPackingModal(order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 포장 명세 팝업 열기/닫기
|
||||||
|
function openPackingModal() {
|
||||||
|
const order = orderMap.get(String(selectedId));
|
||||||
|
if (!order) return;
|
||||||
|
packingModalOrderId = selectedId;
|
||||||
|
if (packingModalTitle) packingModalTitle.textContent = '접수 #' + displayNo(order) + ' · ' + (order.so_ds_name || '');
|
||||||
|
fillPackingModal(order);
|
||||||
|
packingModalOverlay.classList.remove('hidden');
|
||||||
|
packingModalOverlay.classList.add('flex');
|
||||||
|
}
|
||||||
|
function closePackingModal() { packingModalOverlay.classList.add('hidden'); packingModalOverlay.classList.remove('flex'); }
|
||||||
|
if (btnPackingModal) btnPackingModal.addEventListener('click', openPackingModal);
|
||||||
|
document.getElementById('packing-modal-close')?.addEventListener('click', closePackingModal);
|
||||||
|
document.getElementById('packing-modal-close2')?.addEventListener('click', closePackingModal);
|
||||||
|
if (packingModalOverlay) packingModalOverlay.addEventListener('click', (e) => { if (e.target === packingModalOverlay) closePackingModal(); });
|
||||||
|
|
||||||
// ── 접수 리스트 렌더 + 헤더 클릭 정렬 ──────────────────────────
|
// ── 접수 리스트 렌더 + 헤더 클릭 정렬 ──────────────────────────
|
||||||
let sortKey = 'so_idx', sortDir = 'desc', selectedId = null;
|
let sortKey = 'so_idx', sortDir = 'desc', selectedId = null;
|
||||||
@@ -668,6 +803,7 @@
|
|||||||
listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100'));
|
listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100'));
|
||||||
tr.classList.add('bg-blue-100');
|
tr.classList.add('bg-blue-100');
|
||||||
renderDetail(selectedId);
|
renderDetail(selectedId);
|
||||||
|
openPackingModal(); // 행 클릭 시 해당 주문 봉투코드 팝업 즉시 표시
|
||||||
});
|
});
|
||||||
|
|
||||||
detailBody?.addEventListener('input', (e) => {
|
detailBody?.addEventListener('input', (e) => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ $linkClass = $linkClass ?? 'app-brand flex items-center gap-2 shrink-0 text-base
|
|||||||
?>
|
?>
|
||||||
<a href="<?= esc($href) ?>" class="<?= esc($linkClass, 'attr') ?>" title="GBLS (Garbage Bag Logistics System)">
|
<a href="<?= esc($href) ?>" class="<?= esc($linkClass, 'attr') ?>" title="GBLS (Garbage Bag Logistics System)">
|
||||||
<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">
|
<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">
|
||||||
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
|
<path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="leading-none flex flex-col">
|
<span class="leading-none flex flex-col">
|
||||||
<strong class="font-extrabold tracking-wide">GBLS</strong>
|
<strong class="font-extrabold tracking-wide">GBLS</strong>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ $brandHref = $brandHref ?? base_url('dashboard/gov-portal');
|
|||||||
?>
|
?>
|
||||||
<a href="<?= esc($brandHref) ?>" class="gov-portal-brand">
|
<a href="<?= esc($brandHref) ?>" class="gov-portal-brand">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
|
||||||
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
|
<path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span style="display:inline-flex;flex-direction:column;line-height:1.02;">
|
<span style="display:inline-flex;flex-direction:column;line-height:1.02;">
|
||||||
<strong style="font-size:1.02rem;font-weight:800;letter-spacing:.5px;">GBLS</strong>
|
<strong style="font-size:1.02rem;font-weight:800;letter-spacing:.5px;">GBLS</strong>
|
||||||
|
|||||||
@@ -6,17 +6,31 @@
|
|||||||
var listEl = document.getElementById('portalSidebarList');
|
var listEl = document.getElementById('portalSidebarList');
|
||||||
var titleEl = document.getElementById('portalSidebarTitle');
|
var titleEl = document.getElementById('portalSidebarTitle');
|
||||||
|
|
||||||
// 기본정보관리 소메뉴 아이콘(Font Awesome solid) — 이름 키워드 기준(구체적인 것 먼저)
|
// 모든 상위메뉴의 소메뉴 아이콘(Font Awesome solid) — 이름 키워드 기준(구체적인 것 먼저).
|
||||||
|
// 서버 렌더(_dashboard_gov_portal_sidebar.php)의 매핑과 동일하게 유지할 것.
|
||||||
function sidebarIcon(name) {
|
function sidebarIcon(name) {
|
||||||
var n = String(name || '');
|
var n = String(name || '');
|
||||||
var rules = [
|
var rules = [
|
||||||
['바코드', 'fa-barcode'], ['조회', 'fa-magnifying-glass'],
|
['바코드', 'fa-barcode'], ['스캐너', 'fa-barcode'], ['번호', 'fa-hashtag'],
|
||||||
['신규', 'fa-chart-column'], ['현황', 'fa-chart-column'],
|
['판매대장', 'fa-book'], ['대장', 'fa-book'],
|
||||||
['판매소', 'fa-store'], ['대행소', 'fa-handshake'],
|
['집계표', 'fa-table-list'], ['일계표', 'fa-table-list'],
|
||||||
['담당자', 'fa-user-tie'], ['업체', 'fa-building'],
|
['수불', 'fa-right-left'], ['입출고', 'fa-right-left'],
|
||||||
['무료', 'fa-gift'], ['포장', 'fa-box'],
|
['무료', 'fa-gift'], ['반품', 'fa-rotate-left'], ['파기', 'fa-trash-can'],
|
||||||
['단가', 'fa-won-sign'], ['코드', 'fa-list-ol'],
|
['신규', 'fa-chart-column'], ['취소', 'fa-ban'],
|
||||||
['비밀번호', 'fa-key'], ['설정', 'fa-gear']
|
['발주파일', 'fa-file-arrow-down'], ['발주', 'fa-cart-shopping'],
|
||||||
|
['입고', 'fa-dolly'], ['재고', 'fa-warehouse'], ['불출', 'fa-truck-ramp-box'],
|
||||||
|
['전화', 'fa-phone'], ['접수', 'fa-clipboard-list'],
|
||||||
|
['판매현황', 'fa-chart-column'], ['판매소', 'fa-store'], ['대행소', 'fa-handshake'],
|
||||||
|
['담당자', 'fa-user-tie'], ['대상자', 'fa-user-check'], ['업체', 'fa-building'],
|
||||||
|
['단가', 'fa-won-sign'], ['코드', 'fa-list-ol'], ['포장', 'fa-box'],
|
||||||
|
['계획', 'fa-clipboard-check'], ['실사', 'fa-clipboard-check'],
|
||||||
|
['분석', 'fa-chart-line'], ['추이', 'fa-chart-line'], ['통계', 'fa-chart-line'],
|
||||||
|
['홈텍스', 'fa-file-invoice-dollar'], ['판매', 'fa-cash-register'],
|
||||||
|
['현황', 'fa-chart-column'], ['조회', 'fa-magnifying-glass'],
|
||||||
|
['대시보드', 'fa-gauge-high'], ['회원', 'fa-users'], ['승인', 'fa-user-check'],
|
||||||
|
['로그', 'fa-clock-rotate-left'], ['이력', 'fa-clock-rotate-left'],
|
||||||
|
['메뉴', 'fa-bars'], ['지자체', 'fa-city'], ['문의', 'fa-comment-dots'],
|
||||||
|
['비밀번호', 'fa-key'], ['설정', 'fa-gear'], ['관리', 'fa-folder-open']
|
||||||
];
|
];
|
||||||
for (var i = 0; i < rules.length; i++) {
|
for (var i = 0; i < rules.length; i++) {
|
||||||
if (n.indexOf(rules[i][0]) !== -1) return rules[i][1];
|
if (n.indexOf(rules[i][0]) !== -1) return rules[i][1];
|
||||||
@@ -25,7 +39,6 @@
|
|||||||
return 'fa-angle-right';
|
return 'fa-angle-right';
|
||||||
}
|
}
|
||||||
function iconHtml(parentName, childName) {
|
function iconHtml(parentName, childName) {
|
||||||
if (String(parentName || '').replace(/\s/g, '') !== '기본정보관리') return '';
|
|
||||||
return '<i class="fa-solid ' + sidebarIcon(childName) + '" style="width:1.15em;margin-right:.45em;opacity:.85;"></i>';
|
return '<i class="fa-solid ' + sidebarIcon(childName) + '" style="width:1.15em;margin-right:.45em;opacity:.85;"></i>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,24 +7,60 @@ $activeParent = $govNavItems[$govActiveParentIdx] ?? $govNavItems[0] ?? null;
|
|||||||
$sidebarTitle = $activeParent['name'] ?? 'MY MENU';
|
$sidebarTitle = $activeParent['name'] ?? 'MY MENU';
|
||||||
$activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
|
$activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
|
||||||
|
|
||||||
// 기본정보관리 소메뉴에 어울리는 아이콘(Font Awesome solid) 매핑 — 이름 키워드 기준(구체적인 것 먼저)
|
// 모든 상위메뉴의 소메뉴에 어울리는 아이콘(Font Awesome solid) 매핑 — 이름 키워드 기준(구체적인 것 먼저).
|
||||||
$showSidebarIcons = (str_replace(' ', '', (string) $sidebarTitle) === '기본정보관리');
|
$showSidebarIcons = true;
|
||||||
$sidebarIconFor = static function (string $name): string {
|
$sidebarIconFor = static function (string $name): string {
|
||||||
$rules = [
|
$rules = [
|
||||||
'바코드' => 'fa-barcode', // 지정판매소 바코드 출력
|
'바코드' => 'fa-barcode',
|
||||||
'조회' => 'fa-magnifying-glass', // 지정 판매소 조회
|
'스캐너' => 'fa-barcode',
|
||||||
'신규' => 'fa-chart-column', // 지정 판매소 신규/취소 현황
|
'번호' => 'fa-hashtag',
|
||||||
'현황' => 'fa-chart-column',
|
'판매대장' => 'fa-book',
|
||||||
'판매소' => 'fa-store', // 지정 판매소 관리
|
'대장' => 'fa-book',
|
||||||
|
'집계표' => 'fa-table-list',
|
||||||
|
'일계표' => 'fa-table-list',
|
||||||
|
'수불' => 'fa-right-left',
|
||||||
|
'입출고' => 'fa-right-left',
|
||||||
|
'무료' => 'fa-gift',
|
||||||
|
'반품' => 'fa-rotate-left',
|
||||||
|
'파기' => 'fa-trash-can',
|
||||||
|
'신규' => 'fa-chart-column',
|
||||||
|
'취소' => 'fa-ban',
|
||||||
|
'발주파일' => 'fa-file-arrow-down',
|
||||||
|
'발주' => 'fa-cart-shopping',
|
||||||
|
'입고' => 'fa-dolly',
|
||||||
|
'재고' => 'fa-warehouse',
|
||||||
|
'불출' => 'fa-truck-ramp-box',
|
||||||
|
'전화' => 'fa-phone',
|
||||||
|
'접수' => 'fa-clipboard-list',
|
||||||
|
'판매현황' => 'fa-chart-column',
|
||||||
|
'판매소' => 'fa-store',
|
||||||
'대행소' => 'fa-handshake',
|
'대행소' => 'fa-handshake',
|
||||||
'담당자' => 'fa-user-tie',
|
'담당자' => 'fa-user-tie',
|
||||||
|
'대상자' => 'fa-user-check',
|
||||||
'업체' => 'fa-building',
|
'업체' => 'fa-building',
|
||||||
'무료' => 'fa-gift',
|
|
||||||
'포장' => 'fa-box',
|
|
||||||
'단가' => 'fa-won-sign',
|
'단가' => 'fa-won-sign',
|
||||||
'코드' => 'fa-list-ol',
|
'코드' => 'fa-list-ol',
|
||||||
|
'포장' => 'fa-box',
|
||||||
|
'계획' => 'fa-clipboard-check',
|
||||||
|
'실사' => 'fa-clipboard-check',
|
||||||
|
'분석' => 'fa-chart-line',
|
||||||
|
'추이' => 'fa-chart-line',
|
||||||
|
'통계' => 'fa-chart-line',
|
||||||
|
'홈텍스' => 'fa-file-invoice-dollar',
|
||||||
|
'판매' => 'fa-cash-register',
|
||||||
|
'현황' => 'fa-chart-column',
|
||||||
|
'조회' => 'fa-magnifying-glass',
|
||||||
|
'대시보드' => 'fa-gauge-high',
|
||||||
|
'회원' => 'fa-users',
|
||||||
|
'승인' => 'fa-user-check',
|
||||||
|
'로그' => 'fa-clock-rotate-left',
|
||||||
|
'이력' => 'fa-clock-rotate-left',
|
||||||
|
'메뉴' => 'fa-bars',
|
||||||
|
'지자체' => 'fa-city',
|
||||||
|
'문의' => 'fa-comment-dots',
|
||||||
'비밀번호' => 'fa-key',
|
'비밀번호' => 'fa-key',
|
||||||
'설정' => 'fa-gear',
|
'설정' => 'fa-gear',
|
||||||
|
'관리' => 'fa-folder-open',
|
||||||
];
|
];
|
||||||
foreach ($rules as $kw => $icon) {
|
foreach ($rules as $kw => $icon) {
|
||||||
if (mb_stripos($name, $kw) !== false) {
|
if (mb_stripos($name, $kw) !== false) {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ tailwind.config = {
|
|||||||
<header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow">
|
<header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow">
|
||||||
<a href="<?= base_url() ?>" class="flex items-center gap-2 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)">
|
<a href="<?= base_url() ?>" class="flex items-center gap-2 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-white shrink-0" aria-hidden="true" focusable="false">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-white shrink-0" aria-hidden="true" focusable="false">
|
||||||
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
|
<path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="leading-none flex flex-col">
|
<span class="leading-none flex flex-col">
|
||||||
<strong class="text-base font-extrabold tracking-wide">GBLS</strong>
|
<strong class="text-base font-extrabold tracking-wide">GBLS</strong>
|
||||||
@@ -46,7 +46,7 @@ tailwind.config = {
|
|||||||
<div class="bg-gradient-to-br from-navy to-[#007bff] text-white px-8 py-8 text-center">
|
<div class="bg-gradient-to-br from-navy to-[#007bff] text-white px-8 py-8 text-center">
|
||||||
<div class="inline-flex h-14 w-14 items-center justify-center rounded-full bg-white/15 mb-3">
|
<div class="inline-flex h-14 w-14 items-center justify-center rounded-full bg-white/15 mb-3">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-7 w-7 text-white" aria-hidden="true" focusable="false">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-7 w-7 text-white" aria-hidden="true" focusable="false">
|
||||||
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
|
<path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-xl font-bold">종량제 쓰레기봉투 물류시스템</h1>
|
<h1 class="text-xl font-bold">종량제 쓰레기봉투 물류시스템</h1>
|
||||||
|
|||||||
23
writable/database/designated_shop_add_pii_bidx.sql
Normal file
23
writable/database/designated_shop_add_pii_bidx.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- 지정판매소 PII 블라인드 인덱스 컬럼(정확일치 검색용) 추가 — HMAC-SHA256 값 저장(64 hex)
|
||||||
|
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/designated_shop_add_pii_bidx.sql
|
||||||
|
-- 멱등. 컬럼 추가 후, 암호화/백필은 spark 명령(pii:protect-designated-shops)으로 수행.
|
||||||
|
|
||||||
|
SET @db = DATABASE();
|
||||||
|
|
||||||
|
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='designated_shop' AND COLUMN_NAME='ds_tel_bidx')>0,
|
||||||
|
'SELECT ''ds_tel_bidx exists''',
|
||||||
|
'ALTER TABLE `designated_shop` ADD COLUMN `ds_tel_bidx` CHAR(64) NOT NULL DEFAULT '''' COMMENT ''전화 블라인드인덱스(HMAC)'' , ADD INDEX `idx_ds_tel_bidx` (`ds_tel_bidx`)');
|
||||||
|
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||||
|
|
||||||
|
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='designated_shop' AND COLUMN_NAME='ds_rep_name_bidx')>0,
|
||||||
|
'SELECT ''ds_rep_name_bidx exists''',
|
||||||
|
'ALTER TABLE `designated_shop` ADD COLUMN `ds_rep_name_bidx` CHAR(64) NOT NULL DEFAULT '''' COMMENT ''대표자명 블라인드인덱스(HMAC)'' , ADD INDEX `idx_ds_rep_name_bidx` (`ds_rep_name_bidx`)');
|
||||||
|
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||||
|
|
||||||
|
-- 참고: 암호문(ENC:...)은 평문보다 길어짐. 아래 PII 컬럼이 짧으면 길이 확대 필요할 수 있음(확인 후 조정).
|
||||||
|
-- ALTER TABLE `designated_shop`
|
||||||
|
-- MODIFY `ds_rep_name` VARCHAR(255) NULL, MODIFY `ds_tel` VARCHAR(255) NULL,
|
||||||
|
-- MODIFY `ds_rep_phone` VARCHAR(255) NULL, MODIFY `ds_email` VARCHAR(255) NULL,
|
||||||
|
-- MODIFY `ds_va_account` VARCHAR(255) NULL;
|
||||||
15
writable/database/local_government_add_allow_ips.sql
Normal file
15
writable/database/local_government_add_allow_ips.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- 지자체별 허용 IP 대역(CIDR/단일IP, 쉼표·공백 구분) — 지정판매소 PII 자동 원문 열람 조건에 사용
|
||||||
|
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/local_government_add_allow_ips.sql
|
||||||
|
-- 멱등(이미 있으면 건너뜀).
|
||||||
|
--
|
||||||
|
-- 예시 값(운영 반영 시 각 지자체 실제 사무실 공인 IP로 UPDATE):
|
||||||
|
-- UPDATE local_government SET lg_allow_ips = '203.0.113.10, 203.0.113.0/24' WHERE lg_idx = 1;
|
||||||
|
|
||||||
|
SET @db = DATABASE();
|
||||||
|
SET @sql = IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'local_government' AND COLUMN_NAME = 'lg_allow_ips') > 0,
|
||||||
|
'SELECT ''lg_allow_ips already exists'' AS msg',
|
||||||
|
'ALTER TABLE `local_government` ADD COLUMN `lg_allow_ips` VARCHAR(500) NOT NULL DEFAULT '''' COMMENT ''허용 IP 대역(CIDR/단일IP, 쉼표구분) — PII 자동 원문 열람'''
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
5
writable/database/menu_rename_phone_to_order.sql
Normal file
5
writable/database/menu_rename_phone_to_order.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- 사용자 의견 #35: 메뉴 '전화 접수 관리' → '주문접수관리' 개명
|
||||||
|
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/menu_rename_phone_to_order.sql
|
||||||
|
-- 멱등(이미 바뀌었으면 0건).
|
||||||
|
|
||||||
|
UPDATE `menu` SET `mm_name` = '주문접수관리' WHERE `mm_name` = '전화 접수 관리';
|
||||||
@@ -177,7 +177,7 @@ INSERT INTO menu (mt_idx, lg_idx, mm_name, mm_link, mm_pidx, mm_dep, mm_num, mm_
|
|||||||
SELECT @mt_site, 1, t.mm_name,
|
SELECT @mt_site, 1, t.mm_name,
|
||||||
CASE t.mm_name
|
CASE t.mm_name
|
||||||
WHEN '전화 접수' THEN 'bag/shop-orders'
|
WHEN '전화 접수' THEN 'bag/shop-orders'
|
||||||
WHEN '전화 접수 관리' THEN 'bag/shop-orders'
|
WHEN '주문접수관리' THEN 'bag/shop-orders'
|
||||||
WHEN '지정 판매소 판매' THEN 'bag/sale/create'
|
WHEN '지정 판매소 판매' THEN 'bag/sale/create'
|
||||||
WHEN '지정 판매소 반품' THEN 'bag/bag-sales'
|
WHEN '지정 판매소 반품' THEN 'bag/bag-sales'
|
||||||
WHEN '지정 판매소 판매 취소' THEN 'bag/bag-sales'
|
WHEN '지정 판매소 판매 취소' THEN 'bag/bag-sales'
|
||||||
@@ -187,7 +187,7 @@ SELECT @mt_site, 1, t.mm_name,
|
|||||||
@parent_sales, 1, t.mm_num, 0, '', 'Y'
|
@parent_sales, 1, t.mm_num, 0, '', 'Y'
|
||||||
FROM (
|
FROM (
|
||||||
SELECT 0 AS mm_num, '전화 접수' AS mm_name UNION ALL
|
SELECT 0 AS mm_num, '전화 접수' AS mm_name UNION ALL
|
||||||
SELECT 1, '전화 접수 관리' UNION ALL
|
SELECT 1, '주문접수관리' UNION ALL
|
||||||
SELECT 2, '지정 판매소 판매' UNION ALL
|
SELECT 2, '지정 판매소 판매' UNION ALL
|
||||||
SELECT 3, '지정 판매소 반품' UNION ALL
|
SELECT 3, '지정 판매소 반품' UNION ALL
|
||||||
SELECT 4, '지정 판매소 판매 취소' UNION ALL
|
SELECT 4, '지정 판매소 판매 취소' UNION ALL
|
||||||
|
|||||||
Reference in New Issue
Block a user