Files
jongryangje/app/Controllers/Bag.php
taekyoungc c708d30660 실사 저장 안정화와 메뉴 운영 정책을 일관되게 반영한다.
실사 저장값이 페이지 이동 후 원복되지 않도록 저장/조회 경로를 보강하고, 코드 범위 보정과 bis 간 동기화를 추가했다. 또한 메뉴 관리를 레벨4 이상으로 제한하고 메뉴 변경 사항을 모든 지자체에 일괄 반영하도록 동기화 로직을 도입했다.

Made-with: Cursor
2026-04-29 14:59:49 +09:00

4971 lines
205 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Controllers;
use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\ResponseInterface;
use App\Models\BagInventoryModel;
use App\Models\BagIssueModel;
use App\Models\BagIssueItemCodeModel;
use App\Models\BagOrderModel;
use App\Models\BagOrderItemModel;
use App\Models\BagPriceModel;
use App\Models\BagReceivingModel;
use App\Models\BagSaleModel;
use App\Models\CodeKindModel;
use App\Models\CodeDetailModel;
use App\Models\CompanyModel;
use App\Models\PackagingUnitModel;
use App\Models\SalesAgencyModel;
use App\Models\ShopOrderModel;
use App\Models\DesignatedShopModel;
use App\Models\LocalGovernmentModel;
use App\Models\ManagerModel;
use Config\Roles;
class Bag extends BaseController
{
/**
* 로그인 사용자의 지자체 PK 반환 (미로그인/미지정 시 null)
*/
private function lgIdx(): ?int
{
helper('admin');
return admin_effective_lg_idx();
}
/**
* 입고 화면용 인계자: 제작업체(company) 담당자.
*
* @return array{senders: list<object>, defaultSenderIdx: int}
*/
private function receivingManagerPickers(int $lgIdx): array
{
$senders = model(ManagerModel::class, false)
->where('mg_lg_idx', $lgIdx)
->where('mg_state', 1)
->where('mg_dept_code', 'company')
->orderBy('mg_name', 'ASC')
->findAll();
$sessionName = trim((string) (session()->get('mb_name') ?? ''));
$defaultSenderIdx = 0;
foreach ($senders as $s) {
if ((string) ($s->mg_name ?? '') === $sessionName) {
$defaultSenderIdx = (int) ($s->mg_idx ?? 0);
break;
}
}
if ($defaultSenderIdx <= 0 && $senders !== []) {
$defaultSenderIdx = (int) ($senders[0]->mg_idx ?? 0);
}
return [
'senders' => $senders,
'defaultSenderIdx' => $defaultSenderIdx,
];
}
/**
* 인수자 드롭다운: 맨 위에 현재 로그인 회원, 이어서 대행소(agency) 담당자.
* value 는 br_receiver_ref 로 전달: m_{mb_idx} | g_{mg_idx}
*
* @return array{receiverOptions: list<array{ref: string, label: string}>, defaultReceiverRef: string}
*/
private function receivingReceiverSelect(int $lgIdx): array
{
$sessionMbIdx = (int) (session()->get('mb_idx') ?? 0);
$sessionName = trim((string) (session()->get('mb_name') ?? ''));
$normalizeName = static fn (string $name): string => preg_replace('/\s+/u', '', trim($name)) ?? '';
$normSession = $normalizeName($sessionName);
$options = [];
if ($sessionMbIdx > 0) {
$label = $sessionName !== '' ? $sessionName : '로그인 사용자';
$options[] = ['ref' => 'm_' . $sessionMbIdx, 'label' => $label];
}
$agencyManagers = model(ManagerModel::class, false)
->where('mg_lg_idx', $lgIdx)
->where('mg_state', 1)
->where('mg_dept_code', 'agency')
->orderBy('mg_name', 'ASC')
->findAll();
foreach ($agencyManagers as $rcv) {
$mgIdx = (int) ($rcv->mg_idx ?? 0);
$receiverName = trim((string) ($rcv->mg_name ?? ''));
if ($mgIdx <= 0) {
continue;
}
if ($normSession !== '' && $normalizeName($receiverName) === $normSession) {
continue;
}
$options[] = ['ref' => 'g_' . $mgIdx, 'label' => $receiverName];
}
$defaultRef = $options !== [] ? (string) ($options[0]['ref'] ?? '') : '';
return [
'receiverOptions' => $options,
'defaultReceiverRef' => $defaultRef,
];
}
/**
* @param list<array{ref: string, label: string}> $options
*/
private function sanitizeReceiverRef(array $options, string $ref): string
{
foreach ($options as $opt) {
if (($opt['ref'] ?? '') === $ref) {
return $ref;
}
}
return '';
}
private function parseReceiverRefToStoredIdx(int $lgIdx, string $ref): int
{
$ref = trim($ref);
if (preg_match('/^m_(\d+)$/', $ref, $mm)) {
$mbIdx = (int) $mm[1];
if ($mbIdx <= 0 || $mbIdx !== (int) (session()->get('mb_idx') ?? 0)) {
return 0;
}
return $mbIdx;
}
if (preg_match('/^g_(\d+)$/', $ref, $mg)) {
return $this->assertAgencyReceiverIdx($lgIdx, (int) $mg[1]);
}
return 0;
}
private function assertAgencyReceiverIdx(int $lgIdx, int $mgIdx): int
{
if ($mgIdx <= 0) {
return 0;
}
$row = model(ManagerModel::class, false)->where([
'mg_idx' => $mgIdx,
'mg_lg_idx' => $lgIdx,
'mg_state' => 1,
'mg_dept_code' => 'agency',
])->first();
return $row ? $mgIdx : 0;
}
private function resolveCompanySenderName(int $lgIdx, int $mgIdx): string
{
if ($mgIdx <= 0) {
return '';
}
$row = model(ManagerModel::class, false)->where([
'mg_idx' => $mgIdx,
'mg_lg_idx' => $lgIdx,
'mg_state' => 1,
'mg_dept_code' => 'company',
])->first();
return $row ? trim((string) ($row->mg_name ?? '')) : '';
}
private function render(string $title, string $viewFile, array $data = []): string
{
return view('bag/layout/main', [
'title' => $title,
'content' => view($viewFile, $data),
]);
}
// ──────────────────────────────────────────────
// 기본정보관리 (단가·포장 단위 진입 허브)
// ──────────────────────────────────────────────
public function basicInfo(): string
{
return $this->render('기본정보관리', 'bag/basic_info', []);
}
/** 봉투 단가 조회 (사이트) — 기간·봉투구분·봉투코드 필터, 적용기간 겹침, 페이징·인쇄 */
public function prices(): string|RedirectResponse
{
helper('admin');
if ($this->request->is('post')) {
$post = $this->request->getPost();
$pick = static function (array $src, string $key): ?string {
if (! array_key_exists($key, $src)) {
return null;
}
$v = $src[$key];
if ($v === null || is_array($v)) {
return null;
}
$s = trim((string) $v);
return $s === '' ? null : $s;
};
session()->setFlashdata('bag_prices_filter', [
'start_y' => $pick($post, 'start_y'),
'start_m' => $pick($post, 'start_m'),
'start_d' => $pick($post, 'start_d'),
'end_y' => $pick($post, 'end_y'),
'end_m' => $pick($post, 'end_m'),
'end_d' => $pick($post, 'end_d'),
'bag_kind_e' => $pick($post, 'bag_kind_e'),
'bag_code' => $pick($post, 'bag_code'),
]);
return redirect()->to(site_url('bag/prices'));
}
$lgIdx = $this->lgIdx();
$bagPrices = [];
$get = $this->request->getGet();
$flash = session()->getFlashdata('bag_prices_filter');
$readSrc = static function (array $src, string $key): ?string {
if (! array_key_exists($key, $src)) {
return null;
}
$v = $src[$key];
if ($v === null || is_array($v)) {
return null;
}
$s = trim((string) $v);
return $s === '' ? null : $s;
};
$filterKeys = [
'start_y', 'start_m', 'start_d',
'end_y', 'end_m', 'end_d',
'bag_kind_e', 'bag_code',
'start_date', 'end_date',
];
$hasExplicitGetFilter = false;
foreach ($filterKeys as $fk) {
$v = $get[$fk] ?? null;
if ($v !== null && ! is_array($v) && trim((string) $v) !== '') {
$hasExplicitGetFilter = true;
break;
}
}
$src = [];
if ($hasExplicitGetFilter) {
$src = $get;
} elseif (is_array($flash)) {
$src = $flash;
}
$sy = $readSrc($src, 'start_y');
$sm = $readSrc($src, 'start_m');
$sd = $readSrc($src, 'start_d');
$ey = $readSrc($src, 'end_y');
$em = $readSrc($src, 'end_m');
$ed = $readSrc($src, 'end_d');
$startDate = null;
if ($sy !== null && $sy !== '' && $sm !== null && $sm !== '' && $sd !== null && $sd !== '') {
$startDate = parse_ymd_from_triple($sy, $sm, $sd);
}
if ($startDate === null) {
$g = $readSrc($src, 'start_date');
$startDate = ($g !== null && $g !== '') ? $g : null;
}
$endDate = null;
if ($ey !== null && $ey !== '' && $em !== null && $em !== '' && $ed !== null && $ed !== '') {
$endDate = parse_ymd_from_triple($ey, $em, $ed);
}
if ($endDate === null) {
$g = $readSrc($src, 'end_date');
$endDate = ($g !== null && $g !== '') ? $g : null;
}
$startParts = ['y' => '', 'm' => '', 'd' => ''];
$endParts = ['y' => '', 'm' => '', 'd' => ''];
if ($startDate !== null && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $startDate, $m)) {
$startParts = ['y' => $m[1], 'm' => (int) $m[2], 'd' => (int) $m[3]];
} elseif ($sy !== null && $sm !== null && $sd !== null && $sy !== '' && $sm !== '' && $sd !== '') {
$iy = (int) $sy;
$im = (int) $sm;
$id = (int) $sd;
if ($iy >= 1000 && $iy <= 9999 && $im >= 1 && $im <= 12 && $id >= 1 && $id <= 31) {
$startParts = ['y' => (string) $iy, 'm' => $im, 'd' => $id];
}
}
if ($endDate !== null && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $endDate, $m)) {
$endParts = ['y' => $m[1], 'm' => (int) $m[2], 'd' => (int) $m[3]];
} elseif ($ey !== null && $em !== null && $ed !== null && $ey !== '' && $em !== '' && $ed !== '') {
$iy = (int) $ey;
$im = (int) $em;
$id = (int) $ed;
if ($iy >= 1000 && $iy <= 9999 && $im >= 1 && $im <= 12 && $id >= 1 && $id <= 31) {
$endParts = ['y' => (string) $iy, 'm' => $im, 'd' => $id];
}
}
$dateYearMin = (int) date('Y') - 12;
$dateYearMax = (int) date('Y') + 2;
$bagKindE = $readSrc($src, 'bag_kind_e');
$bagCode = $readSrc($src, 'bag_code');
$pager = null;
$bagCodes = [];
$bagKindOpts = [];
$printLines = [];
$printLgName = '';
if ($lgIdx !== null) {
try {
$priceModel = model(BagPriceModel::class);
$builder = $priceModel->where('bp_lg_idx', $lgIdx);
if (($startDate !== null && $startDate !== '') || ($endDate !== null && $endDate !== '')) {
$qStart = ($startDate !== null && $startDate !== '') ? $startDate : $endDate;
$qEnd = ($endDate !== null && $endDate !== '') ? $endDate : $startDate;
if (strcmp((string) $qStart, (string) $qEnd) > 0) {
[$qStart, $qEnd] = [$qEnd, $qStart];
}
$builder->where('bp_start_date <=', $qEnd);
$builder->groupStart()
->where('bp_end_date IS NULL')
->orWhere('bp_end_date >=', $qStart)
->groupEnd();
}
if ($bagKindE !== null && $bagKindE !== '') {
$ek = model(CodeKindModel::class)->where('ck_code', 'E')->first();
if ($ek) {
$eDetail = model(CodeDetailModel::class)
->where('cd_ck_idx', (int) $ek->ck_idx)
->where('cd_code', $bagKindE)
->where('cd_state', 1)
->first();
if ($eDetail !== null) {
$builder->like('bp_bag_code', (string) $bagKindE, 'after');
}
}
}
if ($bagCode !== null && $bagCode !== '') {
$ok = model(CodeKindModel::class)->where('ck_code', 'O')->first();
if ($ok) {
$oDetail = model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $ok->ck_idx, (string) $bagCode, $lgIdx);
if ($oDetail !== null) {
$builder->where('bp_bag_code', $bagCode);
}
}
}
$bagPrices = $builder->orderBy('bp_bag_code', 'ASC')->orderBy('bp_start_date', 'DESC')->paginate(20);
$queryForPager = [];
$tripleS = $sy !== null && $sy !== '' && $sm !== null && $sm !== '' && $sd !== null && $sd !== '';
$tripleE = $ey !== null && $ey !== '' && $em !== null && $em !== '' && $ed !== null && $ed !== '';
if ($tripleS) {
$queryForPager['start_y'] = $sy;
$queryForPager['start_m'] = $sm;
$queryForPager['start_d'] = $sd;
} else {
$legacyS = $readSrc($src, 'start_date');
if ($legacyS !== null) {
$queryForPager['start_date'] = $legacyS;
}
}
if ($tripleE) {
$queryForPager['end_y'] = $ey;
$queryForPager['end_m'] = $em;
$queryForPager['end_d'] = $ed;
} else {
$legacyE = $readSrc($src, 'end_date');
if ($legacyE !== null) {
$queryForPager['end_date'] = $legacyE;
}
}
if ($bagKindE !== null && $bagKindE !== '') {
$queryForPager['bag_kind_e'] = $bagKindE;
}
if ($bagCode !== null && $bagCode !== '') {
$queryForPager['bag_code'] = $bagCode;
}
$queryForPager = array_filter(
$queryForPager,
static fn ($v) => $v !== null && $v !== ''
);
$pagerPath = site_url('bag/prices');
if ($queryForPager !== []) {
$pagerPath .= '?' . http_build_query($queryForPager);
}
$priceModel->pager->setPath($pagerPath);
$pager = $priceModel->pager;
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kindO
? model(CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx)
: [];
$kindE = model(CodeKindModel::class)->where('ck_code', 'E')->first();
$bagKindOpts = $kindE
? model(CodeDetailModel::class)->getByKind((int) $kindE->ck_idx, true, null)
: [];
$lgRow = model(LocalGovernmentModel::class)->find($lgIdx);
$printLgName = $lgRow !== null ? $lgRow->lg_name : '';
} catch (DatabaseException $e) {
log_message('error', '[prices] bag_price 조회 실패: ' . $e->getMessage());
}
}
if (($startDate !== null && $startDate !== '') || ($endDate !== null && $endDate !== '')) {
$qs = ($startDate !== null && $startDate !== '') ? $startDate : $endDate;
$qe = ($endDate !== null && $endDate !== '') ? $endDate : $startDate;
if (strcmp((string) $qs, (string) $qe) > 0) {
[$qs, $qe] = [$qe, $qs];
}
$printLines[] = '조회기간(적용기간 겹침): ' . format_ymd_korean($qs) . ' ~ ' . format_ymd_korean($qe);
}
if ($bagKindE !== null && $bagKindE !== '') {
foreach ($bagKindOpts as $cd) {
if ((string) $cd->cd_code === (string) $bagKindE) {
$printLines[] = '봉투구분: ' . $cd->cd_name . ' (' . $bagKindE . ')';
break;
}
}
}
if ($bagCode !== null && $bagCode !== '') {
$printLines[] = '봉투코드: ' . $bagCode;
}
$viewData = [
'lgIdx' => $lgIdx,
'bagPrices' => $bagPrices,
'pager' => $pager,
'startDate' => $startDate,
'endDate' => $endDate,
'startParts' => $startParts,
'endParts' => $endParts,
'dateYearMin' => $dateYearMin,
'dateYearMax' => $dateYearMax,
'bag_kind_e' => $bagKindE,
'bag_code' => $bagCode,
'bag_codes' => $bagCodes,
'bag_kind_options' => $bagKindOpts,
'printExtraLines' => $printLines,
];
if ($printLgName !== '') {
$viewData['printLgName'] = $printLgName;
}
return $this->render('봉투 단가', 'bag/prices', $viewData);
}
/** 포장 단위 조회 (사이트) */
public function packagingUnits(): string
{
$lgIdx = $this->lgIdx();
$packagingUnits = [];
if ($lgIdx) {
try {
$packagingUnits = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->orderBy('pu_bag_code', 'ASC')->findAll();
} catch (DatabaseException $e) {
log_message('error', '[packagingUnits] packaging_unit 조회 실패: ' . $e->getMessage());
}
}
return $this->render('포장 단위', 'bag/packaging_units', ['packagingUnits' => $packagingUnits]);
}
/**
* 기본코드 종류·세부코드 조회 전용 (사이트 메뉴 기본코드관리)
*/
public function codeKinds(): string
{
$kindModel = model(CodeKindModel::class);
$detailModel = model(CodeDetailModel::class);
$kinds = [];
$countMap = [];
$selectedKind = null;
$detailList = [];
$rowCanEdit = [];
$lgIdx = $this->lgIdx();
try {
$kinds = $kindModel->orderBy('ck_code', 'ASC')->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', '[codeKinds] 실패: {type} {message} @ {file}:{line} / lg={lg}, user={user}, level={level}', [
'type' => $e::class,
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'lg' => $lgIdx !== null ? (string) $lgIdx : 'null',
'user' => (string) (session()->get('mb_id') ?? ''),
'level' => (string) (session()->get('mb_level') ?? ''),
]);
session()->setFlashdata('error', '기본코드 조회 중 오류가 발생했습니다. 관리자에게 로그 확인을 요청해 주세요.');
}
$level = (int) session()->get('mb_level');
$canManageDetails = Roles::canManageCodeMaster($level);
if ($kinds !== []) {
$selectedCkIdx = (int) ($this->request->getGet('ck_idx') ?? 0);
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();
helper('admin');
$adminLg = admin_effective_lg_idx();
foreach ($detailList as $row) {
$rowCanEdit[$row->cd_idx] = Roles::canEditCodeDetailRow($level, $row, $adminLg);
}
}
return $this->render('기본코드관리', 'bag/code_kinds', [
'codeKinds' => $kinds,
'countMap' => $countMap,
'canManageKinds' => Roles::canManageCodeKindMaster($level),
'canManageDetails' => $canManageDetails,
'selectedKind' => $selectedKind,
'detailList' => $detailList,
'rowCanEdit' => $rowCanEdit,
]);
}
/**
* 기본코드 세부 목록 (사이트 레이아웃). 등록·수정·삭제 폼은 /admin/code-details/* 유지.
*/
public function codeDetails(int $ckIdx)
{
$kindModel = model(CodeKindModel::class);
$detailModel = model(CodeDetailModel::class);
$kind = null;
try {
$kind = $kindModel->find($ckIdx);
} catch (\Throwable $e) {
log_message('error', '[codeDetails] kind 조회 실패: {type} {message} @ {file}:{line} / ck={ck}, user={user}, level={level}', [
'type' => $e::class,
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'ck' => (string) $ckIdx,
'user' => (string) (session()->get('mb_id') ?? ''),
'level' => (string) (session()->get('mb_level') ?? ''),
]);
return redirect()->to(site_url('bag/code-kinds'))->with('error', '세부코드 조회 중 오류가 발생했습니다. 관리자에게 로그 확인을 요청해 주세요.');
}
if ($kind === null) {
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
}
$lgIdx = $this->lgIdx();
try {
$list = $detailModel->where('cd_ck_idx', $ckIdx)
->filterByTenantScope($lgIdx)
->orderBy('cd_sort', 'ASC')
->orderBy('cd_code', 'ASC')
->orderBy('cd_idx', 'ASC')
->paginate(20);
$pager = $detailModel->pager;
} catch (\Throwable $e) {
log_message('error', '[codeDetails] list 조회 실패: {type} {message} @ {file}:{line} / ck={ck}, lg={lg}, user={user}, level={level}', [
'type' => $e::class,
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'ck' => (string) $ckIdx,
'lg' => $lgIdx !== null ? (string) $lgIdx : 'null',
'user' => (string) (session()->get('mb_id') ?? ''),
'level' => (string) (session()->get('mb_level') ?? ''),
]);
return redirect()->to(site_url('bag/code-kinds'))->with('error', '세부코드 조회 중 오류가 발생했습니다. 관리자에게 로그 확인을 요청해 주세요.');
}
helper('admin');
$level = (int) session()->get('mb_level');
$adminLg = admin_effective_lg_idx();
$canManage = Roles::canManageCodeMaster($level);
$rowCanEdit = [];
foreach ($list as $row) {
$rowCanEdit[$row->cd_idx] = Roles::canEditCodeDetailRow($level, $row, $adminLg);
}
$title = ($canManage ? '세부코드 관리' : '세부코드 조회') . ' — ' . $kind->ck_name . ' (' . $kind->ck_code . ')';
return $this->render($title, 'bag/code_details', [
'kind' => $kind,
'list' => $list,
'pager' => $pager,
'canManage' => $canManage,
'rowCanEdit' => $rowCanEdit,
]);
}
// ──────────────────────────────────────────────
// 발주 입고 관리
// ──────────────────────────────────────────────
public function purchaseInbound(): string
{
$lgIdx = $this->lgIdx();
$data = ['orders' => [], 'receivings' => [], 'startDate' => null, 'endDate' => null];
if ($lgIdx) {
$startDate = $this->request->getGet('start_date');
$endDate = $this->request->getGet('end_date');
$data['startDate'] = $startDate;
$data['endDate'] = $endDate;
// 발주 목록
$orderBuilder = model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->whereLatestHead($lgIdx);
if ($startDate) $orderBuilder->where('bo_order_date >=', $startDate);
if ($endDate) $orderBuilder->where('bo_order_date <=', $endDate);
$data['orders'] = $orderBuilder->orderBy('bo_order_date', 'DESC')->paginate(20, 'orders');
$data['orderPager'] = model(BagOrderModel::class)->pager;
// 발주별 품목 합계
$itemSummary = [];
foreach ($data['orders'] as $order) {
$items = model(BagOrderItemModel::class)->where('boi_bo_idx', $order->bo_idx)->findAll();
$totalQty = 0;
$totalAmt = 0;
foreach ($items as $it) {
$totalQty += (int) $it->boi_qty_sheet;
$totalAmt += (float) $it->boi_amount;
}
$itemSummary[$order->bo_idx] = ['qty' => $totalQty, 'amount' => $totalAmt, 'count' => count($items)];
}
$data['itemSummary'] = $itemSummary;
// 입고 목록
$recvBuilder = model(BagReceivingModel::class)->where('br_lg_idx', $lgIdx);
if ($startDate) $recvBuilder->where('br_receive_date >=', $startDate);
if ($endDate) $recvBuilder->where('br_receive_date <=', $endDate);
$data['receivings'] = $recvBuilder->orderBy('br_receive_date', 'DESC')->paginate(20, 'receivings');
$data['recvPager'] = model(BagReceivingModel::class)->pager;
}
return $this->render('발주 입고 관리', 'bag/purchase_inbound', $data);
}
// ──────────────────────────────────────────────
// 불출 관리
// ──────────────────────────────────────────────
public function issueLegacy(): RedirectResponse
{
return redirect()->to(site_url('bag/issue/cancel'));
}
public function issue(): string
{
$lgIdx = $this->lgIdx();
$data = [
'filters' => [
'issue_month' => (string) ($this->request->getGet('issue_month') ?? ''),
'dest_name' => (string) ($this->request->getGet('dest_name') ?? ''),
'issue_type' => (string) ($this->request->getGet('issue_type') ?? ''),
'bag_code' => (string) ($this->request->getGet('bag_code') ?? ''),
],
'monthOptions' => [],
'destOptions' => [],
'typeOptions' => ['무료용', '공공용'],
'bagOptions' => [],
'issueGroups' => [],
'detailRows' => [],
'detailSourceRows' => [],
'codeRows' => [],
'selectedGroupDate' => '',
'selectedGroupDest' => '',
'selectedIssueId' => 0,
'selectedBagCode' => '',
];
if (! $lgIdx) {
return $this->render('불출 관리', 'bag/issue', $data);
}
$db = \Config\Database::connect();
$issueTable = $db->table('bag_issue');
$hasItemCodeTable = $db->tableExists('bag_issue_item_code');
$filterMonth = trim((string) $data['filters']['issue_month']);
$filterDest = trim((string) $data['filters']['dest_name']);
$filterType = trim((string) $data['filters']['issue_type']);
$filterBag = trim((string) $data['filters']['bag_code']);
$applyCommonFilters = static function ($builder) use ($lgIdx, $filterMonth, $filterDest, $filterType, $filterBag): void {
$builder->where('bi2_lg_idx', $lgIdx);
if (preg_match('/^\d{4}-\d{2}$/', $filterMonth) === 1) {
$start = $filterMonth . '-01';
$end = date('Y-m-t', strtotime($start));
$builder->where('bi2_issue_date >=', $start);
$builder->where('bi2_issue_date <=', $end);
}
if ($filterDest !== '') {
$builder->where('bi2_dest_name', $filterDest);
}
if ($filterType !== '') {
$builder->where('bi2_issue_type', $filterType);
}
if ($filterBag !== '') {
$builder->where('bi2_bag_code', $filterBag);
}
};
$monthRows = $db->table('bag_issue')
->select("DATE_FORMAT(bi2_issue_date, '%Y-%m') AS issue_month", false)
->where('bi2_lg_idx', $lgIdx)
->groupBy("DATE_FORMAT(bi2_issue_date, '%Y-%m')", false)
->orderBy('issue_month', 'DESC')
->get()
->getResultArray();
foreach ($monthRows as $row) {
$month = (string) ($row['issue_month'] ?? '');
if ($month !== '') {
$data['monthOptions'][] = $month;
}
}
$destRows = $db->table('bag_issue')
->select('bi2_dest_name')
->where('bi2_lg_idx', $lgIdx)
->groupBy('bi2_dest_name')
->orderBy('bi2_dest_name', 'ASC')
->get()
->getResultArray();
$data['destOptions'] = array_values(array_filter(array_map(static fn ($r): string => (string) ($r['bi2_dest_name'] ?? ''), $destRows)));
$bagRows = $db->table('bag_issue')
->select('bi2_bag_code, MAX(bi2_bag_name) AS bi2_bag_name', false)
->where('bi2_lg_idx', $lgIdx)
->groupBy('bi2_bag_code')
->orderBy('bi2_bag_code', 'ASC')
->get()
->getResultArray();
foreach ($bagRows as $row) {
$code = (string) ($row['bi2_bag_code'] ?? '');
if ($code === '') {
continue;
}
$data['bagOptions'][] = [
'code' => $code,
'name' => (string) ($row['bi2_bag_name'] ?? $code),
];
}
$groupBuilder = $db->table('bag_issue')
->select('bi2_issue_date, bi2_dest_name, COUNT(*) AS row_count, SUM(bi2_qty) AS total_qty', false)
->groupBy('bi2_issue_date, bi2_dest_name');
$applyCommonFilters($groupBuilder);
$data['issueGroups'] = $groupBuilder
->orderBy('bi2_issue_date', 'DESC')
->orderBy('bi2_dest_name', 'ASC')
->get()
->getResultArray();
$selectedDate = (string) ($this->request->getGet('sel_date') ?? '');
$selectedDest = (string) ($this->request->getGet('sel_dest') ?? '');
if (($selectedDate === '' || $selectedDest === '') && $data['issueGroups'] !== []) {
$selectedDate = (string) ($data['issueGroups'][0]['bi2_issue_date'] ?? '');
$selectedDest = (string) ($data['issueGroups'][0]['bi2_dest_name'] ?? '');
}
$data['selectedGroupDate'] = $selectedDate;
$data['selectedGroupDest'] = $selectedDest;
if ($selectedDate !== '' && $selectedDest !== '') {
$detailBuilder = $db->table('bag_issue')
->select('bi2_idx, bi2_issue_date, bi2_issue_type, bi2_bag_code, bi2_bag_name, bi2_qty, bi2_status')
->where('bi2_lg_idx', $lgIdx)
->where('bi2_issue_date', $selectedDate)
->where('bi2_dest_name', $selectedDest);
if ($filterType !== '') {
$detailBuilder->where('bi2_issue_type', $filterType);
}
if ($filterBag !== '') {
$detailBuilder->where('bi2_bag_code', $filterBag);
}
$data['detailRows'] = $detailBuilder
->orderBy('bi2_idx', 'ASC')
->get()
->getResultArray();
$data['detailSourceRows'] = $data['detailRows'];
}
$detailIssueIds = array_map(static fn ($row): int => (int) ($row['bi2_idx'] ?? 0), $data['detailRows']);
$cancelMap = [];
$codeQtyMap = [];
if ($hasItemCodeTable && $detailIssueIds !== []) {
$aggRows = $db->table('bag_issue_item_code')
->select('bic_bi2_idx, SUM(bic_qty) AS sum_qty, SUM(bic_cancel_qty) AS sum_cancel', false)
->whereIn('bic_bi2_idx', $detailIssueIds)
->groupBy('bic_bi2_idx')
->get()
->getResultArray();
foreach ($aggRows as $agg) {
$idx = (int) ($agg['bic_bi2_idx'] ?? 0);
if ($idx <= 0) {
continue;
}
$cancelMap[$idx] = (int) ($agg['sum_cancel'] ?? 0);
$codeQtyMap[$idx] = (int) ($agg['sum_qty'] ?? 0);
}
}
foreach ($data['detailRows'] as &$row) {
$idx = (int) ($row['bi2_idx'] ?? 0);
$cancelQty = (int) ($cancelMap[$idx] ?? 0);
$baseQty = isset($codeQtyMap[$idx]) ? (int) $codeQtyMap[$idx] : ((int) ($row['bi2_qty'] ?? 0) + $cancelQty);
$row['base_qty'] = max(0, $baseQty);
$row['cancel_qty'] = max(0, min($row['base_qty'], $cancelQty));
$row['remain_qty'] = max(0, $row['base_qty'] - $row['cancel_qty']);
}
unset($row);
$data['detailSourceRows'] = $data['detailRows'];
$aggByBag = [];
foreach ($data['detailRows'] as $row) {
$bagCodeKey = (string) ($row['bi2_bag_code'] ?? '');
if ($bagCodeKey === '') {
continue;
}
if (! isset($aggByBag[$bagCodeKey])) {
$aggByBag[$bagCodeKey] = [
'bi2_issue_date' => (string) ($row['bi2_issue_date'] ?? ''),
'bi2_issue_type' => (string) ($row['bi2_issue_type'] ?? ''),
'bi2_bag_code' => $bagCodeKey,
'bi2_bag_name' => (string) ($row['bi2_bag_name'] ?? $bagCodeKey),
'base_qty' => 0,
'cancel_qty' => 0,
'issue_ids' => [],
];
}
$aggByBag[$bagCodeKey]['base_qty'] += (int) ($row['base_qty'] ?? 0);
$aggByBag[$bagCodeKey]['cancel_qty'] += (int) ($row['cancel_qty'] ?? 0);
$aggByBag[$bagCodeKey]['issue_ids'][] = (int) ($row['bi2_idx'] ?? 0);
}
$data['detailRows'] = array_values($aggByBag);
$selectedIssueId = (int) ($this->request->getGet('sel_issue_id') ?? 0);
if ($selectedIssueId <= 0 && $data['detailRows'] !== []) {
$selectedIssueId = (int) (($data['detailRows'][0]['issue_ids'][0] ?? 0));
}
$data['selectedIssueId'] = $selectedIssueId;
$selectedBagCode = trim((string) ($this->request->getGet('sel_bag_code') ?? ''));
if ($selectedBagCode === '' && $data['detailRows'] !== []) {
$selectedBagCode = (string) ($data['detailRows'][0]['bi2_bag_code'] ?? '');
}
$data['selectedBagCode'] = $selectedBagCode;
if ($selectedBagCode !== '') {
$selectedIssueIds = [];
foreach (($data['detailRows'] ?? []) as $detailRow) {
if ((string) ($detailRow['bi2_bag_code'] ?? '') !== $selectedBagCode) {
continue;
}
$selectedIssueIds = array_values(array_filter(array_map('intval', (array) ($detailRow['issue_ids'] ?? []))));
break;
}
$sourceByIssue = [];
foreach (($data['detailSourceRows'] ?? []) as $sourceRow) {
if ((string) ($sourceRow['bi2_bag_code'] ?? '') !== $selectedBagCode) {
continue;
}
$sourceIssueId = (int) ($sourceRow['bi2_idx'] ?? 0);
if ($sourceIssueId <= 0) {
continue;
}
$sourceByIssue[$sourceIssueId] = $sourceRow;
}
if ($hasItemCodeTable) {
if ($selectedIssueIds !== []) {
$existingRows = $db->table('bag_issue_item_code')
->select('bic_bi2_idx')
->where('bic_lg_idx', $lgIdx)
->where('bic_bag_code', $selectedBagCode)
->whereIn('bic_bi2_idx', $selectedIssueIds)
->groupBy('bic_bi2_idx')
->get()
->getResultArray();
$existingIssueSet = [];
foreach ($existingRows as $existingRow) {
$issueId = (int) ($existingRow['bic_bi2_idx'] ?? 0);
if ($issueId > 0) {
$existingIssueSet[$issueId] = true;
}
}
foreach ($selectedIssueIds as $issueId) {
$issueId = (int) $issueId;
if ($issueId <= 0 || isset($existingIssueSet[$issueId])) {
continue;
}
$source = $sourceByIssue[$issueId] ?? null;
if (! is_array($source)) {
continue;
}
$sourceQty = max(0, (int) ($source['base_qty'] ?? 0));
if ($sourceQty <= 0) {
continue;
}
$sourceCancel = max(0, min($sourceQty, (int) ($source['cancel_qty'] ?? 0)));
$db->table('bag_issue_item_code')->insert([
'bic_lg_idx' => $lgIdx,
'bic_bi2_idx' => $issueId,
'bic_bag_code' => $selectedBagCode,
'bic_issue_code' => sprintf('%02d-%06d-001', (int) date('y'), $issueId),
'bic_qty' => $sourceQty,
'bic_cancel_qty' => $sourceCancel,
'bic_state' => ($sourceCancel >= $sourceQty) ? 'cancelled' : 'normal',
'bic_regdate' => date('Y-m-d H:i:s'),
]);
}
$data['codeRows'] = $db->table('bag_issue_item_code')
->select('bic_idx, bic_bi2_idx, bic_issue_code, bic_qty, bic_cancel_qty')
->where('bic_lg_idx', $lgIdx)
->where('bic_bag_code', $selectedBagCode)
->whereIn('bic_bi2_idx', $selectedIssueIds)
->orderBy('bic_bi2_idx', 'ASC')
->orderBy('bic_idx', 'ASC')
->get()
->getResultArray();
}
}
$existingIssueIds = [];
foreach (($data['codeRows'] ?? []) as $codeRow) {
$existingIssueId = (int) ($codeRow['bic_bi2_idx'] ?? 0);
if ($existingIssueId > 0) {
$existingIssueIds[$existingIssueId] = true;
}
}
foreach ($sourceByIssue as $sourceIssueId => $sourceRow) {
if (isset($existingIssueIds[$sourceIssueId])) {
continue;
}
$data['codeRows'][] = [
'bic_idx' => 0,
'bic_bi2_idx' => $sourceIssueId,
'bic_issue_code' => sprintf('%02d-%06d-001', (int) date('y'), $sourceIssueId),
'bic_qty' => (int) ($sourceRow['base_qty'] ?? 0),
'bic_cancel_qty' => (int) ($sourceRow['cancel_qty'] ?? 0),
];
}
if (($data['codeRows'] ?? []) !== []) {
usort($data['codeRows'], static function (array $a, array $b): int {
$issueCmp = ((int) ($a['bic_bi2_idx'] ?? 0)) <=> ((int) ($b['bic_bi2_idx'] ?? 0));
if ($issueCmp !== 0) {
return $issueCmp;
}
return ((int) ($a['bic_idx'] ?? 0)) <=> ((int) ($b['bic_idx'] ?? 0));
});
}
}
return $this->render('불출 관리', 'bag/issue', $data);
}
public function issueCancelSave(): RedirectResponse
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/issue/cancel'))->with('error', '지자체를 선택해 주세요.');
}
$db = \Config\Database::connect();
$issueModel = model(BagIssueModel::class);
$inventoryModel = model(BagInventoryModel::class);
$hasItemCodeTable = $db->tableExists('bag_issue_item_code');
$codeCancelQtyInput = $this->request->getPost('code_cancel_qty');
$codeCancelQtyInput = is_array($codeCancelQtyInput) ? $codeCancelQtyInput : [];
$codeCheckedInput = $this->request->getPost('code_cancel_check');
$codeCheckedInput = is_array($codeCheckedInput) ? $codeCheckedInput : [];
$issueCancelQtyInput = $this->request->getPost('issue_cancel_qty');
$issueCancelQtyInput = is_array($issueCancelQtyInput) ? $issueCancelQtyInput : [];
$issueCheckedInput = $this->request->getPost('issue_cancel_check');
$issueCheckedInput = is_array($issueCheckedInput) ? $issueCheckedInput : [];
$issueDeltaMap = [];
$touchedIssueIds = [];
$db->transStart();
if ($hasItemCodeTable && $codeCancelQtyInput !== []) {
$codeIds = array_values(array_unique(array_map('intval', array_keys($codeCancelQtyInput))));
$codeIds = array_values(array_filter($codeIds, static fn ($v): bool => $v > 0));
if ($codeIds !== []) {
$rows = $db->table('bag_issue_item_code')
->select('bic_idx, bic_bi2_idx, bic_qty, bic_cancel_qty')
->where('bic_lg_idx', $lgIdx)
->whereIn('bic_idx', $codeIds)
->get()
->getResultArray();
foreach ($rows as $row) {
$bicIdx = (int) ($row['bic_idx'] ?? 0);
$bi2Idx = (int) ($row['bic_bi2_idx'] ?? 0);
$qty = (int) ($row['bic_qty'] ?? 0);
$oldCancel = (int) ($row['bic_cancel_qty'] ?? 0);
$isChecked = isset($codeCheckedInput[(string) $bicIdx]);
$inputCancel = (int) ($codeCancelQtyInput[(string) $bicIdx] ?? 0);
$newCancel = $isChecked ? $qty : max(0, min($qty, $inputCancel));
if ($newCancel === $oldCancel) {
continue;
}
$db->table('bag_issue_item_code')
->where('bic_idx', $bicIdx)
->update([
'bic_cancel_qty' => $newCancel,
'bic_state' => ($newCancel >= $qty) ? 'cancelled' : 'normal',
]);
if (! isset($issueDeltaMap[$bi2Idx])) {
$issueDeltaMap[$bi2Idx] = 0;
}
$issueDeltaMap[$bi2Idx] += ($newCancel - $oldCancel);
$touchedIssueIds[$bi2Idx] = true;
}
}
}
$fallbackIssueIds = array_values(array_unique(array_map('intval', array_keys($issueCancelQtyInput))));
$fallbackIssueIds = array_values(array_filter($fallbackIssueIds, static fn ($v): bool => $v > 0));
if ($fallbackIssueIds !== []) {
$issueRows = $issueModel
->where('bi2_lg_idx', $lgIdx)
->whereIn('bi2_idx', $fallbackIssueIds)
->findAll();
foreach ($issueRows as $issueRow) {
$bi2Idx = (int) ($issueRow->bi2_idx ?? 0);
if ($bi2Idx <= 0 || isset($touchedIssueIds[$bi2Idx])) {
continue;
}
$baseQty = (int) ($issueRow->bi2_qty ?? 0);
$isChecked = isset($issueCheckedInput[(string) $bi2Idx]);
$inputCancel = (int) ($issueCancelQtyInput[(string) $bi2Idx] ?? 0);
$newCancel = $isChecked ? $baseQty : max(0, min($baseQty, $inputCancel));
if ($newCancel <= 0) {
continue;
}
$issueModel->update($bi2Idx, [
'bi2_qty' => $baseQty - $newCancel,
'bi2_status' => ($newCancel >= $baseQty) ? 'cancelled' : 'normal',
]);
$inventoryModel->adjustQty(
$lgIdx,
(string) ($issueRow->bi2_bag_code ?? ''),
(string) ($issueRow->bi2_bag_name ?? ''),
$newCancel
);
}
}
if ($touchedIssueIds !== []) {
$issueIds = array_keys($touchedIssueIds);
$aggRows = $db->table('bag_issue_item_code')
->select('bic_bi2_idx, SUM(bic_qty) AS sum_qty, SUM(bic_cancel_qty) AS sum_cancel', false)
->whereIn('bic_bi2_idx', $issueIds)
->groupBy('bic_bi2_idx')
->get()
->getResultArray();
$aggMap = [];
foreach ($aggRows as $row) {
$idx = (int) ($row['bic_bi2_idx'] ?? 0);
if ($idx <= 0) {
continue;
}
$aggMap[$idx] = [
'sum_qty' => (int) ($row['sum_qty'] ?? 0),
'sum_cancel' => (int) ($row['sum_cancel'] ?? 0),
];
}
$issues = $issueModel->where('bi2_lg_idx', $lgIdx)->whereIn('bi2_idx', $issueIds)->findAll();
foreach ($issues as $issue) {
$bi2Idx = (int) ($issue->bi2_idx ?? 0);
$sumQty = (int) ($aggMap[$bi2Idx]['sum_qty'] ?? (int) ($issue->bi2_qty ?? 0));
$sumCancel = (int) ($aggMap[$bi2Idx]['sum_cancel'] ?? 0);
$remain = max(0, $sumQty - $sumCancel);
$issueModel->update($bi2Idx, [
'bi2_qty' => $remain,
'bi2_status' => ($remain <= 0 ? 'cancelled' : 'normal'),
]);
$delta = (int) ($issueDeltaMap[$bi2Idx] ?? 0);
if ($delta !== 0) {
$inventoryModel->adjustQty(
$lgIdx,
(string) ($issue->bi2_bag_code ?? ''),
(string) ($issue->bi2_bag_name ?? ''),
$delta
);
}
}
}
$db->transComplete();
if (! $db->transStatus()) {
return redirect()->back()->withInput()->with('error', '불출 취소 저장 중 오류가 발생했습니다.');
}
return redirect()->back()->with('success', '불출 취소 수량이 저장되었습니다.');
}
// ──────────────────────────────────────────────
// 재고 관리
// ──────────────────────────────────────────────
public function inventory(): string
{
$lgIdx = $this->lgIdx();
$baseDate = trim((string) ($this->request->getGet('base_date') ?? date('Y-m-d')));
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $baseDate)) {
$baseDate = date('Y-m-d');
}
$agencyIdx = (int) ($this->request->getGet('agency_idx') ?? 0);
$data = [
'baseDate' => $baseDate,
'agencyIdx' => $agencyIdx,
'agencyOptions' => [],
'rows' => [],
'subtotals' => [],
'grandTotals' => ['total' => 0, 'gugun' => 0, 'agency' => 0],
];
if ($lgIdx) {
$agencyModel = model(SalesAgencyModel::class);
$data['agencyOptions'] = $agencyModel
->where('sa_lg_idx', $lgIdx)
->orderForDisplay()
->findAll();
$report = $this->buildInventoryStatusData($lgIdx, $baseDate, $agencyIdx);
$data = array_merge($data, $report);
}
return $this->render('재고 현황', 'bag/inventory', $data);
}
public function inventoryExport(): ResponseInterface|RedirectResponse
{
helper(['admin', 'export']);
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
}
$baseDate = trim((string) ($this->request->getGet('base_date') ?? date('Y-m-d')));
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $baseDate)) {
$baseDate = date('Y-m-d');
}
$agencyIdx = (int) ($this->request->getGet('agency_idx') ?? 0);
$report = $this->buildInventoryStatusData($lgIdx, $baseDate, $agencyIdx);
$rows = [];
foreach (($report['rows'] ?? []) as $row) {
$rows[] = [
(string) ($row['group'] ?? ''),
(string) ($row['name'] ?? ''),
(int) ($row['total_qty'] ?? 0),
(int) ($row['gugun_qty'] ?? 0),
(int) ($row['agency_qty'] ?? 0),
];
}
foreach (($report['subtotals'] ?? []) as $subtotal) {
$rows[] = [
(string) ($subtotal['group'] ?? ''),
'소계',
(int) ($subtotal['total_qty'] ?? 0),
(int) ($subtotal['gugun_qty'] ?? 0),
(int) ($subtotal['agency_qty'] ?? 0),
];
}
$rows[] = [
'',
'합계',
(int) ($report['grandTotals']['total'] ?? 0),
(int) ($report['grandTotals']['gugun'] ?? 0),
(int) ($report['grandTotals']['agency'] ?? 0),
];
export_xlsx(
'재고현황_' . str_replace('-', '', $baseDate) . '.xlsx',
'재고현황',
['품목구분', '봉투/스티커 종류', '계', '시군구 재고', '대행소 재고'],
$rows
);
}
/**
* @return array{
* rows: list<array{group:string,name:string,total_qty:int,gugun_qty:int,agency_qty:int}>,
* subtotals: list<array{group:string,total_qty:int,gugun_qty:int,agency_qty:int}>,
* grandTotals: array{total:int,gugun:int,agency:int}
* }
*/
private function buildInventoryStatusData(int $lgIdx, string $baseDate, int $agencyIdx): array
{
$builder = model(BagInventoryModel::class)
->where('bi_lg_idx', $lgIdx)
->where('bi_updated_at <=', $baseDate . ' 23:59:59')
->orderBy('bi_bag_code', 'ASC');
// 대행소 재고 연계 테이블이 아직 없어 agency 필터는 조회조건 표시용으로만 유지한다.
if ($agencyIdx > 0) {
// no-op
}
$list = $builder->findAll();
$rows = [];
$subtotalMap = [];
$groupOrder = [];
$grand = ['total' => 0, 'gugun' => 0, 'agency' => 0];
foreach ($list as $row) {
$bagName = trim((string) ($row->bi_bag_name ?? ''));
$bagCode = trim((string) ($row->bi_bag_code ?? ''));
$group = $this->inventoryGroupLabel($bagName, $bagCode);
if (! isset($groupOrder[$group])) {
$groupOrder[$group] = count($groupOrder);
}
$gugunQty = max(0, (int) ($row->bi_qty ?? 0));
$agencyQty = 0;
$totalQty = $gugunQty + $agencyQty;
$rows[] = [
'group' => $group,
'name' => $bagName !== '' ? $bagName : $bagCode,
'total_qty' => $totalQty,
'gugun_qty' => $gugunQty,
'agency_qty' => $agencyQty,
'_sort' => $groupOrder[$group],
];
if (! isset($subtotalMap[$group])) {
$subtotalMap[$group] = ['group' => $group, 'total_qty' => 0, 'gugun_qty' => 0, 'agency_qty' => 0];
}
$subtotalMap[$group]['total_qty'] += $totalQty;
$subtotalMap[$group]['gugun_qty'] += $gugunQty;
$subtotalMap[$group]['agency_qty'] += $agencyQty;
$grand['total'] += $totalQty;
$grand['gugun'] += $gugunQty;
$grand['agency'] += $agencyQty;
}
usort($rows, static function (array $a, array $b): int {
$g = ((int) ($a['_sort'] ?? 0)) <=> ((int) ($b['_sort'] ?? 0));
if ($g !== 0) {
return $g;
}
return strnatcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
foreach ($rows as &$row) {
unset($row['_sort']);
}
unset($row);
$subtotals = array_values($subtotalMap);
usort($subtotals, static function (array $a, array $b) use ($groupOrder): int {
return ((int) ($groupOrder[$a['group']] ?? 0)) <=> ((int) ($groupOrder[$b['group']] ?? 0));
});
return [
'rows' => $rows,
'subtotals' => $subtotals,
'grandTotals' => $grand,
];
}
private function inventoryGroupLabel(string $bagName, string $bagCode): string
{
$name = trim($bagName);
$code = trim($bagCode);
$source = $name !== '' ? $name : $code;
if (mb_strpos($source, '스티커') !== false) {
if (mb_strpos($source, '음식물') !== false) {
return '음식물 스티커';
}
if (mb_strpos($source, '폐기물') !== false) {
return '대형폐기물 스티커';
}
return '기타 스티커';
}
if (mb_strpos($source, '재사용') !== false) {
return '재사용';
}
if (mb_strpos($source, '공공') !== false || mb_strpos($source, '공동주택') !== false) {
return '공공용';
}
if (mb_strpos($source, '음식물') !== false) {
return '음식물 봉투';
}
return '일반용';
}
public function inspectionSelect(): string|RedirectResponse
{
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
}
$this->ensureInventoryInspectionTables();
$this->ensureInspectionPackSnapshotTable();
$today = date('Y-m-d');
$startDate = trim((string) ($this->request->getGet('start_date') ?? date('Y-m-01')));
$endDate = trim((string) ($this->request->getGet('end_date') ?? $today));
$workDate = trim((string) ($this->request->getGet('work_date') ?? $today));
$itemCode = trim((string) ($this->request->getGet('item_code') ?? ''));
$selectedInspectionId = (int) ($this->request->getGet('bis_id') ?? 0);
$viewType = trim((string) ($this->request->getGet('view_type') ?? 'box'));
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
$startDate = date('Y-m-01');
}
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
$endDate = $today;
}
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $workDate)) {
$workDate = $today;
}
if (! in_array($viewType, ['box', 'pack'], true)) {
$viewType = 'box';
}
$inventoryRows = model(BagInventoryModel::class)
->where('bi_lg_idx', $lgIdx)
->orderBy('bi_bag_code', 'ASC')
->findAll();
$db = \Config\Database::connect();
$barcodeRows = $db->table('bag_receiving_pack_code')
->select('brpc_bag_code')
->distinct()
->where('brpc_lg_idx', $lgIdx)
->where('brpc_bag_code !=', '')
->get()
->getResultArray();
$barcodeSet = [];
foreach ($barcodeRows as $row) {
$code = trim((string) ($row['brpc_bag_code'] ?? ''));
if ($code !== '') {
$barcodeSet[$code] = true;
}
}
$popupItems = [];
foreach ($inventoryRows as $inv) {
$code = trim((string) ($inv->bi_bag_code ?? ''));
if ($code === '') {
continue;
}
$name = trim((string) ($inv->bi_bag_name ?? $code));
$qty = (int) ($inv->bi_qty ?? 0);
$hasBarcode = isset($barcodeSet[$code]);
$popupItems[] = [
'bag_code' => $code,
'bag_name' => $name,
'qty' => $qty,
'has_barcode' => $hasBarcode,
];
}
if ($selectedInspectionId <= 0) {
$latestInspection = $db->table('bag_inventory_inspection')
->select("bis_idx, (CASE bis_status WHEN 'confirmed' THEN 3 WHEN 'counting' THEN 2 WHEN 'selected' THEN 1 ELSE 0 END) AS status_rank", false)
->where('bis_lg_idx', $lgIdx)
->where('bis_work_date >=', $startDate)
->where('bis_work_date <=', $endDate)
->orderBy('status_rank', 'DESC')
->orderBy('bis_work_date', 'DESC')
->orderBy('bis_idx', 'DESC')
->get()
->getRowArray();
$selectedInspectionId = (int) ($latestInspection['bis_idx'] ?? 0);
}
$inspectionRuns = $db->table('bag_inventory_inspection')
->select('bis_idx, bis_work_date, bis_status')
->where('bis_lg_idx', $lgIdx)
->where('bis_work_date >=', $startDate)
->where('bis_work_date <=', $endDate)
->orderBy('bis_work_date', 'DESC')
->orderBy('bis_idx', 'DESC')
->get()
->getResultArray();
$overviewBuilder = $db->table('bag_inventory_inspection_item i')
->select('i.bisi_idx, i.bisi_bis_idx, i.bisi_bag_code, i.bisi_bag_name, i.bisi_system_qty, h.bis_work_date, h.bis_status')
->join('bag_inventory_inspection h', 'h.bis_idx = i.bisi_bis_idx', 'inner')
->where('h.bis_lg_idx', $lgIdx)
->where('h.bis_work_date >=', $startDate)
->where('h.bis_work_date <=', $endDate);
if ($selectedInspectionId > 0) {
$overviewBuilder->where('i.bisi_bis_idx', $selectedInspectionId);
}
if ($itemCode !== '') {
$overviewBuilder->where('i.bisi_bag_code', $itemCode);
}
$overviewRows = $overviewBuilder
->orderBy('h.bis_work_date', 'ASC')
->orderBy('i.bisi_bag_code', 'ASC')
->orderBy('i.bisi_idx', 'ASC')
->get()
->getResultArray();
$overviewRows = $this->expandInspectionRowsByBox($db, $lgIdx, $overviewRows, true);
$selectedInspectionItemId = (int) ($this->request->getGet('sel_item_id') ?? 0);
if ($selectedInspectionItemId <= 0 && $overviewRows !== []) {
$selectedInspectionItemId = (int) ($overviewRows[0]['bisi_idx'] ?? 0);
}
$selectedInspectionItem = null;
foreach ($overviewRows as $row) {
if ((int) ($row['bisi_idx'] ?? 0) === $selectedInspectionItemId) {
$selectedInspectionItem = $row;
$selectedInspectionId = (int) ($row['bisi_bis_idx'] ?? 0);
break;
}
}
$items = [];
foreach ($overviewRows as $row) {
$code = trim((string) ($row['bisi_bag_code'] ?? ''));
if ($code === '' || isset($items[$code])) {
continue;
}
$items[$code] = [
'bag_code' => $code,
'bag_name' => trim((string) ($row['bisi_bag_name'] ?? $code)),
];
}
$items = array_values($items);
$boxRows = [];
$sheetRows = [];
$selectedBoxCode = trim((string) ($this->request->getGet('sel_box_code') ?? ''));
$selectedPackCode = trim((string) ($this->request->getGet('sel_pack_code') ?? ''));
if (is_array($selectedInspectionItem)) {
$this->ensureInspectionPackSnapshotForItem($lgIdx, $selectedInspectionItemId);
$bagCode = trim((string) ($selectedInspectionItem['bisi_bag_code'] ?? ''));
if ($bagCode !== '') {
$boxRows = $db->table('bag_inventory_inspection_pack_snapshot')
->select('bisp_idx, bisp_box_code, bisp_pack_code, bisp_sheet_start_code, bisp_sheet_end_code, bisp_sheet_qty')
->where('bisp_lg_idx', $lgIdx)
->where('bisp_bisi_idx', $selectedInspectionItemId)
->where('bisp_bag_code', $bagCode)
->orderBy('bisp_sheet_qty', 'DESC')
->orderBy('bisp_idx', 'ASC')
->get()
->getResultArray();
}
if ($selectedBoxCode === '' && $boxRows !== []) {
$selectedBoxCode = (string) ($boxRows[0]['bisp_box_code'] ?? '');
}
if ($selectedPackCode === '' && $boxRows !== []) {
$selectedPackCode = (string) ($boxRows[0]['bisp_pack_code'] ?? '');
}
foreach ($boxRows as $boxRow) {
$boxCode = (string) ($boxRow['bisp_box_code'] ?? '');
$packCode = (string) ($boxRow['bisp_pack_code'] ?? '');
if ($boxCode === '' || $packCode === '') {
continue;
}
if ($boxCode !== $selectedBoxCode || $packCode !== $selectedPackCode) {
continue;
}
$startCode = (string) ($boxRow['bisp_sheet_start_code'] ?? '');
$endCode = (string) ($boxRow['bisp_sheet_end_code'] ?? '');
$sheetRows = [[
'no' => 1,
'biss_sheet_code' => $startCode . ' ~ ' . $endCode,
'biss_system_qty' => max(0, (int) ($boxRow['bisp_sheet_qty'] ?? 0)),
]];
break;
}
}
return $this->render('실사 선별 조회', 'bag/inventory_inspection_select_overview', [
'startDate' => $startDate,
'endDate' => $endDate,
'workDate' => $workDate,
'itemCode' => $itemCode,
'viewType' => $viewType,
'inspectionRuns' => $inspectionRuns,
'items' => $items,
'selectedInspectionId' => $selectedInspectionId,
'selectedInspectionItemId' => $selectedInspectionItemId,
'overviewRows' => $overviewRows,
'boxRows' => $boxRows,
'sheetRows' => $sheetRows,
'selectedBoxCode' => $selectedBoxCode,
'selectedPackCode' => $selectedPackCode,
'popupItems' => $popupItems,
]);
}
public function inspectionWork(): string|RedirectResponse
{
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
}
$this->ensureInventoryInspectionTables();
$this->ensureInspectionPackSnapshotTable();
$this->ensureInspectionSheetSnapshotTable();
$today = date('Y-m-d');
$startDate = trim((string) ($this->request->getGet('start_date') ?? date('Y-m-01')));
$endDate = trim((string) ($this->request->getGet('end_date') ?? $today));
$workDate = trim((string) ($this->request->getGet('work_date') ?? $today));
$itemCode = trim((string) ($this->request->getGet('item_code') ?? ''));
$selectedInspectionId = (int) ($this->request->getGet('bis_id') ?? 0);
$viewType = trim((string) ($this->request->getGet('view_type') ?? 'box'));
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
$startDate = date('Y-m-01');
}
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
$endDate = $today;
}
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $workDate)) {
$workDate = $today;
}
if (! in_array($viewType, ['box', 'pack'], true)) {
$viewType = 'box';
}
$inventoryRows = model(BagInventoryModel::class)
->where('bi_lg_idx', $lgIdx)
->orderBy('bi_bag_code', 'ASC')
->findAll();
$db = \Config\Database::connect();
$barcodeRows = $db->table('bag_receiving_pack_code')
->select('brpc_bag_code')
->distinct()
->where('brpc_lg_idx', $lgIdx)
->where('brpc_bag_code !=', '')
->get()
->getResultArray();
$barcodeSet = [];
foreach ($barcodeRows as $row) {
$code = trim((string) ($row['brpc_bag_code'] ?? ''));
if ($code !== '') {
$barcodeSet[$code] = true;
}
}
$popupItems = [];
foreach ($inventoryRows as $inv) {
$code = trim((string) ($inv->bi_bag_code ?? ''));
if ($code === '') {
continue;
}
$popupItems[] = [
'bag_code' => $code,
'bag_name' => trim((string) ($inv->bi_bag_name ?? $code)),
'qty' => (int) ($inv->bi_qty ?? 0),
'has_barcode' => isset($barcodeSet[$code]),
];
}
if ($selectedInspectionId <= 0) {
$latestInspection = $db->table('bag_inventory_inspection')
->select("bis_idx, (CASE bis_status WHEN 'confirmed' THEN 3 WHEN 'counting' THEN 2 WHEN 'selected' THEN 1 ELSE 0 END) AS status_rank", false)
->where('bis_lg_idx', $lgIdx)
->where('bis_work_date >=', $startDate)
->where('bis_work_date <=', $endDate)
->orderBy('status_rank', 'DESC')
->orderBy('bis_work_date', 'DESC')
->orderBy('bis_idx', 'DESC')
->get()
->getRowArray();
$selectedInspectionId = (int) ($latestInspection['bis_idx'] ?? 0);
}
$inspectionRuns = $db->table('bag_inventory_inspection')
->select('bis_idx, bis_work_date, bis_status')
->where('bis_lg_idx', $lgIdx)
->where('bis_work_date >=', $startDate)
->where('bis_work_date <=', $endDate)
->orderBy('bis_work_date', 'DESC')
->orderBy('bis_idx', 'DESC')
->get()
->getResultArray();
$requestedInspectionItemId = (int) ($this->request->getGet('sel_item_id') ?? 0);
if ($requestedInspectionItemId > 0) {
$this->ensureInspectionPackSnapshotForItem($lgIdx, $requestedInspectionItemId);
}
$overviewBuilder = $db->table('bag_inventory_inspection_item i')
->select('i.bisi_idx, i.bisi_bis_idx, i.bisi_bag_code, i.bisi_bag_name, i.bisi_system_qty, i.bisi_actual_qty, i.bisi_diff_qty, i.bisi_apply_yn, h.bis_work_date, h.bis_status')
->join('bag_inventory_inspection h', 'h.bis_idx = i.bisi_bis_idx', 'inner')
->where('h.bis_lg_idx', $lgIdx)
->where('h.bis_work_date >=', $startDate)
->where('h.bis_work_date <=', $endDate);
if ($selectedInspectionId > 0) {
$overviewBuilder->where('i.bisi_bis_idx', $selectedInspectionId);
}
if ($itemCode !== '') {
$overviewBuilder->where('i.bisi_bag_code', $itemCode);
}
$overviewRows = $overviewBuilder
->orderBy('h.bis_work_date', 'ASC')
->orderBy('i.bisi_bag_code', 'ASC')
->orderBy('i.bisi_idx', 'ASC')
->get()
->getResultArray();
$overviewRows = $this->expandInspectionRowsByBox($db, $lgIdx, $overviewRows, true);
$selectedInspectionItemId = (int) ($this->request->getGet('sel_item_id') ?? 0);
if ($selectedInspectionItemId <= 0 && $overviewRows !== []) {
$selectedInspectionItemId = (int) ($overviewRows[0]['bisi_idx'] ?? 0);
}
$selectedInspectionItem = null;
foreach ($overviewRows as $row) {
if ((int) ($row['bisi_idx'] ?? 0) === $selectedInspectionItemId) {
$selectedInspectionItem = $row;
$selectedInspectionId = (int) ($row['bisi_bis_idx'] ?? 0);
break;
}
}
$items = [];
foreach ($overviewRows as $row) {
$code = trim((string) ($row['bisi_bag_code'] ?? ''));
if ($code === '' || isset($items[$code])) {
continue;
}
$items[$code] = [
'bag_code' => $code,
'bag_name' => trim((string) ($row['bisi_bag_name'] ?? $code)),
'qty' => (int) ($row['bisi_system_qty'] ?? 0),
'has_barcode' => true,
];
}
$items = array_values($items);
$boxRows = [];
$sheetRows = [];
$selectedBoxCode = trim((string) ($this->request->getGet('sel_box_code') ?? ''));
$selectedPackCode = trim((string) ($this->request->getGet('sel_pack_code') ?? ''));
if (is_array($selectedInspectionItem)) {
$this->ensureInspectionPackSnapshotForItem($lgIdx, $selectedInspectionItemId);
$bagCode = trim((string) ($selectedInspectionItem['bisi_bag_code'] ?? ''));
if ($bagCode !== '') {
$boxRowsAll = $db->table('bag_inventory_inspection_pack_snapshot')
->select('bisp_idx, bisp_box_code, bisp_pack_code, bisp_sheet_start_code, bisp_sheet_end_code, bisp_sheet_qty, bisp_actual_qty, bisp_diff_qty')
->where('bisp_lg_idx', $lgIdx)
->where('bisp_bisi_idx', $selectedInspectionItemId)
->where('bisp_bag_code', $bagCode)
->orderBy('bisp_sheet_qty', 'DESC')
->orderBy('bisp_idx', 'ASC')
->get()
->getResultArray();
foreach ($boxRowsAll as &$boxRow) {
$systemQty = max(0, (int) ($boxRow['bisp_sheet_qty'] ?? 0));
$actualRaw = $boxRow['bisp_actual_qty'] ?? null;
$actualQty = $actualRaw === null ? $systemQty : max(0, (int) $actualRaw);
// 화면 초기 표시값은 포장량/재고/실사재고를 동일하게 맞춘다.
$displayQty = $actualQty;
$boxRow['bisp_sheet_qty'] = $displayQty;
$boxRow['bisp_actual_qty'] = $displayQty;
$boxRow['bisp_diff_qty'] = 0;
}
unset($boxRow);
if ($selectedBoxCode !== '') {
$boxRows = array_values(array_filter(
$boxRowsAll,
static fn (array $row): bool => trim((string) ($row['bisp_box_code'] ?? '')) === $selectedBoxCode
));
} else {
$boxRows = $boxRowsAll;
}
}
if ($selectedBoxCode === '' && $boxRows !== []) {
$selectedBoxCode = (string) ($boxRows[0]['bisp_box_code'] ?? '');
$boxRows = array_values(array_filter(
$boxRows,
static fn (array $row): bool => trim((string) ($row['bisp_box_code'] ?? '')) === $selectedBoxCode
));
}
if ($selectedPackCode === '' && $boxRows !== []) {
$selectedPackCode = (string) ($boxRows[0]['bisp_pack_code'] ?? '');
}
foreach ($boxRows as $boxRow) {
$boxCode = (string) ($boxRow['bisp_box_code'] ?? '');
$packCode = (string) ($boxRow['bisp_pack_code'] ?? '');
if ($boxCode === '' || $packCode === '') {
continue;
}
if ($boxCode !== $selectedBoxCode || $packCode !== $selectedPackCode) {
continue;
}
$this->ensureInspectionSheetSnapshotForPack(
$lgIdx,
$selectedInspectionItemId,
$packCode,
(string) ($boxRow['bisp_sheet_start_code'] ?? ''),
(string) ($boxRow['bisp_sheet_end_code'] ?? '')
);
$sheetRows = $db->table('bag_inventory_inspection_sheet_snapshot')
->select('biss_idx, biss_sheet_code, biss_system_qty, biss_actual_qty, biss_diff_qty, biss_checked_yn')
->where('biss_lg_idx', $lgIdx)
->where('biss_bisi_idx', $selectedInspectionItemId)
->where('biss_pack_code', $packCode)
->orderBy('biss_sheet_code', 'ASC')
->get()
->getResultArray();
$n = 1;
foreach ($sheetRows as &$sr) {
$sr['no'] = $n++;
}
unset($sr);
break;
}
}
return $this->render('실사 선별 관리', 'bag/inventory_inspection_select', [
'startDate' => $startDate,
'endDate' => $endDate,
'workDate' => $workDate,
'itemCode' => $itemCode,
'viewType' => $viewType,
'inspectionRuns' => $inspectionRuns,
'items' => $items,
'popupItems' => $popupItems,
'overviewRows' => $overviewRows,
'selectedInspectionItemId' => $selectedInspectionItemId,
'selectedInspectionId' => $selectedInspectionId,
'boxRows' => $boxRows,
'selectedBoxCode' => $selectedBoxCode,
'selectedPackCode' => $selectedPackCode,
'sheetRows' => $sheetRows,
]);
}
/**
* @return list<array{no:int,sheet_code:string,qty:int}>
*/
private function expandInspectionRowsByBox(\CodeIgniter\Database\BaseConnection $db, int $lgIdx, array $overviewRows, bool $includeActual): array
{
if ($overviewRows === []) {
return [];
}
$itemIds = array_values(array_filter(array_map(
static fn (array $r): int => (int) ($r['bisi_idx'] ?? 0),
$overviewRows
)));
if ($itemIds === []) {
return $overviewRows;
}
$boxAggRows = $db->table('bag_inventory_inspection_pack_snapshot')
->select('bisp_bisi_idx, bisp_box_code, SUM(bisp_sheet_qty) AS sum_system, SUM(COALESCE(bisp_actual_qty,0)) AS sum_actual, SUM(COALESCE(bisp_diff_qty,0)) AS sum_diff', false)
->where('bisp_lg_idx', $lgIdx)
->whereIn('bisp_bisi_idx', $itemIds)
->groupBy('bisp_bisi_idx, bisp_box_code')
->orderBy('bisp_bisi_idx', 'ASC')
->orderBy('bisp_box_code', 'ASC')
->get()
->getResultArray();
$boxAggMap = [];
foreach ($boxAggRows as $bRow) {
$id = (int) ($bRow['bisp_bisi_idx'] ?? 0);
if ($id <= 0) {
continue;
}
$boxAggMap[$id][] = $bRow;
}
$expandedRows = [];
foreach ($overviewRows as $row) {
$itemId = (int) ($row['bisi_idx'] ?? 0);
$group = $boxAggMap[$itemId] ?? [];
if ($group === []) {
$row['box_code'] = '';
$expandedRows[] = $row;
continue;
}
foreach ($group as $g) {
$expanded = $row;
$expanded['box_code'] = trim((string) ($g['bisp_box_code'] ?? ''));
$expanded['bisi_total_system_qty'] = (int) ($row['bisi_system_qty'] ?? 0);
$expanded['bisi_system_qty'] = (int) ($g['sum_system'] ?? 0);
if ($includeActual) {
$expanded['bisi_actual_qty'] = (int) ($g['sum_actual'] ?? 0);
$expanded['bisi_diff_qty'] = (int) ($g['sum_diff'] ?? 0);
}
$expandedRows[] = $expanded;
}
}
return $expandedRows;
}
private function expandInspectionRowsByPack(\CodeIgniter\Database\BaseConnection $db, int $lgIdx, array $overviewRows, bool $includeActual): array
{
if ($overviewRows === []) {
return [];
}
$itemIds = array_values(array_filter(array_map(
static fn (array $r): int => (int) ($r['bisi_idx'] ?? 0),
$overviewRows
)));
if ($itemIds === []) {
return $overviewRows;
}
$packRows = $db->table('bag_inventory_inspection_pack_snapshot')
->select('bisp_bisi_idx, bisp_idx, bisp_box_code, bisp_sheet_qty, COALESCE(bisp_actual_qty,0) AS bisp_actual_qty, COALESCE(bisp_diff_qty,0) AS bisp_diff_qty', false)
->where('bisp_lg_idx', $lgIdx)
->whereIn('bisp_bisi_idx', $itemIds)
->orderBy('bisp_bisi_idx', 'ASC')
->orderBy('bisp_box_code', 'ASC')
->orderBy('bisp_idx', 'ASC')
->get()
->getResultArray();
$packMap = [];
foreach ($packRows as $pRow) {
$id = (int) ($pRow['bisp_bisi_idx'] ?? 0);
if ($id <= 0) {
continue;
}
$packMap[$id][] = $pRow;
}
$expandedRows = [];
foreach ($overviewRows as $row) {
$itemId = (int) ($row['bisi_idx'] ?? 0);
$group = $packMap[$itemId] ?? [];
if ($group === []) {
$row['box_code'] = '';
$expandedRows[] = $row;
continue;
}
foreach ($group as $g) {
$expanded = $row;
$expanded['box_code'] = trim((string) ($g['bisp_box_code'] ?? ''));
$expanded['bisi_total_system_qty'] = (int) ($row['bisi_system_qty'] ?? 0);
$expanded['bisi_system_qty'] = (int) ($g['bisp_sheet_qty'] ?? 0);
if ($includeActual) {
$expanded['bisi_actual_qty'] = (int) ($g['bisp_actual_qty'] ?? 0);
$expanded['bisi_diff_qty'] = (int) ($g['bisp_diff_qty'] ?? 0);
}
$expandedRows[] = $expanded;
}
}
return $expandedRows;
}
/**
* @return list<array{no:int,sheet_code:string,qty:int}>
*/
private function expandSheetCodes(string $startCode, string $endCode): array
{
$startCode = trim($startCode);
$endCode = trim($endCode);
if ($startCode === '' || $endCode === '') {
return [];
}
if (preg_match('/^(.*?)(\d+)$/', $startCode, $sm) !== 1 || preg_match('/^(.*?)(\d+)$/', $endCode, $em) !== 1) {
return [['no' => 1, 'sheet_code' => $startCode, 'qty' => 1]];
}
$startPrefix = (string) ($sm[1] ?? '');
$endPrefix = (string) ($em[1] ?? '');
$startNumRaw = (string) ($sm[2] ?? '');
$endNumRaw = (string) ($em[2] ?? '');
if ($startPrefix !== $endPrefix) {
return [['no' => 1, 'sheet_code' => $startCode, 'qty' => 1]];
}
$startNum = (int) $startNumRaw;
$endNum = (int) $endNumRaw;
if ($startNum <= 0 || $endNum < $startNum) {
return [['no' => 1, 'sheet_code' => $startCode, 'qty' => 1]];
}
$width = max(strlen($startNumRaw), strlen($endNumRaw));
$rows = [];
$no = 1;
for ($n = $startNum; $n <= $endNum; $n++) {
$rows[] = [
'no' => $no++,
'sheet_code' => $startPrefix . str_pad((string) $n, $width, '0', STR_PAD_LEFT),
'qty' => 1,
];
if ($no > 10000) {
break;
}
}
return $rows;
}
public function inspectionRun(): RedirectResponse
{
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
}
$this->ensureInventoryInspectionTables();
$this->ensureInspectionPackSnapshotTable();
$this->ensureInspectionSheetSnapshotTable();
$workDate = trim((string) ($this->request->getPost('work_date') ?? ''));
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $workDate)) {
return redirect()->back()->withInput()->with('error', '작업일자를 확인해 주세요.');
}
$selectedCodes = $this->request->getPost('bag_codes');
$selectedCodes = is_array($selectedCodes) ? array_values(array_unique(array_map(static fn ($v): string => trim((string) $v), $selectedCodes))) : [];
$selectedCodes = array_values(array_filter($selectedCodes, static fn ($v): bool => $v !== ''));
if ($selectedCodes === []) {
return redirect()->back()->withInput()->with('error', '실사 대상 품목을 선택해 주세요.');
}
$db = \Config\Database::connect();
$barcodeRows = $db->table('bag_receiving_pack_code')
->select('brpc_bag_code')
->distinct()
->where('brpc_lg_idx', $lgIdx)
->whereIn('brpc_bag_code', $selectedCodes)
->get()
->getResultArray();
$barcodeSet = [];
foreach ($barcodeRows as $row) {
$code = trim((string) ($row['brpc_bag_code'] ?? ''));
if ($code !== '') {
$barcodeSet[$code] = true;
}
}
$effectiveCodes = array_values(array_filter($selectedCodes, static fn ($code): bool => isset($barcodeSet[$code])));
if ($effectiveCodes === []) {
return redirect()->back()->withInput()->with('error', '바코드가 있는 품목만 실사 대상으로 선택할 수 있습니다.');
}
foreach ($effectiveCodes as $code) {
$this->ensureReceivingPackCodesForBag($lgIdx, $code);
}
$inventoryRows = $db->table('bag_inventory')
->select('bi_bag_code, bi_bag_name, bi_qty')
->where('bi_lg_idx', $lgIdx)
->whereIn('bi_bag_code', $effectiveCodes)
->orderBy('bi_bag_code', 'ASC')
->get()
->getResultArray();
if ($inventoryRows === []) {
return redirect()->back()->withInput()->with('error', '선택한 품목의 재고 데이터가 없습니다.');
}
$db->transStart();
$firstInspectionItemId = 0;
$db->table('bag_inventory_inspection')->insert([
'bis_lg_idx' => $lgIdx,
'bis_work_date' => $workDate,
'bis_status' => 'selected',
'bis_reg_mb_idx' => (int) (session()->get('mb_idx') ?? 0),
'bis_regdate' => date('Y-m-d H:i:s'),
'bis_moddate' => null,
]);
$inspectionId = (int) $db->insertID();
foreach ($inventoryRows as $row) {
$code = trim((string) ($row['bi_bag_code'] ?? ''));
if ($code === '' || ! isset($barcodeSet[$code])) {
continue;
}
$systemQty = (int) ($row['bi_qty'] ?? 0);
$db->table('bag_inventory_inspection_item')->insert([
'bisi_bis_idx' => $inspectionId,
'bisi_bag_code' => $code,
'bisi_bag_name' => trim((string) ($row['bi_bag_name'] ?? $code)),
'bisi_system_qty' => $systemQty,
'bisi_actual_qty' => null,
'bisi_diff_qty' => 0,
'bisi_has_barcode' => 'Y',
'bisi_apply_yn' => 'N',
]);
$inspectionItemId = (int) $db->insertID();
if ($firstInspectionItemId <= 0 && $inspectionItemId > 0) {
$firstInspectionItemId = $inspectionItemId;
}
}
$db->transComplete();
if (! $db->transStatus() || $inspectionId <= 0) {
return redirect()->back()->withInput()->with('error', '전산 선별 처리 중 오류가 발생했습니다.');
}
$query = http_build_query([
'start_date' => $workDate,
'end_date' => $workDate,
'bis_id' => $inspectionId,
'sel_item_id' => $firstInspectionItemId,
]);
return redirect()->to(site_url('bag/inventory/inspection-work?' . $query))
->with('success', '전산 선별 처리가 완료되었습니다.');
}
public function inspectionSelectSave(): RedirectResponse
{
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
}
$this->ensureInventoryInspectionTables();
$this->ensureInspectionPackSnapshotTable();
$this->ensureInspectionSheetSnapshotTable();
$inspectionItemId = (int) ($this->request->getPost('bisi_idx') ?? 0);
if ($inspectionItemId <= 0) {
return redirect()->back()->with('error', '실사 대상 품목이 올바르지 않습니다.');
}
$returnQuery = $this->inspectionReturnQueryFromPost($inspectionItemId);
$db = \Config\Database::connect();
$item = $db->table('bag_inventory_inspection_item')
->where('bisi_idx', $inspectionItemId)
->get()
->getRowArray();
if (! is_array($item) || (int) ($item['bisi_idx'] ?? 0) <= 0) {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '실사 대상 품목을 찾을 수 없습니다.');
}
$requestInspectionId = (int) ($this->request->getPost('bis_id') ?? 0);
$itemInspectionId = (int) ($item['bisi_bis_idx'] ?? 0);
if ($requestInspectionId > 0 && $requestInspectionId !== $itemInspectionId) {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '선택한 실사 작업과 품목 정보가 일치하지 않습니다.');
}
$header = $db->table('bag_inventory_inspection')
->select('bis_idx')
->where('bis_idx', $itemInspectionId)
->where('bis_lg_idx', $lgIdx)
->get()
->getRowArray();
if (! is_array($header) || (int) ($header['bis_idx'] ?? 0) <= 0) {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '실사 작업 정보가 올바르지 않습니다.');
}
$actualInput = $this->request->getPost('pack_actual_qty');
$actualInput = is_array($actualInput) ? $actualInput : [];
$actualJson = trim((string) ($this->request->getPost('pack_actual_json') ?? ''));
$actualFromJson = false;
if ($actualJson !== '') {
$decoded = json_decode($actualJson, true);
if (is_array($decoded)) {
$actualInput = [];
foreach ($decoded as $k => $v) {
$key = trim((string) $k);
if ($key === '' || ! ctype_digit($key)) {
continue;
}
$actualInput[$key] = max(0, (int) $v);
}
$actualFromJson = true;
}
}
if ($actualFromJson && $actualInput === []) {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '저장할 실사 수량(JSON)이 비어 있습니다. 다시 시도해 주세요.');
}
if (! $actualFromJson && $actualInput === []) {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '저장할 실사 수량이 없습니다. 수량을 변경한 뒤 다시 저장해 주세요.');
}
$snapshotRows = $db->table('bag_inventory_inspection_pack_snapshot')
->select('bisp_idx, bisp_bag_code, bisp_pack_code, bisp_sheet_start_code, bisp_sheet_end_code, bisp_sheet_qty, bisp_actual_qty')
->where('bisp_lg_idx', $lgIdx)
->where('bisp_bisi_idx', $inspectionItemId)
->orderBy('bisp_idx', 'ASC')
->get()
->getResultArray();
if ($snapshotRows === []) {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '실사 팩 스냅샷이 없습니다.');
}
$db->transStart();
$sumActual = 0;
$packUpdates = [];
$changedPackQtyMap = [];
$bagCodeForSync = trim((string) ($item['bisi_bag_code'] ?? ''));
$capacityMap = [];
if ($bagCodeForSync !== '') {
$capacityRows = $db->table('bag_receiving_pack_code')
->select('brpc_pack_code, brpc_sheet_qty')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_bag_code', $bagCodeForSync)
->where('brpc_pack_code !=', '')
->get()
->getResultArray();
foreach ($capacityRows as $cRow) {
$packCode = trim((string) ($cRow['brpc_pack_code'] ?? ''));
if ($packCode === '') {
continue;
}
$capacityMap[$packCode] = max(0, (int) ($cRow['brpc_sheet_qty'] ?? 0));
}
}
foreach ($snapshotRows as $row) {
$idx = (int) ($row['bisp_idx'] ?? 0);
if ($idx <= 0) {
continue;
}
$systemQty = max(0, (int) ($row['bisp_sheet_qty'] ?? 0));
$existingActualRaw = $row['bisp_actual_qty'] ?? null;
$existingActual = $existingActualRaw === null ? $systemQty : max(0, (int) $existingActualRaw);
$key = (string) $idx;
$actualQty = array_key_exists($key, $actualInput)
? max(0, (int) $actualInput[$key])
: $existingActual;
$packCode = trim((string) ($row['bisp_pack_code'] ?? ''));
$startCode = trim((string) ($row['bisp_sheet_start_code'] ?? ''));
$currentEndCode = trim((string) ($row['bisp_sheet_end_code'] ?? ''));
if ($packCode !== '' && isset($capacityMap[$packCode])) {
$maxQty = (int) ($capacityMap[$packCode] ?? 0);
if ($maxQty > 0 && $actualQty > $maxQty) {
$db->transRollback();
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '팩 ' . $packCode . '의 허용 수량(' . number_format($maxQty) . '장)을 초과했습니다.');
}
}
$nextEndCode = $this->resolveSheetEndCodeByQty($startCode, $currentEndCode, $actualQty);
$sumActual += $actualQty;
if (! array_key_exists($key, $actualInput)) {
continue;
}
if ($packCode !== '' && $actualQty !== $existingActual) {
$changedPackQtyMap[$packCode] = [
'qty' => $actualQty,
'end_code' => $nextEndCode,
];
}
$packUpdates[] = [
'bisp_idx' => $idx,
'bisp_sheet_qty' => $actualQty,
'bisp_actual_qty' => $actualQty,
'bisp_diff_qty' => 0,
'bisp_sheet_end_code' => $nextEndCode,
'bisp_checked_yn' => 'Y',
];
}
if ($packUpdates !== []) {
$chunk = 500;
$count = count($packUpdates);
for ($i = 0; $i < $count; $i += $chunk) {
$slice = array_slice($packUpdates, $i, $chunk);
$db->table('bag_inventory_inspection_pack_snapshot')->updateBatch($slice, 'bisp_idx');
}
}
// 같은 봉투코드/팩코드는 다른 실사작업에서도 동일 실사값으로 보이도록 동기화
// (요구사항: 48에서 12로 저장하면 47에서도 12로 조회)
if ($bagCodeForSync !== '' && $changedPackQtyMap !== []) {
foreach ($changedPackQtyMap as $packCode => $meta) {
$qty = max(0, (int) ($meta['qty'] ?? 0));
$endCode = (string) ($meta['end_code'] ?? '');
$db->table('bag_inventory_inspection_pack_snapshot')
->where('bisp_lg_idx', $lgIdx)
->where('bisp_bag_code', $bagCodeForSync)
->where('bisp_pack_code', $packCode)
->update([
'bisp_sheet_qty' => $qty,
'bisp_actual_qty' => $qty,
'bisp_diff_qty' => 0,
'bisp_sheet_end_code' => $endCode,
'bisp_checked_yn' => 'Y',
]);
}
}
$systemQty = max(0, (int) ($item['bisi_system_qty'] ?? 0));
$newDiff = $sumActual - $systemQty;
$prevDiff = (int) ($item['bisi_diff_qty'] ?? 0);
$alreadyApplied = (string) ($item['bisi_apply_yn'] ?? 'N') === 'Y';
$applyDelta = $alreadyApplied ? ($newDiff - $prevDiff) : $newDiff;
$invModel = model(BagInventoryModel::class);
if ($applyDelta !== 0) {
$invModel->adjustQty(
$lgIdx,
(string) ($item['bisi_bag_code'] ?? ''),
(string) ($item['bisi_bag_name'] ?? ''),
$applyDelta
);
}
$db->table('bag_inventory_inspection_item')
->where('bisi_idx', $inspectionItemId)
->update([
'bisi_system_qty' => $sumActual,
'bisi_actual_qty' => $sumActual,
'bisi_diff_qty' => 0,
'bisi_apply_yn' => 'Y',
]);
$inspectionId = (int) ($item['bisi_bis_idx'] ?? 0);
$remain = $db->table('bag_inventory_inspection_item')
->where('bisi_bis_idx', $inspectionId)
->where('bisi_apply_yn', 'N')
->countAllResults();
$db->table('bag_inventory_inspection')
->where('bis_idx', $inspectionId)
->update([
'bis_status' => ($remain === 0) ? 'confirmed' : 'counting',
'bis_moddate' => date('Y-m-d H:i:s'),
]);
$db->transComplete();
if (! $db->transStatus()) {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '실사 저장 중 오류가 발생했습니다.');
}
$savedItem = $db->table('bag_inventory_inspection_item')
->select('bisi_system_qty, bisi_actual_qty, bisi_apply_yn')
->where('bisi_idx', $inspectionItemId)
->get()
->getRowArray();
if (
! is_array($savedItem)
|| (string) ($savedItem['bisi_apply_yn'] ?? 'N') !== 'Y'
|| (int) ($savedItem['bisi_actual_qty'] ?? -1) !== $sumActual
) {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '실사 저장 검증에 실패했습니다. 다시 저장해 주세요.');
}
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('success', '실사 저장 완료 (합계: ' . number_format($sumActual) . '장)');
}
public function inspectionSelectConfirm(): RedirectResponse
{
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
}
$inspectionItemId = (int) ($this->request->getPost('bisi_idx') ?? 0);
if ($inspectionItemId <= 0) {
return redirect()->back()->with('error', '실사 대상 품목이 올바르지 않습니다.');
}
$returnQuery = $this->inspectionReturnQueryFromPost($inspectionItemId);
$db = \Config\Database::connect();
$item = $db->table('bag_inventory_inspection_item')
->where('bisi_idx', $inspectionItemId)
->get()
->getRowArray();
if (! is_array($item)) {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '실사 대상 품목을 찾을 수 없습니다.');
}
$requestInspectionId = (int) ($this->request->getPost('bis_id') ?? 0);
$itemInspectionId = (int) ($item['bisi_bis_idx'] ?? 0);
if ($requestInspectionId > 0 && $requestInspectionId !== $itemInspectionId) {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '선택한 실사 작업과 품목 정보가 일치하지 않습니다.');
}
$header = $db->table('bag_inventory_inspection')
->select('bis_idx')
->where('bis_idx', $itemInspectionId)
->where('bis_lg_idx', $lgIdx)
->get()
->getRowArray();
if (! is_array($header) || (int) ($header['bis_idx'] ?? 0) <= 0) {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '실사 작업 정보가 올바르지 않습니다.');
}
if ((string) ($item['bisi_apply_yn'] ?? 'N') === 'Y') {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '이미 확정된 실사 품목입니다.');
}
$actualQty = $item['bisi_actual_qty'];
if ($actualQty === null) {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '먼저 실사 수량을 저장해 주세요.');
}
$diff = (int) ($item['bisi_diff_qty'] ?? 0);
$invModel = model(BagInventoryModel::class);
$db->transStart();
if ($diff !== 0) {
$invModel->adjustQty(
$lgIdx,
(string) ($item['bisi_bag_code'] ?? ''),
(string) ($item['bisi_bag_name'] ?? ''),
$diff
);
}
$db->table('bag_inventory_inspection_item')
->where('bisi_idx', $inspectionItemId)
->update(['bisi_apply_yn' => 'Y']);
$inspectionId = (int) ($item['bisi_bis_idx'] ?? 0);
$remain = $db->table('bag_inventory_inspection_item')
->where('bisi_bis_idx', $inspectionId)
->where('bisi_apply_yn', 'N')
->countAllResults();
$db->table('bag_inventory_inspection')
->where('bis_idx', $inspectionId)
->update([
'bis_status' => ($remain === 0) ? 'confirmed' : 'counting',
'bis_moddate' => date('Y-m-d H:i:s'),
]);
$db->transComplete();
if (! $db->transStatus()) {
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('error', '실사 확정 중 오류가 발생했습니다.');
}
return redirect()->to(site_url('bag/inventory/inspection-work?' . $returnQuery))
->with('success', '실사 결과가 재고에 반영되었습니다.');
}
private function inspectionReturnQueryFromPost(int $fallbackItemId): string
{
$startDate = trim((string) ($this->request->getPost('start_date') ?? ''));
$endDate = trim((string) ($this->request->getPost('end_date') ?? ''));
$bisId = (int) ($this->request->getPost('bis_id') ?? 0);
$itemCode = trim((string) ($this->request->getPost('item_code') ?? ''));
$viewType = trim((string) ($this->request->getPost('view_type') ?? 'box'));
$selItemId = (int) ($this->request->getPost('sel_item_id') ?? $fallbackItemId);
$selBoxCode = trim((string) ($this->request->getPost('sel_box_code') ?? ''));
$selPackCode = trim((string) ($this->request->getPost('sel_pack_code') ?? ''));
return http_build_query([
'start_date' => $startDate,
'end_date' => $endDate,
'bis_id' => $bisId,
'item_code' => $itemCode,
'view_type' => $viewType,
'sel_item_id' => $selItemId > 0 ? $selItemId : $fallbackItemId,
'sel_box_code' => $selBoxCode,
'sel_pack_code' => $selPackCode,
]);
}
public function inspectionDetail(int $id): string|RedirectResponse
{
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
}
$this->ensureInventoryInspectionTables();
$db = \Config\Database::connect();
$inspection = $db->table('bag_inventory_inspection')
->where('bis_idx', $id)
->where('bis_lg_idx', $lgIdx)
->get()
->getRowArray();
if (! is_array($inspection)) {
return redirect()->to(site_url('bag/inventory/inspection-select'))->with('error', '실사 작업을 찾을 수 없습니다.');
}
$items = $db->table('bag_inventory_inspection_item')
->where('bisi_bis_idx', $id)
->orderBy('bisi_bag_code', 'ASC')
->get()
->getResultArray();
return $this->render('실사 조회', 'bag/inventory_inspection_detail', [
'inspection' => $inspection,
'items' => $items,
]);
}
public function inspectionSave(int $id): RedirectResponse
{
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
}
$this->ensureInventoryInspectionTables();
$db = \Config\Database::connect();
$inspection = $db->table('bag_inventory_inspection')
->where('bis_idx', $id)
->where('bis_lg_idx', $lgIdx)
->get()
->getRowArray();
if (! is_array($inspection)) {
return redirect()->to(site_url('bag/inventory/inspection-select'))->with('error', '실사 작업을 찾을 수 없습니다.');
}
$actualQtyInput = $this->request->getPost('actual_qty');
$actualQtyInput = is_array($actualQtyInput) ? $actualQtyInput : [];
if ($actualQtyInput === []) {
return redirect()->back()->with('error', '실사 수량을 입력해 주세요.');
}
$itemIds = array_values(array_unique(array_map('intval', array_keys($actualQtyInput))));
$itemIds = array_values(array_filter($itemIds, static fn ($v): bool => $v > 0));
if ($itemIds === []) {
return redirect()->back()->with('error', '실사 수량 입력 대상이 없습니다.');
}
$rows = $db->table('bag_inventory_inspection_item')
->where('bisi_bis_idx', $id)
->whereIn('bisi_idx', $itemIds)
->get()
->getResultArray();
$rowMap = [];
foreach ($rows as $r) {
$rowMap[(int) ($r['bisi_idx'] ?? 0)] = $r;
}
$db->transStart();
foreach ($itemIds as $itemId) {
if (! isset($rowMap[$itemId])) {
continue;
}
$systemQty = (int) ($rowMap[$itemId]['bisi_system_qty'] ?? 0);
$actualQty = max(0, (int) ($actualQtyInput[(string) $itemId] ?? 0));
$diffQty = $actualQty - $systemQty;
$db->table('bag_inventory_inspection_item')
->where('bisi_idx', $itemId)
->update([
'bisi_actual_qty' => $actualQty,
'bisi_diff_qty' => $diffQty,
]);
}
$db->table('bag_inventory_inspection')
->where('bis_idx', $id)
->update([
'bis_status' => 'counted',
'bis_moddate' => date('Y-m-d H:i:s'),
]);
$db->transComplete();
if (! $db->transStatus()) {
return redirect()->back()->with('error', '실사 저장 중 오류가 발생했습니다.');
}
return redirect()->back()->with('success', '실사 수량이 저장되었습니다.');
}
public function inspectionApply(int $id): RedirectResponse
{
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
}
$this->ensureInventoryInspectionTables();
$db = \Config\Database::connect();
$inspection = $db->table('bag_inventory_inspection')
->where('bis_idx', $id)
->where('bis_lg_idx', $lgIdx)
->get()
->getRowArray();
if (! is_array($inspection)) {
return redirect()->to(site_url('bag/inventory/inspection-select'))->with('error', '실사 작업을 찾을 수 없습니다.');
}
$items = $db->table('bag_inventory_inspection_item')
->where('bisi_bis_idx', $id)
->where('bisi_actual_qty IS NOT NULL', null, false)
->where('bisi_apply_yn', 'N')
->get()
->getResultArray();
if ($items === []) {
return redirect()->back()->with('error', '재고 반영할 실사 데이터가 없습니다.');
}
$invModel = model(BagInventoryModel::class);
$db->transStart();
foreach ($items as $item) {
$diff = (int) ($item['bisi_diff_qty'] ?? 0);
if ($diff !== 0) {
$invModel->adjustQty(
$lgIdx,
(string) ($item['bisi_bag_code'] ?? ''),
(string) ($item['bisi_bag_name'] ?? ''),
$diff
);
}
$db->table('bag_inventory_inspection_item')
->where('bisi_idx', (int) ($item['bisi_idx'] ?? 0))
->update(['bisi_apply_yn' => 'Y']);
}
$db->table('bag_inventory_inspection')
->where('bis_idx', $id)
->update([
'bis_status' => 'applied',
'bis_moddate' => date('Y-m-d H:i:s'),
]);
$db->transComplete();
if (! $db->transStatus()) {
return redirect()->back()->with('error', '재고 반영 중 오류가 발생했습니다.');
}
return redirect()->back()->with('success', '실사 결과가 재고에 반영되었습니다.');
}
private function ensureReceivingPackCodeTableAndBackfill(int $lgIdx): void
{
$db = \Config\Database::connect();
if (! $db->tableExists('bag_receiving_pack_code')) {
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `bag_receiving_pack_code` (
`brpc_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`brpc_br_idx` INT UNSIGNED NOT NULL,
`brpc_lg_idx` INT UNSIGNED NOT NULL,
`brpc_bag_code` VARCHAR(50) NOT NULL,
`brpc_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
`brpc_lot_no` VARCHAR(50) NOT NULL DEFAULT '',
`brpc_box_code` VARCHAR(80) NOT NULL DEFAULT '',
`brpc_pack_code` VARCHAR(80) NOT NULL,
`brpc_sheet_start_code` VARCHAR(120) NOT NULL,
`brpc_sheet_end_code` VARCHAR(120) NOT NULL,
`brpc_sheet_qty` INT UNSIGNED NOT NULL DEFAULT 0,
`brpc_state` VARCHAR(20) NOT NULL DEFAULT 'in_stock',
`brpc_regdate` DATETIME NOT NULL,
PRIMARY KEY (`brpc_idx`),
UNIQUE KEY `uk_brpc_pack_code` (`brpc_pack_code`),
KEY `idx_brpc_br_idx` (`brpc_br_idx`),
KEY `idx_brpc_lg_bag` (`brpc_lg_idx`,`brpc_bag_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
$unitRows = model(PackagingUnitModel::class)
->where('pu_lg_idx', $lgIdx)
->where('pu_state', 1)
->findAll();
$unitMap = [];
foreach ($unitRows as $unit) {
$unitMap[(string) ($unit->pu_bag_code ?? '')] = [
'pack_per_sheet' => max(1, (int) ($unit->pu_pack_per_sheet ?? 1)),
'total_per_box' => max(1, (int) ($unit->pu_total_per_box ?? 1)),
];
}
while (true) {
$missingRows = $db->table('bag_receiving r')
->select('r.br_idx, r.br_bo_idx, r.br_bag_code, r.br_bag_name, r.br_qty_sheet, o.bo_lot_no')
->join('bag_order o', 'o.bo_idx = r.br_bo_idx', 'left')
->join('bag_receiving_pack_code c', 'c.brpc_br_idx = r.br_idx', 'left')
->where('r.br_lg_idx', $lgIdx)
->where('c.brpc_idx IS NULL', null, false)
->orderBy('r.br_idx', 'ASC')
->limit(500)
->get()
->getResultArray();
if ($missingRows === []) {
break;
}
foreach ($missingRows as $row) {
$bagCode = (string) ($row['br_bag_code'] ?? '');
$unit = $unitMap[$bagCode] ?? ['pack_per_sheet' => 1, 'total_per_box' => 1];
$this->createReceivingPackCodes(
$lgIdx,
(int) ($row['br_idx'] ?? 0),
(int) ($row['br_bo_idx'] ?? 0),
$bagCode,
(string) ($row['br_bag_name'] ?? ''),
(int) ($row['br_qty_sheet'] ?? 0),
(int) ($unit['pack_per_sheet'] ?? 1),
(int) ($unit['total_per_box'] ?? 1),
(string) ($row['bo_lot_no'] ?? '')
);
}
}
}
private function ensureReceivingPackCodesForBag(int $lgIdx, string $bagCode): void
{
$bagCode = trim($bagCode);
if ($lgIdx <= 0 || $bagCode === '') {
return;
}
$db = \Config\Database::connect();
if (! $db->tableExists('bag_receiving_pack_code')) {
return;
}
$unit = model(PackagingUnitModel::class)
->where('pu_lg_idx', $lgIdx)
->where('pu_state', 1)
->where('pu_bag_code', $bagCode)
->first();
$packPerSheet = max(1, (int) ($unit->pu_pack_per_sheet ?? 1));
$totalPerBox = max(1, (int) ($unit->pu_total_per_box ?? 1));
while (true) {
$missingRows = $db->table('bag_receiving r')
->select('r.br_idx, r.br_bo_idx, r.br_bag_code, r.br_bag_name, r.br_qty_sheet, o.bo_lot_no')
->join('bag_order o', 'o.bo_idx = r.br_bo_idx', 'left')
->join('bag_receiving_pack_code c', 'c.brpc_br_idx = r.br_idx', 'left')
->where('r.br_lg_idx', $lgIdx)
->where('r.br_bag_code', $bagCode)
->where('c.brpc_idx IS NULL', null, false)
->orderBy('r.br_idx', 'ASC')
->limit(200)
->get()
->getResultArray();
if ($missingRows === []) {
break;
}
foreach ($missingRows as $row) {
$this->createReceivingPackCodes(
$lgIdx,
(int) ($row['br_idx'] ?? 0),
(int) ($row['br_bo_idx'] ?? 0),
(string) ($row['br_bag_code'] ?? ''),
(string) ($row['br_bag_name'] ?? ''),
(int) ($row['br_qty_sheet'] ?? 0),
$packPerSheet,
$totalPerBox,
(string) ($row['bo_lot_no'] ?? '')
);
}
}
}
private function createReceivingPackCodes(
int $lgIdx,
int $brIdx,
int $boIdx,
string $bagCode,
string $bagName,
int $qtySheet,
int $packPerSheet,
int $totalPerBox,
string $lotNo = ''
): void {
if ($brIdx <= 0 || $qtySheet <= 0 || $bagCode === '') {
return;
}
$db = \Config\Database::connect();
if (! $db->tableExists('bag_receiving_pack_code')) {
return;
}
$exists = $db->table('bag_receiving_pack_code')
->where('brpc_br_idx', $brIdx)
->countAllResults();
if ($exists > 0) {
return;
}
$lotNo = trim($lotNo);
if ($lotNo === '' && $boIdx > 0) {
$order = model(BagOrderModel::class)->find($boIdx);
$lotNo = trim((string) ($order->bo_lot_no ?? ''));
}
if ($lotNo === '') {
$lotNo = $bagCode;
}
$packPerSheet = max(1, $packPerSheet);
$totalPerBox = max(1, $totalPerBox);
$packsPerBox = max(1, intdiv($totalPerBox, $packPerSheet));
$packCount = (int) ceil($qtySheet / $packPerSheet);
$sheetCursor = 1;
$regdate = date('Y-m-d H:i:s');
$rows = [];
for ($packSeq = 1; $packSeq <= $packCount; $packSeq++) {
$boxSeq = (int) ceil($packSeq / $packsPerBox);
$sheetQty = min($packPerSheet, max(0, $qtySheet - (($packSeq - 1) * $packPerSheet)));
if ($sheetQty <= 0) {
break;
}
$sheetStartNo = $sheetCursor;
$sheetEndNo = $sheetCursor + $sheetQty - 1;
$sheetCursor = $sheetEndNo + 1;
$boxCode = sprintf('%s-%06d-B%03d', $lotNo, $brIdx, $boxSeq);
$packCode = sprintf('%s-%06d-P%03d', $lotNo, $brIdx, $packSeq);
$startCode = sprintf('%s-S%05d', $packCode, $sheetStartNo);
$endCode = sprintf('%s-S%05d', $packCode, $sheetEndNo);
$rows[] = [
'brpc_br_idx' => $brIdx,
'brpc_lg_idx' => $lgIdx,
'brpc_bag_code' => $bagCode,
'brpc_bag_name' => $bagName !== '' ? $bagName : $bagCode,
'brpc_lot_no' => $lotNo,
'brpc_box_code' => $boxCode,
'brpc_pack_code' => $packCode,
'brpc_sheet_start_code' => $startCode,
'brpc_sheet_end_code' => $endCode,
'brpc_sheet_qty' => $sheetQty,
'brpc_state' => 'in_stock',
'brpc_regdate' => $regdate,
];
}
if ($rows !== []) {
$db->table('bag_receiving_pack_code')->insertBatch($rows);
}
}
private function ensureInspectionPackSnapshotTable(): void
{
$db = \Config\Database::connect();
if ($db->tableExists('bag_inventory_inspection_pack_snapshot')) {
return;
}
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `bag_inventory_inspection_pack_snapshot` (
`bisp_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`bisp_bisi_idx` INT UNSIGNED NOT NULL,
`bisp_lg_idx` INT UNSIGNED NOT NULL,
`bisp_bag_code` VARCHAR(50) NOT NULL,
`bisp_box_code` VARCHAR(80) NOT NULL DEFAULT '',
`bisp_pack_code` VARCHAR(80) NOT NULL,
`bisp_sheet_start_code` VARCHAR(120) NOT NULL,
`bisp_sheet_end_code` VARCHAR(120) NOT NULL,
`bisp_sheet_qty` INT UNSIGNED NOT NULL DEFAULT 0,
`bisp_actual_qty` INT UNSIGNED NULL DEFAULT NULL,
`bisp_diff_qty` INT NOT NULL DEFAULT 0,
`bisp_checked_yn` CHAR(1) NOT NULL DEFAULT 'N',
`bisp_regdate` DATETIME NOT NULL,
PRIMARY KEY (`bisp_idx`),
UNIQUE KEY `uk_bisp_item_pack` (`bisp_bisi_idx`,`bisp_pack_code`),
KEY `idx_bisp_item` (`bisp_bisi_idx`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
private function ensureInspectionSheetSnapshotTable(): void
{
$db = \Config\Database::connect();
if ($db->tableExists('bag_inventory_inspection_sheet_snapshot')) {
return;
}
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `bag_inventory_inspection_sheet_snapshot` (
`biss_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`biss_bisi_idx` INT UNSIGNED NOT NULL,
`biss_lg_idx` INT UNSIGNED NOT NULL,
`biss_pack_code` VARCHAR(80) NOT NULL,
`biss_sheet_code` VARCHAR(120) NOT NULL,
`biss_system_qty` INT UNSIGNED NOT NULL DEFAULT 1,
`biss_actual_qty` INT UNSIGNED NULL DEFAULT NULL,
`biss_diff_qty` INT NOT NULL DEFAULT 0,
`biss_checked_yn` CHAR(1) NOT NULL DEFAULT 'N',
`biss_regdate` DATETIME NOT NULL,
PRIMARY KEY (`biss_idx`),
UNIQUE KEY `uk_biss_item_sheet` (`biss_bisi_idx`,`biss_sheet_code`),
KEY `idx_biss_item_pack` (`biss_bisi_idx`,`biss_pack_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
private function ensureInspectionPackSnapshotForItem(int $lgIdx, int $inspectionItemId, bool $forceRebuild = false): void
{
if ($inspectionItemId <= 0) {
return;
}
$db = \Config\Database::connect();
if (! $db->tableExists('bag_inventory_inspection_pack_snapshot')) {
return;
}
$item = $db->table('bag_inventory_inspection_item')
->where('bisi_idx', $inspectionItemId)
->where('bisi_has_barcode', 'Y')
->get()
->getRowArray();
if (! is_array($item)) {
return;
}
$bagCode = trim((string) ($item['bisi_bag_code'] ?? ''));
if ($bagCode === '') {
return;
}
$this->ensureReceivingPackCodesForBag($lgIdx, $bagCode);
$sourceRows = $db->table('bag_receiving_pack_code')
->select('brpc_box_code, brpc_pack_code, brpc_sheet_start_code, brpc_sheet_end_code, brpc_sheet_qty')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_bag_code', $bagCode)
->where('brpc_state', 'in_stock')
->orderBy('brpc_box_code', 'ASC')
->orderBy('brpc_pack_code', 'ASC')
->get()
->getResultArray();
if ($sourceRows === []) {
return;
}
$existingSnapshot = $db->table('bag_inventory_inspection_pack_snapshot')
->select('COUNT(*) AS row_cnt', false)
->where('bisp_bisi_idx', $inspectionItemId)
->get()
->getRowArray();
$existingCount = (int) ($existingSnapshot['row_cnt'] ?? 0);
// 실사 저장 이후에는 사용자가 수정한 수량을 유지해야 하므로
// 강제 재생성이 아니면 기존 스냅샷이 존재할 때 재생성하지 않는다.
if (! $forceRebuild && $existingCount > 0) {
$this->applyLatestPackAdjustmentsToSnapshot($db, $lgIdx, $inspectionItemId, $bagCode);
return;
}
// 스냅샷은 선택 품목의 현재 in_stock 전체 팩/박스를 기준으로 재생성한다.
$db->table('bag_inventory_inspection_pack_snapshot')
->where('bisp_bisi_idx', $inspectionItemId)
->delete();
$insertRows = [];
$now = date('Y-m-d H:i:s');
foreach ($sourceRows as $src) {
$rowQty = max(0, (int) ($src['brpc_sheet_qty'] ?? 0));
if ($rowQty <= 0) {
continue;
}
$startCode = (string) ($src['brpc_sheet_start_code'] ?? '');
$endCode = (string) ($src['brpc_sheet_end_code'] ?? '');
$insertRows[] = [
'bisp_bisi_idx' => $inspectionItemId,
'bisp_lg_idx' => $lgIdx,
'bisp_bag_code' => $bagCode,
'bisp_box_code' => (string) ($src['brpc_box_code'] ?? ''),
'bisp_pack_code' => (string) ($src['brpc_pack_code'] ?? ''),
'bisp_sheet_start_code' => $startCode,
'bisp_sheet_end_code' => $endCode,
'bisp_sheet_qty' => $rowQty,
'bisp_actual_qty' => $rowQty,
'bisp_diff_qty' => 0,
'bisp_checked_yn' => 'N',
'bisp_regdate' => $now,
];
}
if ($insertRows !== []) {
$db->table('bag_inventory_inspection_pack_snapshot')->insertBatch($insertRows);
}
$this->applyLatestPackAdjustmentsToSnapshot($db, $lgIdx, $inspectionItemId, $bagCode);
}
private function applyLatestPackAdjustmentsToSnapshot(
\CodeIgniter\Database\BaseConnection $db,
int $lgIdx,
int $inspectionItemId,
string $bagCode
): void {
$bagCode = trim($bagCode);
if ($inspectionItemId <= 0 || $bagCode === '') {
return;
}
$latestRows = $db->table('bag_inventory_inspection_pack_snapshot')
->select('bisp_pack_code, MAX(bisp_idx) AS latest_idx', false)
->where('bisp_lg_idx', $lgIdx)
->where('bisp_bag_code', $bagCode)
->where('bisp_checked_yn', 'Y')
->where('bisp_pack_code !=', '')
->groupBy('bisp_pack_code')
->get()
->getResultArray();
if ($latestRows === []) {
return;
}
$latestIdxList = [];
foreach ($latestRows as $row) {
$latestIdx = (int) ($row['latest_idx'] ?? 0);
if ($latestIdx > 0) {
$latestIdxList[] = $latestIdx;
}
}
if ($latestIdxList === []) {
return;
}
$latestValues = $db->table('bag_inventory_inspection_pack_snapshot')
->select('bisp_pack_code, bisp_sheet_qty')
->whereIn('bisp_idx', $latestIdxList)
->get()
->getResultArray();
if ($latestValues === []) {
return;
}
$latestMap = [];
foreach ($latestValues as $row) {
$packCode = trim((string) ($row['bisp_pack_code'] ?? ''));
if ($packCode === '') {
continue;
}
$latestMap[$packCode] = [
'qty' => max(0, (int) ($row['bisp_sheet_qty'] ?? 0)),
];
}
if ($latestMap === []) {
return;
}
$currentRows = $db->table('bag_inventory_inspection_pack_snapshot')
->select('bisp_idx, bisp_pack_code, bisp_sheet_start_code, bisp_sheet_end_code, bisp_sheet_qty')
->where('bisp_lg_idx', $lgIdx)
->where('bisp_bisi_idx', $inspectionItemId)
->where('bisp_bag_code', $bagCode)
->get()
->getResultArray();
if ($currentRows === []) {
return;
}
$updates = [];
foreach ($currentRows as $row) {
$packCode = trim((string) ($row['bisp_pack_code'] ?? ''));
if ($packCode === '' || ! isset($latestMap[$packCode])) {
continue;
}
$targetQty = (int) ($latestMap[$packCode]['qty'] ?? 0);
$currentQty = max(0, (int) ($row['bisp_sheet_qty'] ?? 0));
$startCode = trim((string) ($row['bisp_sheet_start_code'] ?? ''));
$currentEndCode = trim((string) ($row['bisp_sheet_end_code'] ?? ''));
$targetEndCode = $this->resolveSheetEndCodeByQty($startCode, $currentEndCode, $targetQty);
if ($targetQty === $currentQty && $targetEndCode === $currentEndCode) {
continue;
}
$updates[] = [
'bisp_idx' => (int) ($row['bisp_idx'] ?? 0),
'bisp_sheet_qty' => $targetQty,
'bisp_actual_qty' => $targetQty,
'bisp_diff_qty' => 0,
'bisp_sheet_end_code' => $targetEndCode,
'bisp_checked_yn' => 'Y',
];
}
if ($updates !== []) {
$db->table('bag_inventory_inspection_pack_snapshot')->updateBatch($updates, 'bisp_idx');
}
}
private function ensureInspectionSheetSnapshotForItem(int $lgIdx, int $inspectionItemId): void
{
if ($inspectionItemId <= 0) {
return;
}
$db = \Config\Database::connect();
if (! $db->tableExists('bag_inventory_inspection_sheet_snapshot')) {
return;
}
$exists = $db->table('bag_inventory_inspection_sheet_snapshot')
->where('biss_bisi_idx', $inspectionItemId)
->countAllResults();
if ($exists > 0) {
return;
}
$packRows = $db->table('bag_inventory_inspection_pack_snapshot')
->select('bisp_pack_code, bisp_sheet_start_code, bisp_sheet_end_code')
->where('bisp_lg_idx', $lgIdx)
->where('bisp_bisi_idx', $inspectionItemId)
->orderBy('bisp_idx', 'ASC')
->get()
->getResultArray();
if ($packRows === []) {
return;
}
$insertRows = [];
$now = date('Y-m-d H:i:s');
foreach ($packRows as $packRow) {
$packCode = (string) ($packRow['bisp_pack_code'] ?? '');
if ($packCode === '') {
continue;
}
$codes = $this->expandSheetCodes(
(string) ($packRow['bisp_sheet_start_code'] ?? ''),
(string) ($packRow['bisp_sheet_end_code'] ?? '')
);
foreach ($codes as $codeRow) {
$sheetCode = (string) ($codeRow['sheet_code'] ?? '');
if ($sheetCode === '') {
continue;
}
$insertRows[] = [
'biss_bisi_idx' => $inspectionItemId,
'biss_lg_idx' => $lgIdx,
'biss_pack_code' => $packCode,
'biss_sheet_code' => $sheetCode,
'biss_system_qty' => 1,
'biss_actual_qty' => null,
'biss_diff_qty' => 0,
'biss_checked_yn' => 'N',
'biss_regdate' => $now,
];
if (count($insertRows) >= 1000) {
$db->table('bag_inventory_inspection_sheet_snapshot')->insertBatch($insertRows);
$insertRows = [];
}
}
}
if ($insertRows !== []) {
$db->table('bag_inventory_inspection_sheet_snapshot')->insertBatch($insertRows);
}
}
private function ensureInspectionSheetSnapshotForPack(
int $lgIdx,
int $inspectionItemId,
string $packCode,
string $startCode,
string $endCode
): void {
if ($inspectionItemId <= 0 || trim($packCode) === '') {
return;
}
$db = \Config\Database::connect();
if (! $db->tableExists('bag_inventory_inspection_sheet_snapshot')) {
return;
}
$exists = $db->table('bag_inventory_inspection_sheet_snapshot')
->where('biss_lg_idx', $lgIdx)
->where('biss_bisi_idx', $inspectionItemId)
->where('biss_pack_code', $packCode)
->countAllResults();
if ($exists > 0) {
return;
}
$codes = $this->expandSheetCodes($startCode, $endCode);
if ($codes === []) {
return;
}
$now = date('Y-m-d H:i:s');
$rows = [];
foreach ($codes as $codeRow) {
$sheetCode = trim((string) ($codeRow['sheet_code'] ?? ''));
if ($sheetCode === '') {
continue;
}
$rows[] = [
'biss_bisi_idx' => $inspectionItemId,
'biss_lg_idx' => $lgIdx,
'biss_pack_code' => $packCode,
'biss_sheet_code' => $sheetCode,
'biss_system_qty' => 1,
'biss_actual_qty' => null,
'biss_diff_qty' => 0,
'biss_checked_yn' => 'N',
'biss_regdate' => $now,
];
}
if ($rows !== []) {
$db->table('bag_inventory_inspection_sheet_snapshot')->insertBatch($rows);
}
}
private function resolveSheetEndCodeByQty(string $startCode, string $fallbackEndCode, int $qty): string
{
if ($qty <= 0) {
return $fallbackEndCode;
}
if (preg_match('/^(.*?)(\d+)$/', $startCode, $m) !== 1) {
return $fallbackEndCode;
}
$prefix = (string) ($m[1] ?? '');
$startNumRaw = (string) ($m[2] ?? '');
$startNum = (int) $startNumRaw;
if ($startNum <= 0) {
return $fallbackEndCode;
}
$endNum = $startNum + $qty - 1;
$width = strlen($startNumRaw);
return $prefix . str_pad((string) $endNum, $width, '0', STR_PAD_LEFT);
}
/**
* @param list<array<string,mixed>> $rows
* @return list<array<string,mixed>>
*/
private function trimPackRowsToTargetQty(array $rows, int $targetQty): array
{
$targetQty = max(0, $targetQty);
if ($targetQty <= 0 || $rows === []) {
return [];
}
$trimmed = [];
$remain = $targetQty;
foreach ($rows as $row) {
if ($remain <= 0) {
break;
}
$rowQty = max(0, (int) ($row['bisp_sheet_qty'] ?? 0));
if ($rowQty <= 0) {
continue;
}
if ($rowQty <= $remain) {
$trimmed[] = $row;
$remain -= $rowQty;
continue;
}
$startCode = (string) ($row['bisp_sheet_start_code'] ?? '');
$endCode = (string) ($row['bisp_sheet_end_code'] ?? '');
$row['bisp_sheet_qty'] = $remain;
$row['bisp_sheet_end_code'] = $this->resolveSheetEndCodeByQty($startCode, $endCode, $remain);
$trimmed[] = $row;
$remain = 0;
}
return $trimmed;
}
private function ensureInventoryInspectionTables(): void
{
$db = \Config\Database::connect();
if (! $db->tableExists('bag_inventory_inspection')) {
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `bag_inventory_inspection` (
`bis_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`bis_lg_idx` INT UNSIGNED NOT NULL,
`bis_work_date` DATE NOT NULL,
`bis_status` VARCHAR(20) NOT NULL DEFAULT 'selected',
`bis_reg_mb_idx` INT UNSIGNED NOT NULL DEFAULT 0,
`bis_regdate` DATETIME NOT NULL,
`bis_moddate` DATETIME NULL DEFAULT NULL,
PRIMARY KEY (`bis_idx`),
KEY `idx_bis_lg_work` (`bis_lg_idx`, `bis_work_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
if (! $db->tableExists('bag_inventory_inspection_item')) {
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `bag_inventory_inspection_item` (
`bisi_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`bisi_bis_idx` INT UNSIGNED NOT NULL,
`bisi_bag_code` VARCHAR(50) NOT NULL,
`bisi_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
`bisi_system_qty` INT NOT NULL DEFAULT 0,
`bisi_actual_qty` INT NULL DEFAULT NULL,
`bisi_diff_qty` INT NOT NULL DEFAULT 0,
`bisi_has_barcode` CHAR(1) NOT NULL DEFAULT 'Y',
`bisi_apply_yn` CHAR(1) NOT NULL DEFAULT 'N',
PRIMARY KEY (`bisi_idx`),
KEY `idx_bisi_bis` (`bisi_bis_idx`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
} else {
$fields = $db->getFieldNames('bag_inventory_inspection_pack_snapshot');
if (! in_array('bisp_actual_qty', $fields, true)) {
$db->query("ALTER TABLE `bag_inventory_inspection_pack_snapshot` ADD COLUMN `bisp_actual_qty` INT UNSIGNED NULL DEFAULT NULL AFTER `bisp_sheet_qty`");
}
if (! in_array('bisp_diff_qty', $fields, true)) {
$db->query("ALTER TABLE `bag_inventory_inspection_pack_snapshot` ADD COLUMN `bisp_diff_qty` INT NOT NULL DEFAULT 0 AFTER `bisp_actual_qty`");
}
if (! in_array('bisp_checked_yn', $fields, true)) {
$db->query("ALTER TABLE `bag_inventory_inspection_pack_snapshot` ADD COLUMN `bisp_checked_yn` CHAR(1) NOT NULL DEFAULT 'N' AFTER `bisp_diff_qty`");
}
}
}
// ──────────────────────────────────────────────
// 판매 관리
// ──────────────────────────────────────────────
public function sales(): string
{
$lgIdx = $this->lgIdx();
$data = ['salesList' => [], 'orderList' => [], 'startDate' => null, 'endDate' => null];
if ($lgIdx) {
$startDate = $this->request->getGet('start_date');
$endDate = $this->request->getGet('end_date');
$data['startDate'] = $startDate;
$data['endDate'] = $endDate;
// 판매/반품
$saleBuilder = model(BagSaleModel::class)->where('bs_lg_idx', $lgIdx);
if ($startDate) $saleBuilder->where('bs_sale_date >=', $startDate);
if ($endDate) $saleBuilder->where('bs_sale_date <=', $endDate);
$data['salesList'] = $saleBuilder->orderBy('bs_sale_date', 'DESC')->paginate(20, 'sales');
$data['salesPager'] = model(BagSaleModel::class)->pager;
// 주문 접수
$orderBuilder = model(ShopOrderModel::class)->where('so_lg_idx', $lgIdx);
if ($startDate) $orderBuilder->where('so_delivery_date >=', $startDate);
if ($endDate) $orderBuilder->where('so_delivery_date <=', $endDate);
$data['orderList'] = $orderBuilder->orderBy('so_idx', 'DESC')->paginate(20, 'shoporders');
$data['orderPager'] = model(ShopOrderModel::class)->pager;
}
return $this->render('판매 관리', 'bag/sales', $data);
}
// ──────────────────────────────────────────────
// 판매 현황
// ──────────────────────────────────────────────
public function salesStats(): string
{
$lgIdx = $this->lgIdx();
$data = ['result' => [], 'startDate' => null, 'endDate' => null];
if ($lgIdx) {
$startDate = $this->request->getGet('start_date');
$endDate = $this->request->getGet('end_date');
$data['startDate'] = $startDate;
$data['endDate'] = $endDate;
$builder = model(BagSaleModel::class)->where('bs_lg_idx', $lgIdx)->where('bs_type', 'sale');
if ($startDate) $builder->where('bs_sale_date >=', $startDate);
if ($endDate) $builder->where('bs_sale_date <=', $endDate);
$data['result'] = $builder->orderBy('bs_sale_date', 'DESC')->paginate(20);
$data['pager'] = model(BagSaleModel::class)->pager;
}
return $this->render('판매 현황', 'bag/sales_stats', $data);
}
// ──────────────────────────────────────────────
// 봉투 수불 관리
// ──────────────────────────────────────────────
public function flow(): string
{
$lgIdx = $this->lgIdx();
$data = ['receiving' => [], 'sales' => [], 'issues' => [], 'inventory' => [], 'startDate' => null, 'endDate' => null];
if ($lgIdx) {
$startDate = $this->request->getGet('start_date');
$endDate = $this->request->getGet('end_date');
$data['startDate'] = $startDate;
$data['endDate'] = $endDate;
$data['inventory'] = model(BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->findAll();
$recvBuilder = model(BagReceivingModel::class)->where('br_lg_idx', $lgIdx);
if ($startDate) $recvBuilder->where('br_receive_date >=', $startDate);
if ($endDate) $recvBuilder->where('br_receive_date <=', $endDate);
$data['receiving'] = $recvBuilder->findAll();
$saleBuilder = model(BagSaleModel::class)->where('bs_lg_idx', $lgIdx);
if ($startDate) $saleBuilder->where('bs_sale_date >=', $startDate);
if ($endDate) $saleBuilder->where('bs_sale_date <=', $endDate);
$data['sales'] = $saleBuilder->findAll();
$issueBuilder = model(BagIssueModel::class)->where('bi2_lg_idx', $lgIdx);
if ($startDate) $issueBuilder->where('bi2_issue_date >=', $startDate);
if ($endDate) $issueBuilder->where('bi2_issue_date <=', $endDate);
$data['issues'] = $issueBuilder->findAll();
}
return $this->render('봉투 수불 관리', 'bag/flow', $data);
}
// ──────────────────────────────────────────────
// 통계 분석 관리
// ──────────────────────────────────────────────
public function analytics(): string
{
return $this->render('통계 분석 관리', 'bag/analytics', []);
}
// ──────────────────────────────────────────────
// 창 (프로그램 창 관리 - 추후)
// ──────────────────────────────────────────────
public function window(): string
{
return $this->render('창', 'bag/window', []);
}
// ──────────────────────────────────────────────
// 도움말
// ──────────────────────────────────────────────
public function help(): string
{
return $this->render('도움말', 'bag/help', []);
}
// ──────────────────────────────────────────────
// 재고 조정 (실사)
// ──────────────────────────────────────────────
public function inventoryAdjust(): string
{
$lgIdx = $this->lgIdx();
$inventory = $lgIdx ? model(BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code')->findAll() : [];
return $this->render('재고 조정', 'bag/inventory_adjust', compact('inventory'));
}
public function inventoryAdjustStore()
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
}
$rules = [
'bag_code' => 'required|max_length[50]',
'adjust_type' => 'required|in_list[set,add,sub]',
'qty' => 'required|is_natural',
];
if (! $this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$bagCode = $this->request->getPost('bag_code');
$type = $this->request->getPost('adjust_type');
$qty = (int) $this->request->getPost('qty');
$invModel = model(BagInventoryModel::class);
$existing = $invModel->where('bi_lg_idx', $lgIdx)->where('bi_bag_code', $bagCode)->first();
if ($type === 'set') {
if ($existing) {
$invModel->update($existing->bi_idx, ['bi_qty' => $qty, 'bi_updated_at' => date('Y-m-d H:i:s')]);
}
} elseif ($type === 'add') {
$bagName = $existing ? $existing->bi_bag_name : '';
$invModel->adjustQty($lgIdx, $bagCode, $bagName, $qty);
} elseif ($type === 'sub') {
$bagName = $existing ? $existing->bi_bag_name : '';
$invModel->adjustQty($lgIdx, $bagCode, $bagName, -$qty);
}
return redirect()->to(site_url('bag/inventory'))->with('success', '재고가 조정되었습니다.');
}
// ══════════════════════════════════════════════
// CRUD — 사이트 레이아웃으로 등록/처리 폼 제공
// ══════════════════════════════════════════════
// --- 불출 등록 ---
public function issueCreate(): string
{
helper('admin');
$lgIdx = $this->lgIdx();
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kindO ? model(CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) : [];
$bagNameMap = [];
foreach ($bagCodes as $cd) {
$bagNameMap[(string) ($cd->cd_code ?? '')] = (string) ($cd->cd_name ?? '');
}
$inventoryRows = $lgIdx
? model(BagInventoryModel::class)
->where('bi_lg_idx', $lgIdx)
->where('bi_qty >', 0)
->orderBy('bi_bag_code', 'ASC')
->findAll()
: [];
$inventoryMap = [];
foreach ($inventoryRows as $inv) {
$code = (string) ($inv->bi_bag_code ?? '');
if ($code === '') {
continue;
}
$inventoryMap[$code] = (int) ($inv->bi_qty ?? 0);
}
$unitRows = $lgIdx
? model(PackagingUnitModel::class)
->where('pu_lg_idx', $lgIdx)
->where('pu_state', 1)
->findAll()
: [];
$packagingMap = [];
foreach ($unitRows as $unit) {
$code = (string) ($unit->pu_bag_code ?? '');
if ($code === '') {
continue;
}
$packagingMap[$code] = [
'packPerSheet' => max(1, (int) ($unit->pu_pack_per_sheet ?? 1)),
'totalPerBox' => max(1, (int) ($unit->pu_total_per_box ?? 1)),
];
}
$bagMeta = [];
foreach ($inventoryMap as $code => $qty) {
$bagMeta[$code] = [
'name' => (string) ($bagNameMap[$code] ?? ''),
'inventoryQty' => (int) $qty,
'packPerSheet' => (int) (($packagingMap[$code]['packPerSheet'] ?? 1)),
'totalPerBox' => (int) (($packagingMap[$code]['totalPerBox'] ?? 1)),
];
}
$availableBagRows = [];
foreach ($inventoryMap as $code => $qty) {
$availableBagRows[] = [
'bag_code' => (string) $code,
'bag_name' => (string) ($bagNameMap[$code] ?? $code),
'inventory_qty' => (int) $qty,
'pack_per_sheet' => (int) (($packagingMap[$code]['packPerSheet'] ?? 1)),
'total_per_box' => (int) (($packagingMap[$code]['totalPerBox'] ?? 1)),
];
}
$recentIssueRows = $lgIdx
? model(BagIssueModel::class)
->where('bi2_lg_idx', $lgIdx)
->orderBy('bi2_issue_date', 'DESC')
->orderBy('bi2_idx', 'DESC')
->findAll(20)
: [];
$kindD = model(CodeKindModel::class)->where('ck_code', 'D')->first();
$dongCodes = $kindD ? model(CodeDetailModel::class)->getByKind((int) $kindD->ck_idx, true, $lgIdx) : [];
$today = date('Y-m-d');
$freeDongRows = [];
if ($lgIdx) {
$freeDongRows = model(\App\Models\FreeRecipientModel::class)
->builder()
->select('fr_dong_code')
->distinct()
->where('fr_lg_idx', $lgIdx)
->where('fr_state', 1)
->groupStart()
->where('fr_end_date IS NULL')
->orWhere('fr_end_date >=', $today)
->groupEnd()
->where('fr_dong_code !=', '')
->get()
->getResult();
}
$freeDongSet = [];
foreach ($freeDongRows as $row) {
$code = trim((string) ($row->fr_dong_code ?? ''));
if ($code !== '') {
$freeDongSet[$code] = true;
}
}
$destTypeOptions = ['구청', '기타'];
if ($lgIdx) {
$typeRows = model(\App\Models\FreeRecipientModel::class)
->builder()
->select('fr_type_code, fr_name')
->distinct()
->where('fr_lg_idx', $lgIdx)
->where('fr_state', 1)
->groupStart()
->where('fr_end_date IS NULL')
->orWhere('fr_end_date >=', $today)
->groupEnd()
->whereIn('fr_type_code', ['office', 'target'])
->orderBy('fr_name', 'ASC')
->get()
->getResult();
foreach ($typeRows as $row) {
$typeCode = trim((string) ($row->fr_type_code ?? ''));
$name = trim((string) ($row->fr_name ?? ''));
if ($typeCode === 'office') {
$destTypeOptions[] = '동사무소';
continue;
}
if ($typeCode === 'target' && $name !== '') {
$destTypeOptions[] = $name;
}
}
$destTypeOptions = array_values(array_unique($destTypeOptions));
}
return $this->render('불출 처리', 'bag/create_bag_issue', compact(
'bagCodes',
'bagMeta',
'inventoryMap',
'packagingMap',
'availableBagRows',
'recentIssueRows',
'dongCodes',
'freeDongSet',
'destTypeOptions'
));
}
public function issueStore()
{
$admin = new \App\Controllers\Admin\BagIssue();
$admin->initController($this->request, $this->response, service('logger'));
$result = $admin->store();
if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) {
$to = (string) $result->getHeaderLine('Location');
$to = str_replace('/admin/bag-issues', '/bag/issue', $to);
return redirect()->to($to)->with('success', session()->getFlashdata('success'))->with('errors', session()->getFlashdata('errors'));
}
return redirect()->to(site_url('bag/issue/cancel'))->with('success', '불출 처리되었습니다.');
}
public function issueCancel(int $id)
{
$admin = new \App\Controllers\Admin\BagIssue();
$admin->initController($this->request, $this->response, service('logger'));
$admin->cancel($id);
return redirect()->to(site_url('bag/issue/cancel'))->with('success', session()->getFlashdata('success') ?? '취소되었습니다.');
}
// --- 발주 등록 ---
public function orderCreate(): string
{
helper('admin');
$lgIdx = $this->lgIdx();
$companies = $lgIdx
? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll()
: [];
$associations = $lgIdx
? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll()
: [];
$agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : [];
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
$priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : [];
$units = $lgIdx ? model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll() : [];
$recentOrders = $lgIdx
? model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->whereLatestHead($lgIdx)->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->findAll(12)
: [];
$companyMap = [];
foreach ($companies as $company) {
$companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
}
$agencyMap = [];
foreach ($agencies as $agency) {
$agencyMap[(int) $agency->sa_idx] = '[' . ($agency->sa_kind ?? '') . '] ' . ($agency->sa_code ?? '') . ' — ' . ($agency->sa_name ?? '');
}
$bagNameMap = [];
foreach ($bagCodes as $codeDetail) {
$bagNameMap[(string) $codeDetail->cd_code] = (string) $codeDetail->cd_name;
}
$priceMap = [];
foreach ($priceMapRows as $bagCode => $price) {
$priceMap[(string) $bagCode] = (float) ($price->bp_order_price ?? 0);
}
$unitMap = [];
foreach ($units as $unit) {
$unitMap[(string) $unit->pu_bag_code] = [
'boxPerPack' => (int) $unit->pu_box_per_pack,
'packPerSheet' => (int) $unit->pu_pack_per_sheet,
'totalPerBox' => (int) $unit->pu_total_per_box,
];
}
$bagReferenceRows = [];
foreach ($bagCodes as $codeDetail) {
$bagCode = (string) $codeDetail->cd_code;
$unit = $unitMap[$bagCode] ?? ['boxPerPack' => 0, 'packPerSheet' => 0, 'totalPerBox' => 0];
$bagReferenceRows[] = [
'code' => $bagCode,
'name' => (string) ($bagNameMap[$bagCode] ?? ''),
'orderPrice' => (float) ($priceMap[$bagCode] ?? 0),
'boxPerPack' => (int) $unit['boxPerPack'],
'packPerSheet' => (int) $unit['packPerSheet'],
'totalPerBox' => (int) $unit['totalPerBox'],
];
}
return $this->render(
'발주 등록',
'bag/create_bag_order',
array_merge(
compact(
'companies',
'associations',
'agencies',
'bagCodes',
'recentOrders',
'companyMap',
'agencyMap',
'bagReferenceRows'
),
['editMode' => false, 'editDefaults' => null]
)
);
}
/**
* LOT-No 디스켓 불출: 발주 건을 선택해 암호화 seed 파일 생성/다운로드.
*/
public function orderLotSeed(): string|RedirectResponse
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
}
$startMonth = (string) ($this->request->getGet('start_month') ?? date('Y-m'));
$endMonth = (string) ($this->request->getGet('end_month') ?? date('Y-m'));
if (! preg_match('/^\d{4}-\d{2}$/', $startMonth)) {
$startMonth = date('Y-m');
}
if (! preg_match('/^\d{4}-\d{2}$/', $endMonth)) {
$endMonth = $startMonth;
}
if (strtotime($startMonth . '-01') > strtotime($endMonth . '-01')) {
[$startMonth, $endMonth] = [$endMonth, $startMonth];
}
$lotNo = trim((string) ($this->request->getGet('lot_no') ?? ''));
$companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
$startDate = $startMonth . '-01';
$endDate = date('Y-m-t', strtotime($endMonth . '-01 00:00:00'));
$orderModel = model(BagOrderModel::class);
$builder = $orderModel
->where('bo_lg_idx', $lgIdx)
->whereLatestHead($lgIdx)
->where('bo_order_date >=', $startDate)
->where('bo_order_date <=', $endDate)
->whereIn('bo_status', ['normal', 'cancelled'])
->orderBy('bo_order_date', 'DESC')
->orderBy('bo_idx', 'DESC');
if ($lotNo !== '') {
$builder->where('bo_lot_no', $lotNo);
}
if ($companyIdx > 0) {
$builder->where('bo_company_idx', $companyIdx);
}
$orders = $builder->paginate(20);
$pager = $orderModel->pager;
$companies = model(CompanyModel::class)
->where('cp_lg_idx', $lgIdx)
->where('cp_type', '제작업체')
->where('cp_state', 1)
->orderBy('cp_name', 'ASC')
->findAll();
$companyMap = [];
foreach ($companies as $company) {
$companyMap[(int) ($company->cp_idx ?? 0)] = (string) ($company->cp_name ?? '');
}
$orderIds = array_values(array_map(static fn ($o): int => (int) ($o->bo_idx ?? 0), $orders));
$itemSummary = [];
if ($orderIds !== []) {
$items = model(BagOrderItemModel::class)
->whereIn('boi_bo_idx', $orderIds)
->orderBy('boi_bo_idx', 'ASC')
->findAll();
foreach ($items as $item) {
$boIdx = (int) ($item->boi_bo_idx ?? 0);
if (! isset($itemSummary[$boIdx])) {
$itemSummary[$boIdx] = [
'line_count' => 0,
'qty_box' => 0,
'qty_sheet' => 0,
];
}
$itemSummary[$boIdx]['line_count']++;
$itemSummary[$boIdx]['qty_box'] += (int) ($item->boi_qty_box ?? 0);
$itemSummary[$boIdx]['qty_sheet'] += (int) ($item->boi_qty_sheet ?? 0);
}
}
return $this->render('LOT-No 디스켓 불출', 'bag/order_lot_seed', [
'orders' => $orders,
'pager' => $pager,
'startMonth' => $startMonth,
'endMonth' => $endMonth,
'lotNo' => $lotNo,
'companyIdx' => $companyIdx,
'companies' => $companies,
'companyMap' => $companyMap,
'itemSummary' => $itemSummary,
]);
}
public function orderLotSeedGenerate(): RedirectResponse|ResponseInterface
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
}
$boIdx = (int) ($this->request->getPost('bo_idx') ?? 0);
if ($boIdx <= 0) {
return redirect()->back()->with('error', '발주 건을 선택해 주세요.');
}
$order = model(BagOrderModel::class)
->where('bo_lg_idx', $lgIdx)
->where('bo_idx', $boIdx)
->first();
if (! $order) {
return redirect()->back()->with('error', '발주 정보를 찾을 수 없습니다.');
}
$lotNo = trim((string) ($order->bo_lot_no ?? ''));
$uuid = trim((string) ($order->bo_uuid ?? ''));
$version = max(1, (int) ($order->bo_version ?? 1));
if ($lotNo === '' || $uuid === '') {
return redirect()->back()->with('error', '발주의 LOT/UUID 정보가 없어 seed 파일을 생성할 수 없습니다.');
}
$items = model(BagOrderItemModel::class)
->where('boi_bo_idx', $boIdx)
->orderBy('boi_idx', 'ASC')
->findAll();
if ($items === []) {
return redirect()->back()->with('error', '발주 품목이 없어 seed 파일을 생성할 수 없습니다.');
}
$unitRows = model(PackagingUnitModel::class)
->where('pu_lg_idx', $lgIdx)
->where('pu_state', 1)
->findAll();
$packMap = [];
foreach ($unitRows as $unit) {
$code = trim((string) ($unit->pu_bag_code ?? ''));
if ($code === '') {
continue;
}
$packMap[$code] = [
'pack_per_sheet' => max(1, (int) ($unit->pu_pack_per_sheet ?? 1)),
'total_per_box' => max(1, (int) ($unit->pu_total_per_box ?? 1)),
];
}
$orderData = [
'bo_idx' => $boIdx,
'bo_uuid' => (string) $uuid,
'bo_version' => $version,
'bo_lg_idx' => (int) ($order->bo_lg_idx ?? 0),
'bo_gugun_code' => (string) ($order->bo_gugun_code ?? ''),
'bo_dong_code' => (string) ($order->bo_dong_code ?? ''),
'bo_company_idx' => (int) ($order->bo_company_idx ?? 0),
'bo_agency_idx' => (int) ($order->bo_agency_idx ?? 0),
'bo_fee_rate' => (float) ($order->bo_fee_rate ?? 0),
'bo_order_date' => (string) ($order->bo_order_date ?? ''),
'bo_lot_no' => (string) $lotNo,
'bo_status' => (string) ($order->bo_status ?? 'normal'),
];
$hashItems = [];
foreach ($items as $item) {
$code = (string) ($item->boi_bag_code ?? '');
$pack = $packMap[$code] ?? ['pack_per_sheet' => 1, 'total_per_box' => 1];
$qtySheet = max(0, (int) ($item->boi_qty_sheet ?? 0));
$qtyPack = intdiv($qtySheet, max(1, (int) $pack['pack_per_sheet']));
$hashItems[] = [
'boi_idx' => (int) ($item->boi_idx ?? 0),
'boi_bag_code' => $code,
'boi_bag_name' => (string) ($item->boi_bag_name ?? ''),
'boi_unit_price' => (float) ($item->boi_unit_price ?? 0),
'boi_qty_box' => (int) ($item->boi_qty_box ?? 0),
'boi_qty_pack' => $qtyPack,
'boi_qty_sheet' => $qtySheet,
'pack_per_sheet' => (int) $pack['pack_per_sheet'],
'total_per_box' => (int) $pack['total_per_box'],
'boi_amount' => (float) ($item->boi_amount ?? 0),
];
}
$orderHash = trim((string) ($order->bo_hash ?? ''));
if ($orderHash === '') {
$payload = [
'bo_idx' => $boIdx,
'order' => $orderData,
'items' => $hashItems,
];
$payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: (string) $boIdx;
$orderHash = hash('sha256', $payloadJson);
}
$seedPath = $this->generateLotSeedFile($uuid, $version, $lotNo, $orderData, $hashItems, $orderHash);
$seedBinary = @file_get_contents($seedPath);
if (! is_string($seedBinary) || $seedBinary === '') {
return redirect()->back()->with('error', 'seed 파일 생성에는 성공했으나 파일을 읽을 수 없습니다.');
}
return $this->response
->download($seedPath, $seedBinary)
->setFileName(basename($seedPath));
}
/**
* @param array<string,mixed> $orderData
* @param array<int,array<string,mixed>> $items
*/
private function generateLotSeedFile(string $uuid, int $version, string $lotNo, array $orderData, array $items, string $orderHash): string
{
$baseDir = WRITEPATH . 'barcode-seeds';
if (! is_dir($baseDir)) {
mkdir($baseDir, 0775, true);
}
$keyDir = WRITEPATH . 'keys';
if (! is_dir($keyDir)) {
mkdir($keyDir, 0775, true);
}
$privateKeyPath = $keyDir . DIRECTORY_SEPARATOR . 'barcode_seed_private.pem';
$publicKeyPath = $keyDir . DIRECTORY_SEPARATOR . 'barcode_seed_public.pem';
if (! is_file($privateKeyPath) || ! is_file($publicKeyPath)) {
$config = [
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
$resource = openssl_pkey_new($config);
if ($resource !== false) {
$privatePem = '';
openssl_pkey_export($resource, $privatePem);
$details = openssl_pkey_get_details($resource);
$publicPem = $details['key'] ?? '';
if ($privatePem !== '' && $publicPem !== '') {
file_put_contents($privateKeyPath, $privatePem);
file_put_contents($publicKeyPath, $publicPem);
}
}
}
$payload = [
'uuid' => $uuid,
'version' => $version,
'lot_no' => $lotNo,
'order_hash' => $orderHash,
'order' => $orderData,
'items' => $items,
];
$payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}';
$aesKey = random_bytes(32);
$iv = random_bytes(16);
$cipherRaw = openssl_encrypt($payloadJson, 'AES-256-CBC', $aesKey, OPENSSL_RAW_DATA, $iv);
if ($cipherRaw === false) {
$cipherRaw = $payloadJson;
}
$encryptedKey = '';
$publicPem = is_file($publicKeyPath) ? file_get_contents($publicKeyPath) : '';
if (is_string($publicPem) && $publicPem !== '') {
openssl_public_encrypt($aesKey, $encryptedKey, $publicPem, OPENSSL_PKCS1_OAEP_PADDING);
}
$seed = [
'algorithm' => ['symmetric' => 'AES-256-CBC', 'asymmetric' => 'RSA-2048'],
'lot_no' => $lotNo,
'uuid' => $uuid,
'version' => $version,
'iv_b64' => base64_encode($iv),
'key_b64' => $encryptedKey !== '' ? base64_encode($encryptedKey) : '',
'cipher_b64' => base64_encode((string) $cipherRaw),
'payload_hash' => hash('sha256', $payloadJson),
'created_at' => date('c'),
];
$fileName = sprintf('%s_v%d_diskette_%s.seed.json', $lotNo, $version, date('Ymd_His'));
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $fileName;
file_put_contents($fullPath, json_encode($seed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return $fullPath;
}
/**
* 발주 변경 허브: 발주월·변경 구분 선택 후 목록에서 발주를 선택 (GBMS 발주 변경 화면 흐름).
*/
public function orderChange(): string|RedirectResponse
{
helper('admin');
$lgIdx = $this->lgIdx();
$month = $this->request->getGet('month');
if ($month === null || $month === '' || ! is_string($month) || ! preg_match('/^\d{4}-\d{2}$/', $month)) {
$month = date('Y-m');
}
$hubMode = $this->request->getGet('hub_mode');
$hubMode = in_array($hubMode, ['price', 'meta', 'delete'], true) ? $hubMode : 'meta';
$companyMap = [];
if ($lgIdx) {
$companies = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll();
foreach ($companies as $company) {
$companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
}
}
$monthOrders = [];
if ($lgIdx) {
$start = $month . '-01';
$end = date('Y-m-t', strtotime($start . ' 00:00:00'));
$monthOrders = model(BagOrderModel::class)
->where('bo_lg_idx', $lgIdx)
->whereLatestHead($lgIdx)
->where('bo_order_date >=', $start)
->where('bo_order_date <=', $end)
->whereIn('bo_status', ['normal', 'cancelled'])
->orderBy('bo_order_date', 'DESC')
->orderBy('bo_idx', 'DESC')
->findAll();
}
if ($hubMode === 'delete') {
foreach ($monthOrders as $row) {
if ((string) ($row->bo_status ?? '') === 'normal') {
return redirect()->to(
site_url('bag/order/revise/' . (int) $row->bo_idx . '?change_mode=delete')
);
}
}
if ($lgIdx) {
session()->setFlashdata('error', '해당 월에 삭제할 수 있는 발주(정상)가 없습니다.');
}
}
return $this->render(
'발주 변경',
'bag/order_change',
compact('month', 'hubMode', 'monthOrders', 'companyMap')
);
}
public function orderRevise(int $id): string|RedirectResponse
{
helper('admin');
$lgIdx = $this->lgIdx();
$orderModel = model(BagOrderModel::class);
$itemModel = model(\App\Models\BagOrderItemModel::class);
$target = $orderModel->find($id);
if (! $target || (int) $target->bo_lg_idx !== $lgIdx) {
return redirect()->to(site_url('bag/order/change'))->with('error', '수정할 발주를 찾을 수 없습니다.');
}
if ((string) ($target->bo_status ?? '') !== 'normal') {
return redirect()->to(site_url('bag/order/change'))->with('error', '변경할 수 없는 발주입니다.');
}
$changeMode = $this->request->getGet('change_mode');
$changeMode = in_array($changeMode, ['price', 'meta', 'delete'], true) ? $changeMode : 'meta';
$companies = $lgIdx
? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll()
: [];
$associations = $lgIdx
? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll()
: [];
$agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : [];
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
$priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : [];
$units = $lgIdx ? model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll() : [];
$companyMap = [];
foreach ($companies as $company) {
$companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
}
$agencyMap = [];
foreach ($agencies as $agency) {
$agencyMap[(int) $agency->sa_idx] = '[' . ($agency->sa_kind ?? '') . '] ' . ($agency->sa_code ?? '') . ' — ' . ($agency->sa_name ?? '');
}
$bagNameMap = [];
foreach ($bagCodes as $codeDetail) {
$bagNameMap[(string) $codeDetail->cd_code] = (string) $codeDetail->cd_name;
}
$priceMap = [];
foreach ($priceMapRows as $bagCode => $price) {
$priceMap[(string) $bagCode] = (float) ($price->bp_order_price ?? 0);
}
$unitMap = [];
foreach ($units as $unit) {
$unitMap[(string) $unit->pu_bag_code] = [
'boxPerPack' => (int) $unit->pu_box_per_pack,
'packPerSheet' => (int) $unit->pu_pack_per_sheet,
'totalPerBox' => (int) $unit->pu_total_per_box,
];
}
$bagReferenceRows = [];
foreach ($bagCodes as $codeDetail) {
$bagCode = (string) $codeDetail->cd_code;
$unit = $unitMap[$bagCode] ?? ['boxPerPack' => 0, 'packPerSheet' => 0, 'totalPerBox' => 0];
$bagReferenceRows[] = [
'code' => $bagCode,
'name' => (string) ($bagNameMap[$bagCode] ?? ''),
'orderPrice' => (float) ($priceMap[$bagCode] ?? 0),
'boxPerPack' => (int) $unit['boxPerPack'],
'packPerSheet' => (int) $unit['packPerSheet'],
'totalPerBox' => (int) $unit['totalPerBox'],
];
}
$items = $itemModel->where('boi_bo_idx', (int) $target->bo_idx)->orderBy('boi_idx', 'ASC')->findAll();
$orderReturnMonth = substr((string) ($target->bo_order_date ?? date('Y-m-d')), 0, 7);
$monthStart = $orderReturnMonth . '-01';
$monthEnd = date('Y-m-t', strtotime($monthStart . ' 00:00:00'));
$recentOrders = $lgIdx
? $orderModel->where('bo_lg_idx', $lgIdx)
->whereLatestHead($lgIdx)
->where('bo_order_date >=', $monthStart)
->where('bo_order_date <=', $monthEnd)
->whereIn('bo_status', ['normal', 'cancelled'])
->orderBy('bo_order_date', 'DESC')
->orderBy('bo_idx', 'DESC')
->findAll()
: [];
$itemCodes = [];
$itemQtyBoxes = [];
$itemQtySheets = [];
foreach ($items as $item) {
$itemCodes[] = (string) ($item->boi_bag_code ?? '');
$itemQtyBoxes[] = (int) ($item->boi_qty_box ?? 0);
$itemQtySheets[] = (int) ($item->boi_qty_sheet ?? 0);
}
$savedLinePrices = [];
foreach ($items as $item) {
$savedLinePrices[(string) ($item->boi_bag_code ?? '')] = (float) ($item->boi_unit_price ?? 0);
}
foreach ($bagReferenceRows as &$brow) {
$c = (string) ($brow['code'] ?? '');
if ($c !== '' && isset($savedLinePrices[$c])) {
$brow['orderPrice'] = $savedLinePrices[$c];
}
}
unset($brow);
$orderLotNo = (string) ($target->bo_lot_no ?? '');
$editDefaults = [
'bo_source_idx' => (int) $target->bo_idx,
'bo_order_date' => (string) ($target->bo_order_date ?? date('Y-m-d')),
'bo_order_month_ui' => substr((string) ($target->bo_order_date ?? date('Y-m-d')), 0, 7),
'bo_fee_rate' => (string) ($target->bo_fee_rate ?? '0'),
'bo_association_idx' => (string) ($target->bo_association_idx ?? ''),
'bo_company_idx' => (string) ($target->bo_company_idx ?? ''),
'bo_agency_idx' => (string) ($target->bo_agency_idx ?? ''),
'item_bag_code' => $itemCodes,
'item_qty_box' => $itemQtyBoxes,
'item_qty_sheet' => $itemQtySheets,
];
return $this->render(
'발주 변경',
'bag/create_bag_order',
compact(
'companies',
'associations',
'agencies',
'bagCodes',
'recentOrders',
'companyMap',
'agencyMap',
'bagReferenceRows',
'editDefaults',
'changeMode',
'orderReturnMonth',
'orderLotNo'
)
+ ['editMode' => true, 'hubReturn' => true]
);
}
public function orderStore()
{
$admin = new \App\Controllers\Admin\BagOrder();
$admin->initController($this->request, $this->response, service('logger'));
$result = $admin->store();
if ($result instanceof RedirectResponse) {
$success = session()->getFlashdata('success');
$error = session()->getFlashdata('error');
$errors = session()->getFlashdata('errors');
if (! empty($error) || ! empty($errors)) {
$sourceIdx = (int) ($this->request->getPost('bo_source_idx') ?? 0);
$reviseMode = (string) ($this->request->getPost('bo_change_mode') ?? 'meta');
$redirectUrl = $sourceIdx > 0
? site_url('bag/order/revise/' . $sourceIdx . '?change_mode=' . rawurlencode($reviseMode))
: site_url('bag/order/create');
return redirect()->to($redirectUrl)
->withInput()
->with('error', $error)
->with('errors', $errors);
}
$returnHub = (int) ($this->request->getPost('order_return_hub') ?? 0) === 1;
$returnMonth = (string) ($this->request->getPost('order_return_month') ?? '');
$sourceIdxPost = (int) ($this->request->getPost('bo_source_idx') ?? 0);
if ($returnHub && $sourceIdxPost > 0 && preg_match('/^\d{4}-\d{2}$/', $returnMonth)) {
return redirect()->to(site_url('bag/order/change?month=' . $returnMonth))
->with('success', $success ?? '발주가 저장되었습니다.');
}
return redirect()->to(site_url('bag/order/create'))
->with('success', $success);
}
$returnHub = (int) ($this->request->getPost('order_return_hub') ?? 0) === 1;
$returnMonth = (string) ($this->request->getPost('order_return_month') ?? '');
if ($returnHub && (int) ($this->request->getPost('bo_source_idx') ?? 0) > 0 && preg_match('/^\d{4}-\d{2}$/', $returnMonth)) {
return redirect()->to(site_url('bag/order/change?month=' . $returnMonth))->with('success', '발주가 저장되었습니다.');
}
return redirect()->to(site_url('bag/order/create'))->with('success', '발주 등록되었습니다.');
}
public function orderDeletePost()
{
$id = (int) ($this->request->getPost('bo_idx') ?? 0);
if ($id <= 0) {
return redirect()->to(site_url('bag/order/change'))->with('error', '삭제할 발주를 선택해 주세요.');
}
return $this->orderDelete($id);
}
public function orderDelete(int $id)
{
helper('admin');
$lgIdx = $this->lgIdx();
if ($lgIdx === null || $lgIdx <= 0) {
return redirect()->to(site_url('bag/order/change'))->with('error', '지자체를 선택해 주세요.');
}
$orderModel = model(BagOrderModel::class);
$order = $orderModel->find($id);
if (! $order || (int) $order->bo_lg_idx !== $lgIdx) {
return redirect()->to(site_url('bag/order/change'))->with('error', '발주를 찾을 수 없습니다.');
}
if ((string) ($order->bo_status ?? '') !== 'normal') {
return redirect()->to(site_url('bag/order/change'))->with('error', '삭제할 수 없는 발주입니다.');
}
$month = substr((string) ($order->bo_order_date ?? date('Y-m-d')), 0, 7);
$admin = new \App\Controllers\Admin\BagOrder();
$admin->initController($this->request, $this->response, service('logger'));
$response = $admin->delete($id);
if ($response instanceof RedirectResponse) {
$msg = session()->getFlashdata('success') ?? '발주가 삭제 처리되었습니다.';
return redirect()->to(site_url('bag/order/change?month=' . $month))->with('success', $msg);
}
return redirect()->to(site_url('bag/order/change?month=' . $month))->with('success', '처리되었습니다.');
}
public function orderCancel(int $id)
{
helper('admin');
$lgIdx = $this->lgIdx();
if (!$lgIdx) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 확인할 수 없습니다.');
}
$orderModel = model(BagOrderModel::class);
$order = $orderModel->find($id);
if (!$order || (int) $order->bo_lg_idx !== $lgIdx) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '발주를 찾을 수 없습니다.');
}
$before = (array) $order;
$orderModel->update($id, ['bo_status' => 'cancelled', 'bo_moddate' => date('Y-m-d H:i:s')]);
helper('audit');
audit_log('update', 'bag_order', $id, $before, ['bo_status' => 'cancelled']);
return redirect()->to(site_url('bag/purchase-inbound'))->with('success', '발주가 취소되었습니다.');
}
// --- 입고 처리 ---
public function receivingCreate(): string
{
return $this->receivingScanner();
}
public function receivingStore()
{
return $this->receivingScannerStore();
}
/**
* 발주 입고(스캐너 대체 수동입력)
* - 미입고가 남은 발주의 LOT·봉투(이름)로 조회 범위를 좁힌 뒤 입고 처리
* - 인수자: 대행소(agency) 담당자, 기본값 동명이면 로그인 사용자명과 일치하는 담당자
* - 인계자: 제작업체(company) 담당자
*/
public function receivingScanner(): string|RedirectResponse
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
}
$companyIdx = (int) old('company_idx', (int) ($this->request->getGet('company_idx') ?? 0));
$lotNo = '';
$bagCode = '';
$companies = model(CompanyModel::class)
->where('cp_lg_idx', $lgIdx)
->where('cp_type', '제작업체')
->where('cp_state', 1)
->orderBy('cp_name', 'ASC')
->findAll();
$defaultCompanyIdx = ! empty($companies)
? (int) ($companies[0]->cp_idx ?? 0)
: 0;
if ($companyIdx > 0) {
$validCompany = false;
foreach ($companies as $company) {
if ((int) ($company->cp_idx ?? 0) === $companyIdx) {
$validCompany = true;
break;
}
}
if (! $validCompany) {
$companyIdx = $defaultCompanyIdx;
}
} elseif ($defaultCompanyIdx > 0) {
// 초기 진입 시 드롭다운 최상단 제작업체를 기본 선택한다.
$companyIdx = $defaultCompanyIdx;
}
$lotChoices = [];
$bagFilterOptions = $this->receivingBagFilterOptions($lgIdx, $companyIdx, '');
$pick = $this->receivingManagerPickers($lgIdx);
$recvSel = $this->receivingReceiverSelect($lgIdx);
$receiverRef = (string) old('br_receiver_ref', $recvSel['defaultReceiverRef']);
$receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
if ($receiverRef === '') {
$receiverRef = $recvSel['defaultReceiverRef'];
}
$senderIdx = (int) old('br_sender_idx', $pick['defaultSenderIdx']);
$rows = $companyIdx > 0
? $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, '')
: [];
$rowsByKey = [];
foreach ($rows as $row) {
$rowsByKey[(string) $row['row_key']] = $row;
}
return $this->render(
'발주 입고(스캐너)',
'bag/receiving_scanner',
[
'companyIdx' => $companyIdx,
'companies' => $companies,
'lotNo' => '',
'bagCode' => '',
'bagFilterOptions' => $bagFilterOptions,
'lotChoices' => $lotChoices,
'receiverOptions' => $recvSel['receiverOptions'],
'receiverRef' => $receiverRef,
'senders' => $pick['senders'],
'senderIdx' => $senderIdx,
'rows' => $rows,
'rowsByKey' => $rowsByKey,
]
);
}
public function receivingScannerStore(): RedirectResponse
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
}
$receiveDate = (string) ($this->request->getPost('br_receive_date') ?? date('Y-m-d'));
$companyIdx = (int) ($this->request->getPost('company_idx') ?? 0);
$lotNo = '';
$filterBagCode = '';
$recvSel = $this->receivingReceiverSelect($lgIdx);
$receiverRef = (string) ($this->request->getPost('br_receiver_ref') ?? '');
$receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
if ($receiverRef === '') {
$receiverRef = $recvSel['defaultReceiverRef'];
}
$receiverIdx = $this->parseReceiverRefToStoredIdx($lgIdx, $receiverRef);
$senderIdx = (int) ($this->request->getPost('br_sender_idx') ?? 0);
$inputQty = $this->request->getPost('receive_qty_sheet');
$inputQty = is_array($inputQty) ? $inputQty : [];
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $receiveDate)) {
return redirect()->back()->withInput()->with('error', '입고일 형식을 확인해 주세요.');
}
if ($companyIdx <= 0) {
return redirect()->back()->withInput()->with('error', '제작업체를 선택해 주세요.');
}
if ($receiverIdx <= 0) {
return redirect()->back()->withInput()->with('error', '인수자를 선택해 주세요.');
}
$senderResolved = $this->resolveCompanySenderName($lgIdx, $senderIdx);
$rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, '');
$rowMap = [];
foreach ($rows as $row) {
$rowMap[(string) $row['row_key']] = $row;
}
$insertRows = [];
foreach ($inputQty as $rowKey => $qtyRaw) {
$rowKey = (string) $rowKey;
$qty = (int) $qtyRaw;
if ($qty <= 0 || ! isset($rowMap[$rowKey])) {
continue;
}
$base = $rowMap[$rowKey];
$pending = (int) ($base['pending_qty_sheet'] ?? 0);
if ($pending <= 0) {
continue;
}
if ($qty > $pending) {
$qty = $pending;
}
$totalPerBox = max(1, (int) ($base['total_per_box'] ?? 1));
$qtyBox = intdiv($qty, $totalPerBox);
$sender = $senderResolved !== '' ? $senderResolved : (string) ($base['company_rep_name'] ?? '');
$insertRows[] = [
'br_bo_idx' => (int) $base['bo_idx'],
'br_lg_idx' => $lgIdx,
'br_bag_code' => (string) $base['bag_code'],
'br_bag_name' => (string) $base['bag_name'],
'br_qty_box' => $qtyBox,
'br_qty_sheet' => $qty,
'br_receive_date' => $receiveDate,
'br_receiver_idx' => $receiverIdx,
'br_sender_name' => $sender,
'br_type' => 'scanner',
'br_regdate' => date('Y-m-d H:i:s'),
];
}
if (empty($insertRows)) {
return redirect()->back()->withInput()->with('error', '입고 처리할 수량을 입력해 주세요.');
}
$recvModel = model(BagReceivingModel::class);
$invModel = model(BagInventoryModel::class);
$db = \Config\Database::connect();
$db->transStart();
foreach ($insertRows as $row) {
$recvModel->insert($row);
$brIdx = (int) $recvModel->getInsertID();
$invModel->adjustQty(
$lgIdx,
(string) $row['br_bag_code'],
(string) $row['br_bag_name'],
(int) $row['br_qty_sheet']
);
$this->createReceivingPackCodes(
$lgIdx,
$brIdx,
(int) $row['br_bo_idx'],
(string) $row['br_bag_code'],
(string) $row['br_bag_name'],
(int) $row['br_qty_sheet'],
max(1, (int) ($rowMap[(string) ((int) $row['br_bo_idx'] . '|' . (string) $row['br_bag_code'])]['pack_per_sheet'] ?? 1)),
max(1, (int) ($rowMap[(string) ((int) $row['br_bo_idx'] . '|' . (string) $row['br_bag_code'])]['total_per_box'] ?? 1))
);
}
$db->transComplete();
if (! $db->transStatus()) {
return redirect()->back()->withInput()->with('error', '입고 처리 중 오류가 발생했습니다.');
}
$query = ['company_idx' => $companyIdx];
return redirect()->to(site_url('bag/receiving/scanner') . '?' . http_build_query($query))
->with('success', count($insertRows) . '건 입고 처리되었습니다.');
}
/**
* 일괄 입고: LOT-봉투 행 기준 미입고량 전체 입고.
*/
public function receivingBatch(): string|RedirectResponse
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
}
$companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
$bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
$companies = model(CompanyModel::class)
->where('cp_lg_idx', $lgIdx)
->where('cp_type', '제작업체')
->where('cp_state', 1)
->orderBy('cp_name', 'ASC')
->findAll();
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodeOptions = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
$pick = $this->receivingManagerPickers($lgIdx);
$recvSel = $this->receivingReceiverSelect($lgIdx);
$receiverRef = (string) old('br_receiver_ref', $recvSel['defaultReceiverRef']);
$receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
if ($receiverRef === '') {
$receiverRef = $recvSel['defaultReceiverRef'];
}
// 조회 화면에서는 입고완료 행도 함께 보여 미입고량 0을 확인할 수 있게 한다.
$rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, $bagCode, false, '');
return $this->render(
'일괄 입고',
'bag/receiving_batch',
[
'companyIdx' => $companyIdx,
'bagCode' => $bagCode,
'companies' => $companies,
'bagCodeOptions' => $bagCodeOptions,
'receiverOptions' => $recvSel['receiverOptions'],
'receiverRef' => $receiverRef,
'senders' => $pick['senders'],
'senderIdx' => (int) old('br_sender_idx', $pick['defaultSenderIdx']),
'rows' => $rows,
]
);
}
public function receivingBatchStore(): RedirectResponse
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
}
$companyIdx = (int) ($this->request->getPost('company_idx') ?? 0);
$bagCode = trim((string) ($this->request->getPost('bag_code') ?? ''));
$selected = $this->request->getPost('selected_rows');
$selected = is_array($selected) ? array_map('strval', $selected) : [];
$receiveDate = (string) ($this->request->getPost('br_receive_date') ?? date('Y-m-d'));
$recvSel = $this->receivingReceiverSelect($lgIdx);
$receiverRef = (string) ($this->request->getPost('br_receiver_ref') ?? '');
$receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
if ($receiverRef === '') {
$receiverRef = $recvSel['defaultReceiverRef'];
}
$receiverIdx = $this->parseReceiverRefToStoredIdx($lgIdx, $receiverRef);
$senderIdx = (int) ($this->request->getPost('br_sender_idx') ?? 0);
if (empty($selected)) {
return redirect()->back()->withInput()->with('error', '일괄 입고할 행을 선택해 주세요.');
}
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $receiveDate)) {
return redirect()->back()->withInput()->with('error', '입고일 형식을 확인해 주세요.');
}
if ($receiverIdx <= 0) {
return redirect()->back()->withInput()->with('error', '인수자를 선택해 주세요.');
}
$senderResolved = $this->resolveCompanySenderName($lgIdx, $senderIdx);
$rows = $this->buildReceivingCandidateRows($lgIdx, 0, '', true, '');
$rowMap = [];
foreach ($rows as $row) {
$rowMap[(string) $row['row_key']] = $row;
}
$insertRows = [];
foreach ($selected as $rowKey) {
if (! isset($rowMap[$rowKey])) {
continue;
}
$base = $rowMap[$rowKey];
$qty = (int) ($base['pending_qty_sheet'] ?? 0);
if ($qty <= 0) {
continue;
}
$totalPerBox = max(1, (int) ($base['total_per_box'] ?? 1));
$qtyBox = intdiv($qty, $totalPerBox);
$sender = $senderResolved !== '' ? $senderResolved : (string) ($base['company_rep_name'] ?? '');
$insertRows[] = [
'br_bo_idx' => (int) $base['bo_idx'],
'br_lg_idx' => $lgIdx,
'br_bag_code' => (string) $base['bag_code'],
'br_bag_name' => (string) $base['bag_name'],
'br_qty_box' => $qtyBox,
'br_qty_sheet' => $qty,
'br_receive_date' => $receiveDate,
'br_receiver_idx' => $receiverIdx,
'br_sender_name' => $sender,
'br_type' => 'batch',
'br_regdate' => date('Y-m-d H:i:s'),
];
}
if (empty($insertRows)) {
return redirect()->back()->withInput()->with('error', '선택한 행에 입고할 수량이 없습니다.');
}
$recvModel = model(BagReceivingModel::class);
$invModel = model(BagInventoryModel::class);
$db = \Config\Database::connect();
$db->transStart();
foreach ($insertRows as $row) {
$recvModel->insert($row);
$brIdx = (int) $recvModel->getInsertID();
$invModel->adjustQty(
$lgIdx,
(string) $row['br_bag_code'],
(string) $row['br_bag_name'],
(int) $row['br_qty_sheet']
);
$this->createReceivingPackCodes(
$lgIdx,
$brIdx,
(int) $row['br_bo_idx'],
(string) $row['br_bag_code'],
(string) $row['br_bag_name'],
(int) $row['br_qty_sheet'],
max(1, (int) ($rowMap[(string) ((int) $row['br_bo_idx'] . '|' . (string) $row['br_bag_code'])]['pack_per_sheet'] ?? 1)),
max(1, (int) ($rowMap[(string) ((int) $row['br_bo_idx'] . '|' . (string) $row['br_bag_code'])]['total_per_box'] ?? 1))
);
}
$db->transComplete();
if (! $db->transStatus()) {
return redirect()->back()->withInput()->with('error', '일괄 입고 처리 중 오류가 발생했습니다.');
}
return redirect()->to(site_url('bag/receiving/batch?company_idx=' . $companyIdx . '&bag_code=' . rawurlencode($bagCode)))
->with('success', count($insertRows) . '건 일괄 입고 처리되었습니다.');
}
public function receivingStatus(): string|RedirectResponse
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
}
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
$companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
$bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
$receiveType = (string) ($this->request->getGet('receive_type') ?? 'all');
if (! in_array($receiveType, ['all', 'completed', 'pending'], true)) {
$receiveType = 'all';
}
$companies = model(CompanyModel::class)
->where('cp_lg_idx', $lgIdx)
->where('cp_type', '제작업체')
->where('cp_state', 1)
->orderBy('cp_name', 'ASC')
->findAll();
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodeOptions = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
$rows = $this->buildReceivingStatusRows($lgIdx, $startDate, $endDate, $companyIdx, $bagCode, $receiveType);
$groupTotals = [];
$grandTotalReceive = 0;
foreach ($rows as $row) {
$key = (string) ($row['display_date'] ?? '');
if (! isset($groupTotals[$key])) {
$groupTotals[$key] = 0;
}
$groupTotals[$key] += (int) ($row['received_qty_sheet'] ?? 0);
$grandTotalReceive += (int) ($row['received_qty_sheet'] ?? 0);
}
return $this->render(
'입고 현황',
'bag/receiving_status',
compact(
'startDate',
'endDate',
'companyIdx',
'bagCode',
'receiveType',
'companies',
'bagCodeOptions',
'rows',
'groupTotals',
'grandTotalReceive'
)
);
}
public function receivingStatusExport(): RedirectResponse
{
helper(['admin', 'export']);
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
}
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
$companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
$bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
$receiveType = (string) ($this->request->getGet('receive_type') ?? 'all');
if (! in_array($receiveType, ['all', 'completed', 'pending'], true)) {
$receiveType = 'all';
}
$rows = $this->buildReceivingStatusRows($lgIdx, $startDate, $endDate, $companyIdx, $bagCode, $receiveType);
$exportRows = [];
foreach ($rows as $row) {
$exportRows[] = [
(string) ($row['display_date'] ?? ''),
(string) ($row['bag_name'] ?? ''),
(int) ($row['received_qty_sheet'] ?? 0),
(string) ($row['order_date'] ?? ''),
(int) ($row['order_qty_sheet'] ?? 0),
(string) ($row['order_no'] ?? ''),
(string) ($row['company_name'] ?? ''),
(string) ($row['receive_status_label'] ?? ''),
(string) ($row['agency_name'] ?? ''),
'',
];
}
export_xlsx(
'입고현황_' . date('Ymd'),
'입고현황',
['입고일자', '품명', '입고수량', '발주일자', '발주수량', '발주번호', '제작업체', '입고여부', '입고처', '비고'],
$exportRows
);
}
/**
* 미입고 잔량이 있는 발주 LOT 목록(스캐너 입고용 드롭다운).
*
* @return list<array{lot_no: string, bo_idx: int, order_date: string, company_name: string, pending_lines: int}>
*/
private function buildReceivingPendingLotChoices(int $lgIdx, int $companyIdx = 0): array
{
$rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, '');
$byLot = [];
foreach ($rows as $r) {
$lot = (string) ($r['lot_no'] ?? '');
if ($lot === '') {
continue;
}
if (! isset($byLot[$lot])) {
$byLot[$lot] = [
'lot_no' => $lot,
'bo_idx' => (int) ($r['bo_idx'] ?? 0),
'order_date' => (string) ($r['order_date'] ?? ''),
'company_name' => (string) ($r['company_name'] ?? ''),
'pending_lines' => 0,
];
}
$byLot[$lot]['pending_lines']++;
}
$list = array_values($byLot);
usort($list, static function (array $a, array $b): int {
$da = (string) ($a['order_date'] ?? '');
$db = (string) ($b['order_date'] ?? '');
if ($da === $db) {
return strcmp((string) ($b['lot_no'] ?? ''), (string) ($a['lot_no'] ?? ''));
}
return strcmp($db, $da);
});
return $list;
}
private function sanitizeLotNoForReceiving(int $lgIdx, int $companyIdx, string $lotNo): string
{
$lotNo = trim($lotNo);
if ($lotNo === '') {
return '';
}
foreach ($this->buildReceivingPendingLotChoices($lgIdx, $companyIdx) as $choice) {
if ((string) ($choice['lot_no'] ?? '') === $lotNo) {
return $lotNo;
}
}
return '';
}
/**
* 선택 조건(제작업체 + LOT)에 해당하는 미입고 품목(봉투) 목록 — 조회 조건 드롭다운용.
*
* @return list<array{bag_code: string, bag_name: string}>
*/
private function receivingBagFilterOptions(int $lgIdx, int $companyIdx, string $lotNo = ''): array
{
if ($companyIdx <= 0) {
return [];
}
$allForFilter = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, $lotNo);
$byCode = [];
foreach ($allForFilter as $r) {
$c = (string) ($r['bag_code'] ?? '');
if ($c === '') {
continue;
}
if (! isset($byCode[$c])) {
$byCode[$c] = (string) ($r['bag_name'] ?? '');
}
}
$list = [];
foreach ($byCode as $code => $name) {
$list[] = ['bag_code' => $code, 'bag_name' => $name];
}
usort($list, static fn (array $a, array $b): int => strcmp($a['bag_name'], $b['bag_name']));
return $list;
}
private function sanitizeBagCodeForReceiving(int $lgIdx, int $companyIdx, string $lotNo, string $bagCode): string
{
$bagCode = trim($bagCode);
if ($bagCode === '' || $companyIdx <= 0) {
return '';
}
foreach ($this->receivingBagFilterOptions($lgIdx, $companyIdx, $lotNo) as $opt) {
if ($opt['bag_code'] === $bagCode) {
return $bagCode;
}
}
return '';
}
/**
* 입고 대상 후보(LOT-봉투행) 생성.
*
* @param string $lotNo 빈 문자열이면 LOT 제한 없음. 지정 시 해당 LOT(최신 헤드) 발주만.
*/
private function buildReceivingCandidateRows(int $lgIdx, int $companyIdx = 0, string $bagCode = '', bool $onlyPending = true, string $lotNo = ''): array
{
$orderBuilder = model(BagOrderModel::class)
->where('bo_lg_idx', $lgIdx)
->whereLatestHead($lgIdx)
->where('bo_status', 'normal')
->orderBy('bo_order_date', 'DESC')
->orderBy('bo_idx', 'DESC');
if ($lotNo !== '') {
$orderBuilder->where('bo_lot_no', $lotNo);
}
if ($companyIdx > 0) {
$orderBuilder->where('bo_company_idx', $companyIdx);
}
$orders = $orderBuilder->findAll();
if (empty($orders)) {
return [];
}
$orderIds = array_map(static fn($o) => (int) ($o->bo_idx ?? 0), $orders);
$companyMap = [];
foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->findAll() as $company) {
$companyMap[(int) ($company->cp_idx ?? 0)] = [
'name' => (string) ($company->cp_name ?? ''),
'rep' => (string) ($company->cp_rep_name ?? ''),
];
}
$agencyMap = [];
foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $agency) {
$agencyMap[(int) ($agency->sa_idx ?? 0)] = (string) ($agency->sa_name ?? '');
}
$unitMap = [];
foreach (model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll() as $unit) {
$unitMap[(string) ($unit->pu_bag_code ?? '')] = [
'pack_per_sheet' => (int) ($unit->pu_pack_per_sheet ?? 1),
'total_per_box' => (int) ($unit->pu_total_per_box ?? 1),
];
}
$itemBuilder = model(BagOrderItemModel::class)->whereIn('boi_bo_idx', $orderIds);
if ($bagCode !== '') {
$itemBuilder->where('boi_bag_code', $bagCode);
}
$items = $itemBuilder->orderBy('boi_bo_idx', 'DESC')->orderBy('boi_idx', 'ASC')->findAll();
$receivedRows = model(BagReceivingModel::class)
->select('br_bo_idx, br_bag_code, SUM(br_qty_sheet) as recv_qty_sheet, MAX(br_receive_date) as last_receive_date')
->where('br_lg_idx', $lgIdx)
->whereIn('br_bo_idx', $orderIds)
->groupBy('br_bo_idx, br_bag_code')
->findAll();
$receivedMap = [];
foreach ($receivedRows as $recv) {
$receivedMap[(int) ($recv->br_bo_idx ?? 0) . '|' . (string) ($recv->br_bag_code ?? '')] = [
'recv_qty_sheet' => (int) ($recv->recv_qty_sheet ?? 0),
'last_receive_date' => (string) ($recv->last_receive_date ?? ''),
];
}
$orderMap = [];
foreach ($orders as $order) {
$orderMap[(int) ($order->bo_idx ?? 0)] = $order;
}
$rows = [];
foreach ($items as $item) {
$boIdx = (int) ($item->boi_bo_idx ?? 0);
if (! isset($orderMap[$boIdx])) {
continue;
}
$order = $orderMap[$boIdx];
$itemBagCode = (string) ($item->boi_bag_code ?? '');
$recv = $receivedMap[$boIdx . '|' . $itemBagCode] ?? ['recv_qty_sheet' => 0, 'last_receive_date' => ''];
$orderQtySheet = (int) ($item->boi_qty_sheet ?? 0);
$receivedQtySheet = min($orderQtySheet, (int) ($recv['recv_qty_sheet'] ?? 0));
$pendingQtySheet = max(0, $orderQtySheet - $receivedQtySheet);
if ($onlyPending && $pendingQtySheet <= 0) {
continue;
}
$unit = $unitMap[$itemBagCode] ?? ['pack_per_sheet' => 1, 'total_per_box' => 1];
$companyInfo = $companyMap[(int) ($order->bo_company_idx ?? 0)] ?? ['name' => '', 'rep' => ''];
$rows[] = [
'row_key' => $boIdx . '|' . $itemBagCode,
'bo_idx' => $boIdx,
'order_no' => sprintf('%06d', $boIdx),
'lot_no' => (string) ($order->bo_lot_no ?? ''),
'order_date' => (string) ($order->bo_order_date ?? ''),
'company_name' => (string) ($companyInfo['name'] ?? ''),
'company_rep_name' => (string) ($companyInfo['rep'] ?? ''),
'agency_name' => (string) ($agencyMap[(int) ($order->bo_agency_idx ?? 0)] ?? ''),
'bag_code' => $itemBagCode,
'bag_name' => (string) ($item->boi_bag_name ?? ''),
'order_qty_sheet' => $orderQtySheet,
'received_qty_sheet' => $receivedQtySheet,
'pending_qty_sheet' => $pendingQtySheet,
'pack_per_sheet' => max(1, (int) ($unit['pack_per_sheet'] ?? 1)),
'total_per_box' => max(1, (int) ($unit['total_per_box'] ?? 1)),
'last_receive_date' => (string) ($recv['last_receive_date'] ?? ''),
];
}
return $rows;
}
private function buildReceivingStatusRows(
int $lgIdx,
string $startDate,
string $endDate,
int $companyIdx,
string $bagCode,
string $receiveType
): array {
$rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, $bagCode, false, '');
$filtered = [];
foreach ($rows as $row) {
$pendingQty = (int) ($row['pending_qty_sheet'] ?? 0);
$isCompleted = $pendingQty <= 0;
if ($receiveType === 'completed' && ! $isCompleted) {
continue;
}
if ($receiveType === 'pending' && $isCompleted) {
continue;
}
$displayDate = (string) ($row['last_receive_date'] ?? '');
if ($displayDate === '') {
$displayDate = (string) ($row['order_date'] ?? '');
}
if ($startDate !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) && $displayDate < $startDate) {
continue;
}
if ($endDate !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) && $displayDate > $endDate) {
continue;
}
$row['display_date'] = $displayDate;
$row['receive_status_label'] = $isCompleted ? '완료' : '미완료';
$filtered[] = $row;
}
usort($filtered, static function (array $a, array $b): int {
$da = (string) ($a['display_date'] ?? '');
$db = (string) ($b['display_date'] ?? '');
if ($da === $db) {
return strcmp((string) ($a['bag_name'] ?? ''), (string) ($b['bag_name'] ?? ''));
}
return strcmp($da, $db);
});
return $filtered;
}
// --- 판매 등록 ---
public function saleCreate(): string
{
helper('admin');
$lgIdx = $this->lgIdx();
$shops = $lgIdx ? model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll() : [];
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
return $this->render('판매 등록', 'bag/create_bag_sale', compact('shops', 'bagCodes'));
}
public function saleStore()
{
$admin = new \App\Controllers\Admin\BagSale();
$admin->initController($this->request, $this->response, service('logger'));
$result = $admin->store();
if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) {
return redirect()->to(site_url('bag/sales'))->with('success', session()->getFlashdata('success'))->with('errors', session()->getFlashdata('errors'));
}
return redirect()->to(site_url('bag/sales'))->with('success', '판매 등록되었습니다.');
}
// --- 주문 접수 ---
public function shopOrderCreate(): string
{
helper('admin');
$lgIdx = $this->lgIdx();
$shops = $lgIdx ? model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll() : [];
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
return $this->render('주문 접수', 'bag/create_shop_order', compact('shops', 'bagCodes'));
}
public function shopOrderStore()
{
$admin = new \App\Controllers\Admin\ShopOrder();
$admin->initController($this->request, $this->response, service('logger'));
$result = $admin->store();
if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) {
return redirect()->to(site_url('bag/sales'))->with('success', session()->getFlashdata('success'))->with('errors', session()->getFlashdata('errors'));
}
return redirect()->to(site_url('bag/sales'))->with('success', '주문 접수되었습니다.');
}
}