사용자 매뉴얼·번호알기·gov-portal 대시보드와 메뉴 동선·수불 리포트를 보강한다.
- 사용자 매뉴얼: league/commonmark 기반 bag/manual(로그인 전용), ManualRenderer + Config\Manual manifest, 콘텐츠 8종, E2E - 번호알기(봉투번호확인): bag/number-lookup, BagNumberLookup, E2E - gov-portal 대시보드 시안(기본/strip)·기본코드관리 화면 - 메뉴 관리: 등록·수정 후 메뉴 화면 유지, 수정 버튼 클릭 시 상단 스크롤 - 수불/분석 리포트(LOT 수불·반품/파기·수급계획·추이) 표시 보강 - .gitignore: docs/ → /docs/ 앵커링(최상위 개발문서만 제외, app/Docs는 추적) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ namespace App\Libraries;
|
||||
|
||||
/**
|
||||
* 통계 분석 관리 (전년대비 / 월별·계절별 추이)
|
||||
*
|
||||
* 월별·계절별 추이·전년대비: bs_type = sale 판매량·판매금액만 집계 (반품·취소 제외)
|
||||
*/
|
||||
class BagAnalyticsReportBuilder
|
||||
{
|
||||
@@ -14,6 +16,12 @@ class BagAnalyticsReportBuilder
|
||||
/** @var array<string, string> */
|
||||
private array $bagNames = [];
|
||||
|
||||
/** 판매(bs_type=sale) 낱장 수량만 합산 */
|
||||
private function saleQtySql(): string
|
||||
{
|
||||
return "CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) ELSE 0 END";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{label: string, months_label: string, months: list<int>, cross_year: bool}>
|
||||
*/
|
||||
@@ -336,14 +344,11 @@ class BagAnalyticsReportBuilder
|
||||
string $gugunCode,
|
||||
int $dsIdx
|
||||
): array {
|
||||
$saleQty = $this->saleQtySql();
|
||||
$sql = "
|
||||
SELECT bs.bs_bag_code AS bag_code, YEAR(bs.bs_sale_date) AS y, MONTH(bs.bs_sale_date) AS m,
|
||||
SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty)
|
||||
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty)
|
||||
ELSE 0 END) AS net_qty,
|
||||
SUM(CASE WHEN bs.bs_type = 'sale' THEN bs.bs_amount
|
||||
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_amount)
|
||||
ELSE 0 END) AS net_amt
|
||||
SUM({$saleQty}) AS sale_qty,
|
||||
SUM(CASE WHEN bs.bs_type = 'sale' THEN bs.bs_amount ELSE 0 END) AS sale_amt
|
||||
FROM bag_sale bs
|
||||
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
|
||||
WHERE bs.bs_lg_idx = ?
|
||||
@@ -369,8 +374,8 @@ class BagAnalyticsReportBuilder
|
||||
continue;
|
||||
}
|
||||
$agg[$code][$y][$m] = [
|
||||
'qty' => (float) ($row['net_qty'] ?? 0),
|
||||
'amt' => (float) ($row['net_amt'] ?? 0),
|
||||
'qty' => (float) ($row['sale_qty'] ?? 0),
|
||||
'amt' => (float) ($row['sale_amt'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -515,11 +520,10 @@ class BagAnalyticsReportBuilder
|
||||
private function monthlyNetByShop(int $lgIdx, int $year, int $month, string $gugunCode, int $saIdx = 0): array
|
||||
{
|
||||
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
|
||||
$saleQty = $this->saleQtySql();
|
||||
$sql = "
|
||||
SELECT bs.bs_ds_idx AS ds_idx,
|
||||
SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty)
|
||||
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty)
|
||||
ELSE 0 END) AS net_qty
|
||||
SUM({$saleQty}) AS sale_qty
|
||||
FROM bag_sale bs
|
||||
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
|
||||
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) = ?
|
||||
@@ -538,7 +542,7 @@ class BagAnalyticsReportBuilder
|
||||
|
||||
$map = [];
|
||||
foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
|
||||
$map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['net_qty'] ?? 0);
|
||||
$map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['sale_qty'] ?? 0);
|
||||
}
|
||||
|
||||
return $map;
|
||||
@@ -560,11 +564,10 @@ class BagAnalyticsReportBuilder
|
||||
}
|
||||
|
||||
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
|
||||
$saleQty = $this->saleQtySql();
|
||||
$sql = "
|
||||
SELECT bs.bs_ds_idx AS ds_idx,
|
||||
SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty)
|
||||
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty)
|
||||
ELSE 0 END) / 12 AS avg_qty
|
||||
SUM({$saleQty}) / 12 AS avg_qty
|
||||
FROM bag_sale bs
|
||||
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
|
||||
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ?
|
||||
@@ -606,15 +609,13 @@ class BagAnalyticsReportBuilder
|
||||
}
|
||||
|
||||
$divisor = count($months);
|
||||
$qtyExpr = "CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty)
|
||||
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty)
|
||||
ELSE 0 END";
|
||||
$saleQty = $this->saleQtySql();
|
||||
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
|
||||
|
||||
if ($crossYearWinter) {
|
||||
$sql = "
|
||||
SELECT bs.bs_ds_idx AS ds_idx,
|
||||
SUM({$qtyExpr}) / ? AS avg_qty
|
||||
SUM({$saleQty}) / ? AS avg_qty
|
||||
FROM bag_sale bs
|
||||
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
|
||||
WHERE bs.bs_lg_idx = ?
|
||||
@@ -629,7 +630,7 @@ class BagAnalyticsReportBuilder
|
||||
$placeholders = implode(',', array_fill(0, count($months), '?'));
|
||||
$sql = "
|
||||
SELECT bs.bs_ds_idx AS ds_idx,
|
||||
SUM({$qtyExpr}) / ? AS avg_qty
|
||||
SUM({$saleQty}) / ? AS avg_qty
|
||||
FROM bag_sale bs
|
||||
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
|
||||
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ?
|
||||
|
||||
@@ -202,11 +202,21 @@ class BagLotFlowBuilder
|
||||
return $this->resolvedFromPackRow($barcode, '박스', $first, 1, count($boxRows), $sheetQty);
|
||||
}
|
||||
|
||||
$exactSheet = $this->db->table('bag_receiving_pack_code')
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->where('brpc_sheet_start_code', $barcode)
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if (is_array($exactSheet) && $exactSheet !== []) {
|
||||
return $this->resolvedFromPackRow($barcode, '낱장', $exactSheet, 0, 0, 1);
|
||||
}
|
||||
|
||||
$sheetRows = $this->db->table('bag_receiving_pack_code')
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->where('brpc_sheet_start_code <=', $barcode)
|
||||
->where('brpc_sheet_end_code >=', $barcode)
|
||||
->limit(50)
|
||||
->where('brpc_sheet_start_code !=', '')
|
||||
->where('brpc_sheet_end_code !=', '')
|
||||
->limit(200)
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($sheetRows as $row) {
|
||||
@@ -217,9 +227,109 @@ class BagLotFlowBuilder
|
||||
}
|
||||
}
|
||||
|
||||
$fromScan = $this->resolveBarcodeFromScanTables($lgIdx, $barcode);
|
||||
if ($fromScan !== null) {
|
||||
return $fromScan;
|
||||
}
|
||||
|
||||
return ['ok' => false, 'message' => '등록되지 않은 바코드입니다.'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 판매·반품 스캔에만 있는 낱장 코드(입고 팩 테이블 미등록) 조회
|
||||
*
|
||||
* @return array{ok: true, barcode: string, unit: string, bag_code: string, bag_name: string, lot_no: string, box_code: string, pack_code: string, pack_ids: list<int>, qty_box: int, qty_pack: int, qty_sheet: int}|null
|
||||
*/
|
||||
private function resolveBarcodeFromScanTables(int $lgIdx, string $barcode): ?array
|
||||
{
|
||||
$bagCode = '';
|
||||
$bagName = '';
|
||||
if ($this->db->tableExists('bag_sale_scan_code')) {
|
||||
$sale = $this->db->table('bag_sale_scan_code')
|
||||
->select('bssc_bag_code, bssc_bag_name')
|
||||
->where('bssc_lg_idx', $lgIdx)
|
||||
->where('bssc_code', $barcode)
|
||||
->orderBy('bssc_regdate', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if (is_array($sale) && $sale !== []) {
|
||||
$bagCode = (string) ($sale['bssc_bag_code'] ?? '');
|
||||
$bagName = (string) ($sale['bssc_bag_name'] ?? '');
|
||||
}
|
||||
}
|
||||
if ($bagCode === '' && $this->db->tableExists('bag_return_scan_code')) {
|
||||
$ret = $this->db->table('bag_return_scan_code')
|
||||
->select('brsc_bag_code, brsc_bag_name')
|
||||
->where('brsc_lg_idx', $lgIdx)
|
||||
->where('brsc_code', $barcode)
|
||||
->orderBy('brsc_regdate', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if (is_array($ret) && $ret !== []) {
|
||||
$bagCode = (string) ($ret['brsc_bag_code'] ?? '');
|
||||
$bagName = (string) ($ret['brsc_bag_name'] ?? '');
|
||||
}
|
||||
}
|
||||
if ($bagCode === '' && $bagName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$packRow = $this->findPackRowContainingSheet($lgIdx, $barcode);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'barcode' => $barcode,
|
||||
'unit' => '낱장',
|
||||
'bag_code' => $bagCode,
|
||||
'bag_name' => $bagName,
|
||||
'lot_no' => (string) ($packRow['brpc_lot_no'] ?? ''),
|
||||
'box_code' => (string) ($packRow['brpc_box_code'] ?? ''),
|
||||
'pack_code' => (string) ($packRow['brpc_pack_code'] ?? ''),
|
||||
'pack_ids' => isset($packRow['brpc_idx']) ? [(int) $packRow['brpc_idx']] : [],
|
||||
'qty_box' => 0,
|
||||
'qty_pack' => 0,
|
||||
'qty_sheet' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findPackRowContainingSheet(int $lgIdx, string $barcode): array
|
||||
{
|
||||
if (! $this->db->tableExists('bag_receiving_pack_code')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$exact = $this->db->table('bag_receiving_pack_code')
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->where('brpc_sheet_start_code', $barcode)
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if (is_array($exact) && $exact !== []) {
|
||||
return $exact;
|
||||
}
|
||||
|
||||
foreach ($this->db->table('bag_receiving_pack_code')
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->where('brpc_sheet_start_code !=', '')
|
||||
->where('brpc_sheet_end_code !=', '')
|
||||
->limit(200)
|
||||
->get()
|
||||
->getResultArray() as $row) {
|
||||
$start = (string) ($row['brpc_sheet_start_code'] ?? '');
|
||||
$end = (string) ($row['brpc_sheet_end_code'] ?? '');
|
||||
if ($this->barcodeInRange($barcode, $start, $end)) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $pack
|
||||
* @return array{ok: true, barcode: string, unit: string, bag_code: string, bag_name: string, lot_no: string, box_code: string, pack_code: string, pack_ids: list<int>, qty_box: int, qty_pack: int, qty_sheet: int}
|
||||
@@ -247,30 +357,62 @@ class BagLotFlowBuilder
|
||||
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||
*/
|
||||
private function collectFlowRows(int $lgIdx, array $resolved): array
|
||||
{
|
||||
$unit = (string) ($resolved['unit'] ?? '');
|
||||
|
||||
return match ($unit) {
|
||||
'낱장' => $this->collectFlowRowsForSheet($lgIdx, $resolved),
|
||||
'박스' => $this->collectFlowRowsForBox($lgIdx, $resolved),
|
||||
default => $this->collectFlowRowsForPack($lgIdx, $resolved),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 낱장: 해당 바코드 판매·반품만 + 소속 팩 입고 1건(발주·동일 LOT 전체 이력 제외)
|
||||
*
|
||||
* @param array<string, mixed> $resolved
|
||||
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||
*/
|
||||
private function collectFlowRowsForSheet(int $lgIdx, array $resolved): array
|
||||
{
|
||||
$rows = [];
|
||||
$codes = [$resolved['barcode']];
|
||||
if (($resolved['pack_code'] ?? '') !== '' && ! in_array($resolved['pack_code'], $codes, true)) {
|
||||
$codes[] = $resolved['pack_code'];
|
||||
}
|
||||
if (($resolved['box_code'] ?? '') !== '' && ! in_array($resolved['box_code'], $codes, true)) {
|
||||
$codes[] = $resolved['box_code'];
|
||||
$barcode = trim((string) ($resolved['barcode'] ?? ''));
|
||||
if ($barcode === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$brIdx = 0;
|
||||
if ($this->db->tableExists('bag_receiving_pack_code')) {
|
||||
$packCode = (string) ($resolved['pack_code'] ?? '');
|
||||
if ($packCode !== '') {
|
||||
$p = $this->db->table('bag_receiving_pack_code')
|
||||
->select('brpc_br_idx')
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->where('brpc_pack_code', $packCode)
|
||||
->get()
|
||||
->getRowArray();
|
||||
$brIdx = (int) ($p['brpc_br_idx'] ?? 0);
|
||||
$brIdx = $this->receivingBrIdxForPackCode($lgIdx, (string) ($resolved['pack_code'] ?? ''));
|
||||
if ($brIdx > 0) {
|
||||
foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) {
|
||||
$rows[] = $ev;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->loadScanEventsForCodes($lgIdx, [$barcode]) as $ev) {
|
||||
$rows[] = $ev;
|
||||
}
|
||||
foreach ($this->loadReturnEventsForCodes($lgIdx, [$barcode]) as $ev) {
|
||||
$rows[] = $ev;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 팩: 팩·낱장 바코드 스캔 + 입고 + LOT 발주
|
||||
*
|
||||
* @param array<string, mixed> $resolved
|
||||
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||
*/
|
||||
private function collectFlowRowsForPack(int $lgIdx, array $resolved): array
|
||||
{
|
||||
$rows = [];
|
||||
$codes = array_values(array_unique(array_filter([
|
||||
(string) ($resolved['barcode'] ?? ''),
|
||||
(string) ($resolved['pack_code'] ?? ''),
|
||||
], static fn (string $c): bool => $c !== '')));
|
||||
|
||||
$brIdx = $this->receivingBrIdxForPackCode($lgIdx, (string) ($resolved['pack_code'] ?? ''));
|
||||
if ($brIdx > 0) {
|
||||
foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) {
|
||||
$rows[] = $ev;
|
||||
@@ -294,6 +436,91 @@ class BagLotFlowBuilder
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 박스: 박스·소속 팩 코드 스캔 + 입고(박스 내 팩) + LOT 발주
|
||||
*
|
||||
* @param array<string, mixed> $resolved
|
||||
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||
*/
|
||||
private function collectFlowRowsForBox(int $lgIdx, array $resolved): array
|
||||
{
|
||||
$rows = [];
|
||||
$boxCode = (string) ($resolved['box_code'] ?? '');
|
||||
$codes = array_values(array_unique(array_filter([
|
||||
(string) ($resolved['barcode'] ?? ''),
|
||||
$boxCode,
|
||||
(string) ($resolved['pack_code'] ?? ''),
|
||||
], static fn (string $c): bool => $c !== '')));
|
||||
|
||||
if ($boxCode !== '' && $this->db->tableExists('bag_receiving_pack_code')) {
|
||||
$packCodes = $this->db->table('bag_receiving_pack_code')
|
||||
->select('brpc_pack_code')
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->where('brpc_box_code', $boxCode)
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($packCodes as $p) {
|
||||
$pc = (string) ($p['brpc_pack_code'] ?? '');
|
||||
if ($pc !== '' && ! in_array($pc, $codes, true)) {
|
||||
$codes[] = $pc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$seenBr = [];
|
||||
if ($boxCode !== '' && $this->db->tableExists('bag_receiving_pack_code')) {
|
||||
$brRows = $this->db->table('bag_receiving_pack_code')
|
||||
->select('brpc_br_idx')
|
||||
->distinct()
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->where('brpc_box_code', $boxCode)
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($brRows as $brRow) {
|
||||
$brIdx = (int) ($brRow['brpc_br_idx'] ?? 0);
|
||||
if ($brIdx <= 0 || isset($seenBr[$brIdx])) {
|
||||
continue;
|
||||
}
|
||||
$seenBr[$brIdx] = true;
|
||||
foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) {
|
||||
$rows[] = $ev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->loadScanEventsForCodes($lgIdx, $codes) as $ev) {
|
||||
$rows[] = $ev;
|
||||
}
|
||||
foreach ($this->loadReturnEventsForCodes($lgIdx, $codes) as $ev) {
|
||||
$rows[] = $ev;
|
||||
}
|
||||
|
||||
$lotNo = (string) ($resolved['lot_no'] ?? '');
|
||||
if ($lotNo !== '') {
|
||||
foreach ($this->loadOrderEventsForLot($lgIdx, $lotNo) as $ev) {
|
||||
$rows[] = $ev;
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function receivingBrIdxForPackCode(int $lgIdx, string $packCode): int
|
||||
{
|
||||
if ($packCode === '' || ! $this->db->tableExists('bag_receiving_pack_code')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$p = $this->db->table('bag_receiving_pack_code')
|
||||
->select('brpc_br_idx')
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->where('brpc_pack_code', $packCode)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return (int) ($p['brpc_br_idx'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||
*/
|
||||
|
||||
225
app/Libraries/BagNumberLookup.php
Normal file
225
app/Libraries/BagNumberLookup.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
/**
|
||||
* 봉투번호확인(번호알기) — 코드 입력 → 바코드·인쇄숫자·인식번호 분해.
|
||||
* LOT-입고PK-팩/박스-낱장 형식 및 DB 등록 바코드 지원.
|
||||
*/
|
||||
class BagNumberLookup
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* ok: bool,
|
||||
* message: string,
|
||||
* input: string,
|
||||
* barcode_text: string,
|
||||
* print_text: string,
|
||||
* recognition_text: string,
|
||||
* unit: string,
|
||||
* bag_code: string,
|
||||
* bag_name: string
|
||||
* }
|
||||
*/
|
||||
public function resolve(string $raw, ?int $lgIdx = null): array
|
||||
{
|
||||
$input = trim($raw);
|
||||
if ($input === '') {
|
||||
return $this->fail('코드를 입력해 주세요.', $input);
|
||||
}
|
||||
|
||||
$normalized = strtoupper(preg_replace('/\s+/', '', $input) ?? $input);
|
||||
$parsed = $this->parseStructuredCode($normalized);
|
||||
|
||||
if ($parsed === null && $lgIdx !== null) {
|
||||
$parsed = $this->resolveFromDatabase($lgIdx, $normalized);
|
||||
}
|
||||
|
||||
if ($parsed === null) {
|
||||
return $this->fail('인식할 수 없는 코드입니다. 봉투 바코드(LOT-입고번호-팩/박스-낱장) 형식을 확인해 주세요.', $input);
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => '',
|
||||
'input' => $input,
|
||||
'barcode_text' => $this->formatRow($parsed['barcode'], 4),
|
||||
'print_text' => $this->formatRow($parsed['print'], 3),
|
||||
'recognition_text' => $this->formatRow($parsed['recognition'], 2),
|
||||
'unit' => (string) ($parsed['unit'] ?? ''),
|
||||
'bag_code' => (string) ($parsed['bag_code'] ?? ''),
|
||||
'bag_name' => (string) ($parsed['bag_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $parts
|
||||
*/
|
||||
private function formatRow(array $parts, int $slots): string
|
||||
{
|
||||
$cells = array_pad($parts, $slots, '-');
|
||||
foreach ($cells as $i => $cell) {
|
||||
if ($cell === '' || $cell === null) {
|
||||
$cells[$i] = '-';
|
||||
}
|
||||
}
|
||||
|
||||
return implode(' ', $cells);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{barcode:list<string>,print:list<string>,recognition:list<string>,unit:string,bag_code?:string,bag_name?:string}|null
|
||||
*/
|
||||
private function parseStructuredCode(string $code): ?array
|
||||
{
|
||||
if (preg_match('/^([A-Z0-9]+)-(\d{6})-P(\d{3})-S(\d+)$/i', $code, $m) === 1) {
|
||||
$sheet = str_pad((string) $m[4], 5, '0', STR_PAD_LEFT);
|
||||
|
||||
return [
|
||||
'barcode' => [(string) $m[1], (string) $m[2], 'P' . $m[3], 'S' . $sheet],
|
||||
'print' => [(string) (int) $m[2], (string) (int) $m[3], (string) (int) $sheet],
|
||||
'recognition' => [(string) $m[2], 'P' . $m[3]],
|
||||
'unit' => '낱장',
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/^([A-Z0-9]+)-(\d{6})-P(\d{3})$/i', $code, $m) === 1) {
|
||||
return [
|
||||
'barcode' => [(string) $m[1], (string) $m[2], 'P' . $m[3], '-'],
|
||||
'print' => [(string) (int) $m[2], (string) (int) $m[3], '-'],
|
||||
'recognition' => [(string) $m[2], 'P' . $m[3]],
|
||||
'unit' => '팩',
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/^([A-Z0-9]+)-(\d{6})-B(\d{3})$/i', $code, $m) === 1) {
|
||||
return [
|
||||
'barcode' => [(string) $m[1], (string) $m[2], 'B' . $m[3], '-'],
|
||||
'print' => [(string) (int) $m[2], (string) (int) $m[3], '-'],
|
||||
'recognition' => [(string) $m[2], 'B' . $m[3]],
|
||||
'unit' => '박스',
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/^([A-Z0-9]{4,8})$/i', $code) === 1) {
|
||||
return [
|
||||
'barcode' => [$code, '-', '-', '-'],
|
||||
'print' => ['-', '-', '-'],
|
||||
'recognition' => [$code, '-'],
|
||||
'unit' => 'LOT',
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{barcode:list<string>,print:list<string>,recognition:list<string>,unit:string,bag_code?:string,bag_name?:string}|null
|
||||
*/
|
||||
private function resolveFromDatabase(int $lgIdx, string $code): ?array
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
if (! $db->tableExists('bag_receiving_pack_code')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $db->table('bag_receiving_pack_code')
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->where('brpc_pack_code', $code)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if (is_array($row) && $row !== []) {
|
||||
return $this->parsedFromPackRow($code, $row, '팩');
|
||||
}
|
||||
|
||||
$boxRows = $db->table('bag_receiving_pack_code')
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->where('brpc_box_code', $code)
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if (is_array($boxRows) && $boxRows !== []) {
|
||||
return $this->parsedFromPackRow($code, $boxRows, '박스');
|
||||
}
|
||||
|
||||
$sheetRow = $db->table('bag_receiving_pack_code')
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->where('brpc_sheet_start_code <=', $code)
|
||||
->where('brpc_sheet_end_code >=', $code)
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if (is_array($sheetRow) && $sheetRow !== []) {
|
||||
$parsed = $this->parseStructuredCode($code);
|
||||
if ($parsed !== null) {
|
||||
$parsed['bag_code'] = (string) ($sheetRow['brpc_bag_code'] ?? '');
|
||||
$parsed['bag_name'] = (string) ($sheetRow['brpc_bag_name'] ?? '');
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
$exactSheet = $db->table('bag_receiving_pack_code')
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->groupStart()
|
||||
->where('brpc_sheet_start_code', $code)
|
||||
->orWhere('brpc_sheet_end_code', $code)
|
||||
->groupEnd()
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if (is_array($exactSheet) && $exactSheet !== []) {
|
||||
$parsed = $this->parseStructuredCode($code);
|
||||
if ($parsed !== null) {
|
||||
$parsed['bag_code'] = (string) ($exactSheet['brpc_bag_code'] ?? '');
|
||||
$parsed['bag_name'] = (string) ($exactSheet['brpc_bag_name'] ?? '');
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array{barcode:list<string>,print:list<string>,recognition:list<string>,unit:string,bag_code:string,bag_name:string}
|
||||
*/
|
||||
private function parsedFromPackRow(string $code, array $row, string $unit): array
|
||||
{
|
||||
$parsed = $this->parseStructuredCode($code);
|
||||
if ($parsed === null) {
|
||||
$parsed = [
|
||||
'barcode' => [$code, '-', '-', '-'],
|
||||
'print' => ['-', '-', '-'],
|
||||
'recognition' => ['-', '-'],
|
||||
'unit' => $unit,
|
||||
];
|
||||
}
|
||||
$parsed['unit'] = $unit;
|
||||
$parsed['bag_code'] = (string) ($row['brpc_bag_code'] ?? '');
|
||||
$parsed['bag_name'] = (string) ($row['brpc_bag_name'] ?? '');
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool,message:string,input:string,barcode_text:string,print_text:string,recognition_text:string,unit:string,bag_code:string,bag_name:string}
|
||||
*/
|
||||
private function fail(string $message, string $input): array
|
||||
{
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => $message,
|
||||
'input' => $input,
|
||||
'barcode_text' => $this->formatRow([], 4),
|
||||
'print_text' => $this->formatRow([], 3),
|
||||
'recognition_text' => $this->formatRow([], 2),
|
||||
'unit' => '',
|
||||
'bag_code' => '',
|
||||
'bag_name' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
101
app/Libraries/GovPortalCodeKindsPage.php
Normal file
101
app/Libraries/GovPortalCodeKindsPage.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\CodeDetailModel;
|
||||
use App\Models\CodeKindModel;
|
||||
use Config\Roles;
|
||||
|
||||
/**
|
||||
* 공공 포털형 기본 코드관리 UI 시안 전용 데이터 (/bag/code-kinds 와 별도 URL, 동일 집계)
|
||||
*/
|
||||
class GovPortalCodeKindsPage
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildPageData(?int $lgIdx, int $level, ?int $adminLgIdx, int $selectedCkIdx, array $filters): array
|
||||
{
|
||||
$kindModel = model(CodeKindModel::class);
|
||||
$detailModel = model(CodeDetailModel::class);
|
||||
$kinds = [];
|
||||
$countMap = [];
|
||||
$selectedKind = null;
|
||||
$detailList = [];
|
||||
$rowCanEdit = [];
|
||||
|
||||
$qCode = trim((string) ($filters['q_code'] ?? ''));
|
||||
$qName = trim((string) ($filters['q_name'] ?? ''));
|
||||
$qState = (string) ($filters['q_state'] ?? '');
|
||||
|
||||
try {
|
||||
$builder = $kindModel->orderBy('ck_code', 'ASC');
|
||||
if ($qCode !== '') {
|
||||
$builder->like('ck_code', $qCode);
|
||||
}
|
||||
if ($qName !== '') {
|
||||
$builder->like('ck_name', $qName);
|
||||
}
|
||||
if ($qState === '1' || $qState === '0') {
|
||||
$builder->where('ck_state', (int) $qState);
|
||||
}
|
||||
$kinds = $builder->findAll();
|
||||
foreach ($kinds as $row) {
|
||||
$countMap[$row->ck_idx] = (int) $detailModel->where('cd_ck_idx', $row->ck_idx)
|
||||
->filterByTenantScope($lgIdx)
|
||||
->countAllResults();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', '[GovPortalCodeKinds] {type} {message}', [
|
||||
'type' => $e::class,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$canManageKinds = Roles::canManageCodeKindMaster($level);
|
||||
$canManageDetails = Roles::canManageCodeMaster($level);
|
||||
|
||||
if ($kinds !== []) {
|
||||
foreach ($kinds as $row) {
|
||||
if ((int) $row->ck_idx === $selectedCkIdx) {
|
||||
$selectedKind = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($selectedKind === null) {
|
||||
$selectedKind = $kinds[0];
|
||||
}
|
||||
}
|
||||
|
||||
if ($selectedKind !== null) {
|
||||
$detailList = $detailModel->where('cd_ck_idx', (int) $selectedKind->ck_idx)
|
||||
->filterByTenantScope($lgIdx)
|
||||
->orderBy('cd_sort', 'ASC')
|
||||
->orderBy('cd_code', 'ASC')
|
||||
->orderBy('cd_idx', 'ASC')
|
||||
->findAll();
|
||||
|
||||
foreach ($detailList as $row) {
|
||||
$rowCanEdit[$row->cd_idx] = Roles::canEditCodeDetailRow($level, $row, $adminLgIdx);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'codeKinds' => $kinds,
|
||||
'countMap' => $countMap,
|
||||
'canManageKinds' => $canManageKinds,
|
||||
'canManageDetails' => $canManageDetails,
|
||||
'selectedKind' => $selectedKind,
|
||||
'detailList' => $detailList,
|
||||
'rowCanEdit' => $rowCanEdit,
|
||||
'totalCount' => count($kinds),
|
||||
'filters' => [
|
||||
'q_code' => $qCode,
|
||||
'q_name' => $qName,
|
||||
'q_state' => $qState,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
111
app/Libraries/ManualRenderer.php
Normal file
111
app/Libraries/ManualRenderer.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use Config\Manual as ManualConfig;
|
||||
use League\CommonMark\Exception\CommonMarkException;
|
||||
use League\CommonMark\GithubFlavoredMarkdownConverter;
|
||||
|
||||
/**
|
||||
* 사용자 매뉴얼(설명서) 렌더러.
|
||||
*
|
||||
* - 목차(manifest)는 Config\Manual 에서 가져온다.
|
||||
* - slug → 파일 매핑은 화이트리스트(manifest)로만 결정한다(사용자 입력으로 파일명 조합 금지).
|
||||
* - 마크다운은 GFM(표·코드블록)로 변환하며, md 내 raw HTML 은 이스케이프한다.
|
||||
*/
|
||||
class ManualRenderer
|
||||
{
|
||||
private ManualConfig $config;
|
||||
|
||||
private GithubFlavoredMarkdownConverter $converter;
|
||||
|
||||
public function __construct(?ManualConfig $config = null)
|
||||
{
|
||||
$this->config = $config ?? config(ManualConfig::class);
|
||||
$this->converter = new GithubFlavoredMarkdownConverter([
|
||||
// 콘텐츠 저자는 신뢰되지만, 사고 방지를 위해 정책을 명시 고정한다.
|
||||
'html_input' => 'escape',
|
||||
'allow_unsafe_links' => false,
|
||||
'max_nesting_level' => 50,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 목차(slug → title/file). 배열 순서가 노출 순서.
|
||||
*
|
||||
* @return array<string, array{title: string, file: string}>
|
||||
*/
|
||||
public function pages(): array
|
||||
{
|
||||
return $this->config->pages;
|
||||
}
|
||||
|
||||
/** 목차의 첫 번째 slug (기본 진입 페이지). */
|
||||
public function firstSlug(): string
|
||||
{
|
||||
return (string) (array_key_first($this->config->pages) ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* slug 메타 조회. 화이트리스트에 없으면 null.
|
||||
*
|
||||
* @return array{title: string, file: string}|null
|
||||
*/
|
||||
public function find(string $slug): ?array
|
||||
{
|
||||
return $this->config->pages[$slug] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* slug 페이지를 HTML 로 변환해 반환. 미등록 slug·파일 없음·변환 실패 시 null.
|
||||
*/
|
||||
public function render(string $slug): ?string
|
||||
{
|
||||
$page = $this->find($slug);
|
||||
if ($page === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $this->resolvePath($page['file']);
|
||||
if ($path === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$markdown = @file_get_contents($path);
|
||||
if ($markdown === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (string) $this->converter->convert($markdown);
|
||||
} catch (CommonMarkException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일명을 디렉터리 경계 안으로 안전하게 해석한다.
|
||||
* manifest 의 고정 파일명만 받으며, realpath 가 $dir 하위인지 재검증한다.
|
||||
*/
|
||||
private function resolvePath(string $file): ?string
|
||||
{
|
||||
$baseReal = realpath(rtrim($this->config->dir, '/\\'));
|
||||
if ($baseReal === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidate = realpath($baseReal . DIRECTORY_SEPARATOR . $file);
|
||||
if ($candidate === false || ! is_file($candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prefix = $baseReal . DIRECTORY_SEPARATOR;
|
||||
if (! str_starts_with($candidate, $prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user