Files
jongryangje/app/Controllers/Admin/SalesReport.php

3165 lines
116 KiB
PHP
Raw Normal View History

<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\BagIssueModel;
use App\Models\BagReceivingModel;
use App\Models\BagInventoryModel;
use App\Models\CodeDetailModel;
use App\Models\CodeKindModel;
use App\Models\PackagingUnitModel;
use App\Models\DesignatedShopModel;
use App\Models\LocalGovernmentModel;
use App\Models\SalesAgencyModel;
class SalesReport extends BaseController
{
/**
* P5-01 / PWB-090101-001: 지정 판매소 판매 대장 (일자별·기간별, 엑셀·인쇄)
*/
public function salesLedger()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
$modeRaw = (string) ($this->request->getGet('mode') ?? 'daily');
$mode = $modeRaw === 'period' ? 'period' : 'daily';
$dsIdx = (int) ($this->request->getGet('ds_idx') ?? 0);
$saIdx = (int) ($this->request->getGet('sa_idx') ?? 0);
$export = (int) ($this->request->getGet('export') ?? 0) === 1;
$catGet = $this->request->getGet('cat');
$cats = is_array($catGet) ? array_values(array_filter(array_map('strval', $catGet))) : [];
if ($cats === [] || in_array('all', $cats, true)) {
$cats = [];
}
$db = \Config\Database::connect();
$hasBsFee = $db->fieldExists('bs_fee', 'bag_sale');
$lgRow = model(LocalGovernmentModel::class)->find($lgIdx);
$lgName = $lgRow ? (string) ($lgRow->lg_name ?? '') : '';
$shops = model(DesignatedShopModel::class)
->where('ds_lg_idx', $lgIdx)
->orderBy('ds_name', 'ASC')
->orderBy('ds_shop_no', 'ASC')
->findAll();
$agencies = model(SalesAgencyModel::class)
->where('sa_lg_idx', $lgIdx)
->orderForDisplay()
->findAll();
$hasDsSaIdx = $db->fieldExists('ds_sa_idx', 'designated_shop');
$effectiveSaIdx = ($hasDsSaIdx && $saIdx > 0) ? $saIdx : 0;
$detailRows = $this->fetchSalesLedgerDetailRows(
$db,
$lgIdx,
$startDate,
$endDate,
$dsIdx,
$effectiveSaIdx,
$hasBsFee
);
$filtered = [];
foreach ($detailRows as $row) {
if ($this->ledgerRowMatchesCategories($row, $cats)) {
$filtered[] = $row;
}
}
if ($mode === 'daily') {
$built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee);
} else {
$built = $this->buildPeriodLedgerPresentation($filtered, $hasBsFee);
}
if ($export) {
$headers = $mode === 'daily'
? ['일자', '지정번호', '판매소명', '대표자', '소재지', '품명', '판매량', '판매금액', '수수료', '총액', '구분']
: ['지정번호', '판매소명', '대표자', '소재지', '품명', '판매량', '판매금액', '수수료', '총액', '구분'];
$exportRows = [];
foreach ($built['rows'] as $r) {
$kind = (string) ($r['kind'] ?? 'data');
$label = $kind === 'subtotal' ? '소계' : ($kind === 'grand' ? '합계' : '');
if ($mode === 'daily') {
$exportRows[] = [
(string) ($r['sale_date'] ?? ''),
(string) ($r['designation_no'] ?? ''),
(string) ($r['shop_name'] ?? ''),
(string) ($r['rep_name'] ?? ''),
(string) ($r['address'] ?? ''),
(string) ($r['product_name'] ?? ''),
(string) ($r['qty'] ?? ''),
(string) ($r['amount'] ?? ''),
(string) ($r['fee'] ?? ''),
(string) ($r['total'] ?? ''),
$label,
];
} else {
$exportRows[] = [
(string) ($r['designation_no'] ?? ''),
(string) ($r['shop_name'] ?? ''),
(string) ($r['rep_name'] ?? ''),
(string) ($r['address'] ?? ''),
(string) ($r['product_name'] ?? ''),
(string) ($r['qty'] ?? ''),
(string) ($r['amount'] ?? ''),
(string) ($r['fee'] ?? ''),
(string) ($r['total'] ?? ''),
$label,
];
}
}
$sheetName = $mode === 'daily' ? '일자별 판매대장' : '기간별 판매대장';
export_excel_2003_xml(
'지정판매소_판매대장_' . $startDate . '_' . $endDate,
$sheetName,
$headers,
$exportRows
);
}
$shopLabel = '전체';
if ($dsIdx > 0) {
foreach ($shops as $s) {
if ((int) ($s->ds_idx ?? 0) === $dsIdx) {
$shopLabel = (string) ($s->ds_name ?? '');
break;
}
}
}
$agencyModel = model(SalesAgencyModel::class);
$hasKindCode = $agencyModel->hasKindCodeColumns();
$agencyLabel = '전체';
if ($saIdx > 0) {
foreach ($agencies as $a) {
if ((int) ($a->sa_idx ?? 0) === $saIdx) {
$agencyLabel = $hasKindCode
? trim('[' . (string) ($a->sa_kind ?? '') . '] ' . (string) ($a->sa_code ?? '') . ' ' . (string) ($a->sa_name ?? ''))
: trim((string) ($a->sa_name ?? ''));
break;
}
}
}
$printSubtitleLines = [
trim($lgName . ' / 지정판매소: ' . $shopLabel . ' / 대행소: ' . $agencyLabel),
'조회기간: ' . $startDate . ' ~ ' . $endDate . ' · (단위: 매 / 원)',
];
return $this->renderWorkPage('지정 판매소 판매 대장', 'admin/sales_report/sales_ledger', [
'ledgerRows' => $built['rows'],
'saleLineCount' => $built['saleLineCount'],
'startDate' => $startDate,
'endDate' => $endDate,
'mode' => $mode,
'dsIdx' => $dsIdx,
'saIdx' => $saIdx,
'cats' => $cats,
'shops' => $shops,
'agencies' => $agencies,
'lgName' => $lgName,
'filterShopLabel' => $shopLabel,
'filterAgencyLabel' => $agencyLabel,
'printSubtitleLines' => $printSubtitleLines,
]);
}
/**
* @return list<object>
*/
private function fetchSalesLedgerDetailRows(
\CodeIgniter\Database\BaseConnection $db,
int $lgIdx,
string $startDate,
string $endDate,
int $dsIdx,
int $saIdx,
bool $hasBsFee
): array {
$feeExpr = $hasBsFee ? 'COALESCE(bs.bs_fee, 0)' : '0';
$select = 'bs.bs_sale_date, bs.bs_ds_idx, bs.bs_bag_code, bs.bs_bag_name, '
. 'ABS(bs.bs_qty) AS line_qty, bs.bs_amount AS line_amount, ' . $feeExpr . ' AS line_fee, '
. 'COALESCE(ds.ds_shop_no, \'\') AS ds_shop_no, COALESCE(ds.ds_name, bs.bs_ds_name) AS ds_name, '
. 'COALESCE(ds.ds_rep_name, \'\') AS ds_rep_name, '
. "TRIM(CONCAT(COALESCE(ds.ds_addr,''), ' ', COALESCE(ds.ds_addr_detail,''))) AS ds_addr, "
. 'COALESCE(ds.ds_zone_code, \'\') AS ds_zone_code, COALESCE(ds.ds_gugun_code, \'\') AS ds_gugun_code';
$builder = $db->table('bag_sale bs');
$builder->select($select, false);
$builder->join('designated_shop ds', 'ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx', 'left');
$builder->where('bs.bs_lg_idx', $lgIdx);
$builder->where('bs.bs_sale_date >=', $startDate);
$builder->where('bs.bs_sale_date <=', $endDate);
$builder->where('bs.bs_type', 'sale');
if ($dsIdx > 0) {
$builder->where('bs.bs_ds_idx', $dsIdx);
}
if ($saIdx > 0) {
$builder->where('ds.ds_sa_idx', $saIdx);
}
$builder->orderBy('bs.bs_sale_date', 'ASC');
$builder->orderBy('ds.ds_shop_no', 'ASC');
$builder->orderBy('bs.bs_ds_idx', 'ASC');
$builder->orderBy('bs.bs_bag_code', 'ASC');
return $builder->get()->getResult();
}
/**
* @param list<string> $cats 배열이면 전체
*/
private function ledgerRowMatchesCategories(object $row, array $cats): bool
{
if ($cats === []) {
return true;
}
$name = (string) ($row->bs_bag_name ?? '');
$code = (string) ($row->bs_bag_code ?? '');
$cat = $this->classifyLedgerProductCategory($name, $code);
return in_array($cat, $cats, true);
}
private function classifyLedgerProductCategory(string $bagName, string $bagCode): string
{
$s = $bagName . ' ' . $bagCode;
if (mb_strpos($s, '스티커') !== false) {
return 'sticker';
}
if (mb_strpos($s, '용기') !== false) {
return 'container';
}
if (mb_strpos($s, '공동주택') !== false) {
return 'apt';
}
if (mb_strpos($s, '공공') !== false) {
return 'public_use';
}
if (mb_strpos($s, '재사용') !== false) {
return 'reuse';
}
if (mb_strpos($s, '음식물') !== false) {
return 'food';
}
if (mb_strpos($s, '폐기물') !== false) {
return 'waste';
}
return 'general';
}
/**
* @param list<object> $rows
* @return array{rows: list<array<string,mixed>>, saleLineCount: int}
*/
private function buildDailyLedgerPresentation(array $rows, bool $hasBsFee): array
{
$out = [];
$saleLineCount = count($rows);
$grandQty = $grandAmt = $grandFee = $grandTot = 0;
$prevKey = null;
$subQty = $subAmt = $subFee = $subTot = 0;
$flushSubtotal = static function () use (&$out, &$subQty, &$subAmt, &$subFee, &$subTot): void {
if ($subQty === 0 && $subAmt === 0 && $subFee === 0 && $subTot === 0) {
return;
}
$out[] = [
'kind' => 'subtotal',
'sale_date' => '',
'designation_no' => '',
'shop_name' => '',
'rep_name' => '',
'address' => '',
'product_name' => '소 계',
'qty' => number_format($subQty),
'amount' => number_format((int) round($subAmt)),
'fee' => number_format((int) round($subFee)),
'total' => number_format((int) round($subTot)),
];
$subQty = $subAmt = $subFee = $subTot = 0;
};
foreach ($rows as $row) {
$date = (string) $row->bs_sale_date;
$dsId = (int) ($row->bs_ds_idx ?? 0);
$key = $date . '|' . $dsId;
if ($prevKey !== null && $key !== $prevKey) {
$flushSubtotal();
}
$prevKey = $key;
$qty = (int) round((float) ($row->line_qty ?? 0));
$amt = (float) ($row->line_amount ?? 0);
$fee = $hasBsFee ? (float) ($row->line_fee ?? 0) : 0.0;
$total = $amt + $fee;
$designation = $this->formatDesignationNo(
(string) ($row->ds_zone_code ?? ''),
(string) ($row->ds_gugun_code ?? ''),
(string) ($row->ds_shop_no ?? '')
);
$out[] = [
'kind' => 'data',
'sale_date' => $date,
'designation_no' => $designation,
'shop_name' => (string) ($row->ds_name ?? ''),
'rep_name' => (string) ($row->ds_rep_name ?? ''),
'address' => trim((string) ($row->ds_addr ?? '')),
'product_name' => (string) ($row->bs_bag_name ?? ''),
'qty' => number_format($qty),
'amount' => number_format((int) round($amt)),
'fee' => $fee != 0.0 ? number_format((int) round($fee)) : '',
'total' => number_format((int) round($total)),
];
$subQty += $qty;
$subAmt += $amt;
$subFee += $fee;
$subTot += $total;
$grandQty += $qty;
$grandAmt += $amt;
$grandFee += $fee;
$grandTot += $total;
}
$flushSubtotal();
$out[] = [
'kind' => 'grand',
'sale_date' => '',
'designation_no' => '',
'shop_name' => '',
'rep_name' => '',
'address' => '',
'product_name' => '합 계',
'qty' => number_format($grandQty),
'amount' => number_format((int) round($grandAmt)),
'fee' => $grandFee != 0.0 ? number_format((int) round($grandFee)) : '',
'total' => number_format((int) round($grandTot)),
];
return ['rows' => $out, 'saleLineCount' => $saleLineCount];
}
/**
* @param list<object> $rows
* @return array{rows: list<array<string,mixed>>, saleLineCount: int}
*/
private function buildPeriodLedgerPresentation(array $rows, bool $hasBsFee): array
{
$groups = [];
foreach ($rows as $row) {
$dsId = (int) ($row->bs_ds_idx ?? 0);
$code = (string) ($row->bs_bag_code ?? '');
$name = (string) ($row->bs_bag_name ?? '');
$gk = $dsId . '|' . $code . '|' . $name;
if (! isset($groups[$gk])) {
$groups[$gk] = [
'bs_ds_idx' => $dsId,
'ds_shop_no' => (string) ($row->ds_shop_no ?? ''),
'ds_name' => (string) ($row->ds_name ?? ''),
'ds_rep_name' => (string) ($row->ds_rep_name ?? ''),
'ds_addr' => trim((string) ($row->ds_addr ?? '')),
'ds_zone_code' => (string) ($row->ds_zone_code ?? ''),
'ds_gugun_code' => (string) ($row->ds_gugun_code ?? ''),
'bs_bag_code' => $code,
'bs_bag_name' => $name,
'qty' => 0,
'amount' => 0.0,
'fee' => 0.0,
];
}
$groups[$gk]['qty'] += (int) round((float) ($row->line_qty ?? 0));
$groups[$gk]['amount'] += (float) ($row->line_amount ?? 0);
$groups[$gk]['fee'] += $hasBsFee ? (float) ($row->line_fee ?? 0) : 0.0;
}
$saleLineCount = count($rows);
uasort($groups, static function (array $a, array $b): int {
$cmp = strcmp((string) ($a['ds_shop_no'] ?? ''), (string) ($b['ds_shop_no'] ?? ''));
if ($cmp !== 0) {
return $cmp;
}
$cmp2 = strcmp((string) ($a['bs_bag_code'] ?? ''), (string) ($b['bs_bag_code'] ?? ''));
return $cmp2 !== 0 ? $cmp2 : strcmp((string) ($a['bs_bag_name'] ?? ''), (string) ($b['bs_bag_name'] ?? ''));
});
$out = [];
$prevDs = null;
$subQty = $subAmt = $subFee = $subTot = 0;
$grandQty = $grandAmt = $grandFee = $grandTot = 0;
$flushSub = static function () use (&$out, &$subQty, &$subAmt, &$subFee, &$subTot): void {
if ($subQty === 0 && $subAmt === 0 && $subFee === 0 && $subTot === 0) {
return;
}
$out[] = [
'kind' => 'subtotal',
'designation_no' => '',
'shop_name' => '',
'rep_name' => '',
'address' => '',
'product_name' => '소 계',
'qty' => number_format($subQty),
'amount' => number_format((int) round($subAmt)),
'fee' => number_format((int) round($subFee)),
'total' => number_format((int) round($subTot)),
];
$subQty = $subAmt = $subFee = $subTot = 0;
};
foreach ($groups as $g) {
$dsId = (int) ($g['bs_ds_idx'] ?? 0);
if ($prevDs !== null && $dsId !== $prevDs) {
$flushSub();
}
$prevDs = $dsId;
$qty = (int) ($g['qty'] ?? 0);
$amt = (float) ($g['amount'] ?? 0);
$fee = (float) ($g['fee'] ?? 0);
$total = $amt + $fee;
$designation = $this->formatDesignationNo(
(string) ($g['ds_zone_code'] ?? ''),
(string) ($g['ds_gugun_code'] ?? ''),
(string) ($g['ds_shop_no'] ?? '')
);
$out[] = [
'kind' => 'data',
'designation_no' => $designation,
'shop_name' => (string) ($g['ds_name'] ?? ''),
'rep_name' => (string) ($g['ds_rep_name'] ?? ''),
'address' => (string) ($g['ds_addr'] ?? ''),
'product_name' => (string) ($g['bs_bag_name'] ?? ''),
'qty' => number_format($qty),
'amount' => number_format((int) round($amt)),
'fee' => $fee != 0.0 ? number_format((int) round($fee)) : '',
'total' => number_format((int) round($total)),
];
$subQty += $qty;
$subAmt += $amt;
$subFee += $fee;
$subTot += $total;
$grandQty += $qty;
$grandAmt += $amt;
$grandFee += $fee;
$grandTot += $total;
}
$flushSub();
$out[] = [
'kind' => 'grand',
'designation_no' => '',
'shop_name' => '',
'rep_name' => '',
'address' => '',
'product_name' => '합 계',
'qty' => number_format($grandQty),
'amount' => number_format((int) round($grandAmt)),
'fee' => $grandFee != 0.0 ? number_format((int) round($grandFee)) : '',
'total' => number_format((int) round($grandTot)),
];
return ['rows' => $out, 'saleLineCount' => $saleLineCount];
}
private function formatDesignationNo(string $zoneCode, string $gugunCode, string $shopNo): string
{
$head = trim($zoneCode) !== '' ? trim($zoneCode) : trim($gugunCode);
$shopNo = trim($shopNo);
if ($head !== '' && $shopNo !== '') {
return $head . ' - ' . $shopNo;
}
if ($shopNo !== '') {
return $shopNo;
}
return $head;
}
/**
* P5-02: 일계표 (일계 + 당월 누계, 대행소·구분, 엑셀·인쇄)
*/
public function dailySummary()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$date = (string) ($this->request->getGet('date') ?? date('Y-m-d'));
$saIdx = (int) ($this->request->getGet('sa_idx') ?? 0);
$catFilter = trim((string) ($this->request->getGet('cat') ?? ''));
$export = (int) ($this->request->getGet('export') ?? 0) === 1;
$monthStart = date('Y-m-01', strtotime($date));
$db = \Config\Database::connect();
$hasBsFee = $db->fieldExists('bs_fee', 'bag_sale');
$hasDsSaIdx = $db->fieldExists('ds_sa_idx', 'designated_shop');
$effectiveSaIdx = ($hasDsSaIdx && $saIdx > 0) ? $saIdx : 0;
$agencies = model(SalesAgencyModel::class)
->where('sa_lg_idx', $lgIdx)
->orderForDisplay()
->findAll();
$aggRows = $this->fetchDailySummaryBagAggregates(
$db,
$lgIdx,
$date,
$monthStart,
$hasBsFee,
$effectiveSaIdx
);
$catOrder = ['general', 'food', 'sticker', 'reuse', 'apt', 'public_use', 'container', 'waste'];
$catLabels = [
'general' => '일반용',
'food' => '음식물',
'sticker' => '스티커',
'reuse' => '재사용',
'apt' => '공동주택용',
'public_use' => '공공용',
'container' => '용기',
'waste' => '폐기물',
];
$byCat = [];
foreach ($catOrder as $ck) {
$byCat[$ck] = [];
}
foreach ($aggRows as $row) {
$name = (string) ($row->bs_bag_name ?? '');
$code = (string) ($row->bs_bag_code ?? '');
$ck = $this->classifyLedgerProductCategory($name, $code);
if ($catFilter !== '' && $ck !== $catFilter) {
continue;
}
$dQty = (int) round((float) ($row->d_qty ?? 0));
$dAmt = (float) ($row->d_amt ?? 0);
$dFee = $hasBsFee ? (float) ($row->d_fee ?? 0) : 0.0;
$mQty = (int) round((float) ($row->m_qty ?? 0));
$mAmt = (float) ($row->m_amt ?? 0);
$mFee = $hasBsFee ? (float) ($row->m_fee ?? 0) : 0.0;
$byCat[$ck][] = [
'bag_code' => $code,
'bag_name' => $name !== '' ? $name : $code,
'd_qty' => $dQty,
'd_amt' => $dAmt,
'd_fee' => $dFee,
'd_levy' => $dAmt + $dFee,
'm_qty' => $mQty,
'm_amt' => $mAmt,
'm_fee' => $mFee,
'm_levy' => $mAmt + $mFee,
];
}
$tableRows = [];
$gD = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0];
$gM = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0];
foreach ($catOrder as $ck) {
$items = $byCat[$ck] ?? [];
if ($items === []) {
continue;
}
usort($items, static fn (array $a, array $b): int => strcmp((string) ($a['bag_code'] ?? ''), (string) ($b['bag_code'] ?? '')));
$sD = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0];
$sM = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0];
foreach ($items as $it) {
$tableRows[] = array_merge(['kind' => 'data', 'cat_key' => $ck, 'cat_label' => $catLabels[$ck] ?? $ck], $it);
$sD['qty'] += $it['d_qty'];
$sD['amt'] += $it['d_amt'];
$sD['fee'] += $it['d_fee'];
$sD['levy'] += $it['d_levy'];
$sM['qty'] += $it['m_qty'];
$sM['amt'] += $it['m_amt'];
$sM['fee'] += $it['m_fee'];
$sM['levy'] += $it['m_levy'];
}
$tableRows[] = [
'kind' => 'subtotal',
'cat_key' => $ck,
'cat_label' => $catLabels[$ck] ?? $ck,
'bag_name' => '소 계',
'bag_code' => '',
'd_qty' => $sD['qty'],
'd_amt' => $sD['amt'],
'd_fee' => $sD['fee'],
'd_levy' => $sD['levy'],
'm_qty' => $sM['qty'],
'm_amt' => $sM['amt'],
'm_fee' => $sM['fee'],
'm_levy' => $sM['levy'],
];
$gD['qty'] += $sD['qty'];
$gD['amt'] += $sD['amt'];
$gD['fee'] += $sD['fee'];
$gD['levy'] += $sD['levy'];
$gM['qty'] += $sM['qty'];
$gM['amt'] += $sM['amt'];
$gM['fee'] += $sM['fee'];
$gM['levy'] += $sM['levy'];
}
if ($tableRows !== []) {
$tableRows[] = [
'kind' => 'grand',
'cat_key' => '',
'cat_label' => '',
'bag_name' => '합 계',
'bag_code' => '',
'd_qty' => $gD['qty'],
'd_amt' => $gD['amt'],
'd_fee' => $gD['fee'],
'd_levy' => $gD['levy'],
'm_qty' => $gM['qty'],
'm_amt' => $gM['amt'],
'm_fee' => $gM['fee'],
'm_levy' => $gM['levy'],
];
}
$lgRow = model(LocalGovernmentModel::class)->find($lgIdx);
$lgName = $lgRow ? (string) ($lgRow->lg_name ?? '') : '';
$agencyLabel = '전체';
if ($saIdx > 0) {
foreach ($agencies as $a) {
if ((int) ($a->sa_idx ?? 0) === $saIdx) {
$agencyLabel = trim((string) ($a->sa_name ?? ''));
break;
}
}
}
$catLabelFilter = ($catFilter !== '' && isset($catLabels[$catFilter])) ? $catLabels[$catFilter] : '전체';
if ($export) {
$headers = [
'구분',
'봉투종류',
'일계_수량',
'일계_판매금액',
'일계_수수료',
'일계_징수액',
'누계(월)_수량',
'누계(월)_판매금액',
'누계(월)_수수료',
'누계(월)_징수액',
];
$exportRows = [];
foreach ($tableRows as $r) {
$exportRows[] = [
(string) ($r['cat_label'] ?? ''),
(string) ($r['bag_name'] ?? ''),
(string) ($r['d_qty'] ?? ''),
(string) (int) round((float) ($r['d_amt'] ?? 0)),
(string) (int) round((float) ($r['d_fee'] ?? 0)),
(string) (int) round((float) ($r['d_levy'] ?? 0)),
(string) ($r['m_qty'] ?? ''),
(string) (int) round((float) ($r['m_amt'] ?? 0)),
(string) (int) round((float) ($r['m_fee'] ?? 0)),
(string) (int) round((float) ($r['m_levy'] ?? 0)),
];
}
export_excel_2003_xml(
'일계표_' . $date,
'일계표',
$headers,
$exportRows
);
}
$printExtraLines = [
'조회일: ' . $date . ' · 대행소: ' . $agencyLabel . ' · 구분: ' . $catLabelFilter,
'누계 구간: ' . $monthStart . ' ~ ' . $date . ' · (단위: 매 / 원)',
];
return $this->renderWorkPage('일계표', 'admin/sales_report/daily_summary', [
'tableRows' => $tableRows,
'date' => $date,
'monthStart' => $monthStart,
'saIdx' => $saIdx,
'catFilter' => $catFilter,
'agencies' => $agencies,
'catLabels' => $catLabels,
'hasBsFee' => $hasBsFee,
'lgName' => $lgName,
'agencyLabel' => $agencyLabel,
'catLabelFilter' => $catLabelFilter,
'printExtraLines' => $printExtraLines,
]);
}
/**
* 품목(봉투코드·명) 당일·당월 누계 집계 (판매만)
*
* @return list<object>
*/
private function fetchDailySummaryBagAggregates(
\CodeIgniter\Database\BaseConnection $db,
int $lgIdx,
string $date,
string $monthStart,
bool $hasBsFee,
int $saIdx
): array {
$feeDaily = $hasBsFee
? 'SUM(CASE WHEN bs.bs_sale_date = ? THEN COALESCE(bs.bs_fee, 0) ELSE 0 END)'
: 'CAST(0 AS DECIMAL(14,2))';
$feeMonth = $hasBsFee
? 'SUM(CASE WHEN bs.bs_sale_date BETWEEN ? AND ? THEN COALESCE(bs.bs_fee, 0) ELSE 0 END)'
: 'CAST(0 AS DECIMAL(14,2))';
$sql = 'SELECT bs.bs_bag_code, bs.bs_bag_name, '
. 'SUM(CASE WHEN bs.bs_sale_date = ? THEN ABS(bs.bs_qty) ELSE 0 END) AS d_qty, '
. 'SUM(CASE WHEN bs.bs_sale_date = ? THEN bs.bs_amount ELSE 0 END) AS d_amt, '
. $feeDaily . ' AS d_fee, '
. 'SUM(CASE WHEN bs.bs_sale_date BETWEEN ? AND ? THEN ABS(bs.bs_qty) ELSE 0 END) AS m_qty, '
. 'SUM(CASE WHEN bs.bs_sale_date BETWEEN ? AND ? THEN bs.bs_amount ELSE 0 END) AS m_amt, '
. $feeMonth . ' AS m_fee '
. 'FROM bag_sale bs '
. 'LEFT JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx '
. 'WHERE bs.bs_lg_idx = ? AND bs.bs_type = ? AND bs.bs_sale_date BETWEEN ? AND ? ';
$params = [
$date,
$date,
];
if ($hasBsFee) {
$params[] = $date;
}
$params[] = $monthStart;
$params[] = $date;
$params[] = $monthStart;
$params[] = $date;
if ($hasBsFee) {
$params[] = $monthStart;
$params[] = $date;
}
$params[] = $lgIdx;
$params[] = 'sale';
$params[] = $monthStart;
$params[] = $date;
if ($saIdx > 0) {
$sql .= ' AND ds.ds_sa_idx = ? ';
$params[] = $saIdx;
}
$sql .= 'GROUP BY bs.bs_bag_code, bs.bs_bag_name ORDER BY bs.bs_bag_code';
return $db->query($sql, $params)->getResult();
}
private function isFoodOrStickerLedgerCategory(string $catKey): bool
{
return $catKey === 'food' || $catKey === 'sticker';
}
/**
* 기간별 판매현황 집계 (일자+품목 또는 품목만)
*
* @return list<object>
*/
private function fetchPeriodSalesAggregates(
\CodeIgniter\Database\BaseConnection $db,
int $lgIdx,
string $startDate,
string $endDate,
bool $hasBsFee,
int $saIdx,
bool $byDaily
): array {
$feeExpr = $hasBsFee
? "SUM(CASE WHEN bs.bs_type = 'sale' THEN COALESCE(bs.bs_fee, 0) ELSE 0 END)"
: 'CAST(0 AS DECIMAL(14,2))';
$dateSelect = $byDaily ? 'bs.bs_sale_date AS bs_sale_date, ' : '';
$groupBy = $byDaily ? 'bs.bs_sale_date, bs.bs_bag_code, bs.bs_bag_name' : 'bs.bs_bag_code, bs.bs_bag_name';
$orderBy = $byDaily
? 'bs.bs_sale_date ASC, bs.bs_bag_code ASC'
: 'bs.bs_bag_code ASC';
$sql = 'SELECT ' . $dateSelect . 'bs.bs_bag_code, bs.bs_bag_name, '
. "SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) ELSE 0 END) AS s_qty, "
. "SUM(CASE WHEN bs.bs_type = 'sale' THEN bs.bs_amount ELSE 0 END) AS s_amt, "
. $feeExpr . ' AS s_fee, '
. "SUM(CASE WHEN bs.bs_type = 'return' THEN ABS(bs.bs_qty) ELSE 0 END) AS r_qty, "
. "SUM(CASE WHEN bs.bs_type = 'return' THEN ABS(bs.bs_amount) ELSE 0 END) AS r_amt "
. 'FROM bag_sale bs '
. 'LEFT JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx '
. "WHERE bs.bs_lg_idx = ? AND bs.bs_sale_date BETWEEN ? AND ? AND bs.bs_type IN ('sale','return') ";
$params = [$lgIdx, $startDate, $endDate];
if ($saIdx > 0) {
$sql .= ' AND ds.ds_sa_idx = ? ';
$params[] = $saIdx;
}
$sql .= 'GROUP BY ' . $groupBy . ' ORDER BY ' . $orderBy;
return $db->query($sql, $params)->getResult();
}
/**
* @param list<object> $rawRows
*
* @return array{lines: list<array<string,mixed>>, foot_all: array<string,int|float>, foot_bag: array<string,int|float>, foot_fs: array<string,int|float>}
*/
private function buildPeriodSalesPresentation(array $rawRows, bool $byDaily, string $catFilter, bool $hasBsFee): array
{
$empty = static fn (): array => [
's_qty' => 0, 's_amt' => 0.0, 's_fee' => 0.0, 's_levy' => 0.0,
'r_qty' => 0, 'r_amt' => 0.0,
't_qty' => 0, 't_amt' => 0.0, 't_fee' => 0.0, 't_levy' => 0.0,
];
$dataFlat = [];
foreach ($rawRows as $row) {
$name = (string) ($row->bs_bag_name ?? '');
$code = (string) ($row->bs_bag_code ?? '');
$ck = $this->classifyLedgerProductCategory($name, $code);
if ($catFilter !== '' && $ck !== $catFilter) {
continue;
}
$sQty = (int) round((float) ($row->s_qty ?? 0));
$sAmt = (float) ($row->s_amt ?? 0);
$sFee = $hasBsFee ? (float) ($row->s_fee ?? 0) : 0.0;
$sLev = $sAmt + $sFee;
$rQty = (int) round((float) ($row->r_qty ?? 0));
$rAmt = (float) ($row->r_amt ?? 0);
$tQty = $sQty - $rQty;
$tAmt = $sAmt - $rAmt;
$tFee = $sFee;
$tLev = $tAmt + $tFee;
$ymd = $byDaily ? (string) ($row->bs_sale_date ?? '') : '';
$dataFlat[] = [
'code' => $code,
'name' => $name !== '' ? $name : $code,
'ck' => $ck,
'ymd' => $ymd,
's_qty' => $sQty,
's_amt' => $sAmt,
's_fee' => $sFee,
's_levy' => $sLev,
'r_qty' => $rQty,
'r_amt' => $rAmt,
't_qty' => $tQty,
't_amt' => $tAmt,
't_fee' => $tFee,
't_levy' => $tLev,
];
}
$addBucket = static function (array $b, array $row): array {
$b['s_qty'] += $row['s_qty'];
$b['s_amt'] += $row['s_amt'];
$b['s_fee'] += $row['s_fee'];
$b['s_levy'] += $row['s_levy'];
$b['r_qty'] += $row['r_qty'];
$b['r_amt'] += $row['r_amt'];
$b['t_qty'] += $row['t_qty'];
$b['t_amt'] += $row['t_amt'];
$b['t_fee'] += $row['t_fee'];
$b['t_levy'] += $row['t_levy'];
return $b;
};
$footAll = $empty();
$footBag = $empty();
$footFs = $empty();
foreach ($dataFlat as $row) {
$footAll = $addBucket($footAll, $row);
if ($this->isFoodOrStickerLedgerCategory((string) $row['ck'])) {
$footFs = $addBucket($footFs, $row);
} else {
$footBag = $addBucket($footBag, $row);
}
}
if ($dataFlat === []) {
return [
'lines' => [],
'foot_all' => $footAll,
'foot_bag' => $footBag,
'foot_fs' => $footFs,
];
}
$lines = [];
if ($byDaily) {
$byDate = [];
foreach ($dataFlat as $row) {
$d = (string) $row['ymd'];
if ($d === '') {
continue;
}
$byDate[$d][] = $row;
}
ksort($byDate);
foreach ($byDate as $ymd => $items) {
if ($items === []) {
continue;
}
usort($items, static fn (array $a, array $b): int => strcmp((string) $a['code'], (string) $b['code']));
$blockLen = count($items) + 3;
$i = 0;
foreach ($items as $it) {
$lines[] = array_merge($it, [
'kind' => 'data',
'ymd_rowspan' => $i === 0 ? $blockLen : 0,
]);
++$i;
}
$dayAll = $empty();
$dayBag = $empty();
$dayFs = $empty();
foreach ($items as $it) {
$dayAll = $addBucket($dayAll, $it);
if ($this->isFoodOrStickerLedgerCategory((string) $it['ck'])) {
$dayFs = $addBucket($dayFs, $it);
} else {
$dayBag = $addBucket($dayBag, $it);
}
}
foreach (
[
['day_sub_all', '소 계', $dayAll],
['day_sub_bag', '봉투계', $dayBag],
['day_sub_fs', '음식물 / 스티커 계', $dayFs],
] as $trip
) {
$lines[] = [
'kind' => $trip[0],
'ymd' => '',
'ymd_rowspan' => 0,
'code' => '',
'name' => (string) $trip[1],
'ck' => '',
's_qty' => $trip[2]['s_qty'],
's_amt' => $trip[2]['s_amt'],
's_fee' => $trip[2]['s_fee'],
's_levy' => $trip[2]['s_levy'],
'r_qty' => $trip[2]['r_qty'],
'r_amt' => $trip[2]['r_amt'],
't_qty' => $trip[2]['t_qty'],
't_amt' => $trip[2]['t_amt'],
't_fee' => $trip[2]['t_fee'],
't_levy' => $trip[2]['t_levy'],
];
}
}
} else {
usort($dataFlat, static fn (array $a, array $b): int => strcmp((string) $a['code'], (string) $b['code']));
foreach ($dataFlat as $it) {
$lines[] = array_merge($it, [
'kind' => 'data',
'ymd_rowspan' => 0,
]);
}
}
foreach (
[
['foot_all', '합 계', $footAll],
['foot_bag', '봉투계', $footBag],
['foot_fs', '음식물 / 스티커 계', $footFs],
] as $ft
) {
$lines[] = [
'kind' => $ft[0],
'ymd' => '',
'ymd_rowspan' => 0,
'code' => '',
'name' => (string) $ft[1],
'ck' => '',
's_qty' => $ft[2]['s_qty'],
's_amt' => $ft[2]['s_amt'],
's_fee' => $ft[2]['s_fee'],
's_levy' => $ft[2]['s_levy'],
'r_qty' => $ft[2]['r_qty'],
'r_amt' => $ft[2]['r_amt'],
't_qty' => $ft[2]['t_qty'],
't_amt' => $ft[2]['t_amt'],
't_fee' => $ft[2]['t_fee'],
't_levy' => $ft[2]['t_levy'],
];
}
return [
'lines' => $lines,
'foot_all' => $footAll,
'foot_bag' => $footBag,
'foot_fs' => $footFs,
];
}
/**
* P5-03: 기간별 판매현황 (일자별·기간별 집계, 대행소·구분, 판매/반품/, 엑셀·인쇄)
*/
public function periodSales()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
if ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
$saIdx = (int) ($this->request->getGet('sa_idx') ?? 0);
$catFilter = trim((string) ($this->request->getGet('cat') ?? ''));
$mode = trim((string) ($this->request->getGet('mode') ?? 'daily'));
$byDaily = ($mode !== 'period');
$export = (int) ($this->request->getGet('export') ?? 0) === 1;
$db = \Config\Database::connect();
$hasBsFee = $db->fieldExists('bs_fee', 'bag_sale');
$hasDsSaIdx = $db->fieldExists('ds_sa_idx', 'designated_shop');
$effectiveSaIdx = ($hasDsSaIdx && $saIdx > 0) ? $saIdx : 0;
$agencies = model(SalesAgencyModel::class)
->where('sa_lg_idx', $lgIdx)
->orderForDisplay()
->findAll();
$raw = $this->fetchPeriodSalesAggregates($db, $lgIdx, $startDate, $endDate, $hasBsFee, $effectiveSaIdx, $byDaily);
$pres = $this->buildPeriodSalesPresentation($raw, $byDaily, $catFilter, $hasBsFee);
$lines = $pres['lines'];
$catLabels = [
'general' => '일반용',
'food' => '음식물',
'sticker' => '스티커',
'reuse' => '재사용',
'apt' => '공동주택용',
'public_use' => '공공용',
'container' => '용기',
'waste' => '폐기물',
];
$lgRow = model(LocalGovernmentModel::class)->find($lgIdx);
$lgName = $lgRow ? (string) ($lgRow->lg_name ?? '') : '';
$agencyLabel = '전체';
if ($saIdx > 0) {
foreach ($agencies as $a) {
if ((int) ($a->sa_idx ?? 0) === $saIdx) {
$agencyLabel = trim((string) ($a->sa_name ?? ''));
break;
}
}
}
$catLabelFilter = ($catFilter !== '' && isset($catLabels[$catFilter])) ? $catLabels[$catFilter] : '전체';
$modeLabel = $byDaily ? '일자별' : '기간별';
if ($export) {
$headers = [
'일자',
'품목',
'판매_수량',
'판매_판매금액',
'판매_수수료',
'판매_징수액',
'반품_수량',
'반품_금액',
'계_수량',
'계_판매금액',
'계_수수료',
'계_징수액',
];
$exportRows = [];
foreach ($lines as $ln) {
$kind = (string) ($ln['kind'] ?? '');
$ymd = (string) ($ln['ymd'] ?? '');
if (! $byDaily && $kind === 'data') {
$ymd = '';
}
$exportRows[] = [
$ymd,
(string) ($ln['name'] ?? ''),
(string) ($ln['s_qty'] ?? '0'),
(string) (int) round((float) ($ln['s_amt'] ?? 0)),
$hasBsFee ? (string) (int) round((float) ($ln['s_fee'] ?? 0)) : '',
(string) (int) round((float) ($ln['s_levy'] ?? 0)),
(string) ($ln['r_qty'] ?? '0'),
(string) (int) round((float) ($ln['r_amt'] ?? 0)),
(string) ($ln['t_qty'] ?? '0'),
(string) (int) round((float) ($ln['t_amt'] ?? 0)),
$hasBsFee ? (string) (int) round((float) ($ln['t_fee'] ?? 0)) : '',
(string) (int) round((float) ($ln['t_levy'] ?? 0)),
];
}
export_excel_2003_xml(
'기간별판매현황_' . $startDate . '_' . $endDate,
'기간별판매현황',
$headers,
$exportRows
);
}
$printExtraLines = [
'조회기간: ' . $startDate . ' ~ ' . $endDate . ' · 대행소: ' . $agencyLabel . ' · 구분: ' . $catLabelFilter . ' · 집계: ' . $modeLabel . ' · (단위: 매 / 원)',
];
return $this->renderWorkPage('기간별 판매현황', 'admin/sales_report/period_sales', [
'lines' => $lines,
'startDate' => $startDate,
'endDate' => $endDate,
'saIdx' => $saIdx,
'catFilter' => $catFilter,
'mode' => $byDaily ? 'daily' : 'period',
'agencies' => $agencies,
'catLabels' => $catLabels,
'hasBsFee' => $hasBsFee,
'lgName' => $lgName,
'agencyLabel' => $agencyLabel,
'catLabelFilter' => $catLabelFilter,
'printExtraLines' => $printExtraLines,
]);
}
/**
* P5-04: 판매 현황 (품목별 합계·월·분기, 구·군·대행소, 수수료·징수액, 엑셀·인쇄)
*/
public function yearlySales()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$year = (int) ($this->request->getGet('year') ?? date('Y'));
$year = max(2000, min(2100, $year));
$gugunCode = trim((string) ($this->request->getGet('gugun_code') ?? ''));
$saIdx = (int) ($this->request->getGet('sa_idx') ?? 0);
$export = (int) ($this->request->getGet('export') ?? 0) === 1;
$db = \Config\Database::connect();
$hasBsFee = $db->fieldExists('bs_fee', 'bag_sale');
$hasDsSaIdx = $db->fieldExists('ds_sa_idx', 'designated_shop');
$effectiveSaIdx = ($hasDsSaIdx && $saIdx > 0) ? $saIdx : 0;
$monthRows = $this->fetchYearlySalesMonthlyAggregates(
$db,
$lgIdx,
$year,
$gugunCode,
$effectiveSaIdx,
$hasBsFee
);
$agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll();
$gugunOptions = $this->yearlySalesGugunDropdownRows($lgIdx);
$built = $this->buildYearlySalesPresentation($monthRows, $hasBsFee);
$lgRow = model(LocalGovernmentModel::class)->find($lgIdx);
$lgName = $lgRow ? (string) ($lgRow->lg_name ?? '') : '';
$gugunLabel = '전체';
if ($gugunCode !== '') {
foreach ($gugunOptions as $g) {
if (($g['code'] ?? '') === $gugunCode) {
$gugunLabel = trim((string) ($g['name'] ?? $gugunCode));
break;
}
}
}
$agencyLabel = '전체';
if ($saIdx > 0) {
foreach ($agencies as $a) {
if ((int) ($a->sa_idx ?? 0) === $saIdx) {
$agencyLabel = trim((string) ($a->sa_name ?? ''));
break;
}
}
}
if ($export) {
$headers = array_merge(['품목', '구분'], array_map(static fn (array $c): string => (string) ($c['label'] ?? ''), $built['colSpec']));
$exportRows = [];
foreach ($built['itemBlocks'] as $block) {
foreach ($block['lines'] as $li) {
$exportRows[] = array_merge(
[(string) ($block['name'] ?? ''), (string) ($li['measure'] ?? '')],
array_map(static fn ($v): string => (string) $v, $li['exportCells'] ?? [])
);
}
}
if ($built['itemBlocks'] !== []) {
foreach ($built['footerBlock']['lines'] as $li) {
$exportRows[] = array_merge(
[(string) ($built['footerBlock']['name'] ?? ''), (string) ($li['measure'] ?? '')],
array_map(static fn ($v): string => (string) $v, $li['exportCells'] ?? [])
);
}
}
$exportStamp = date('Ymd_His');
export_excel_2003_xml(
'년판매현황_' . $year . '_' . $exportStamp,
$year . '년판매',
$headers,
$exportRows
);
}
$printExtraLines = [
'구·군: ' . $gugunLabel . ' · 대행소: ' . $agencyLabel,
'(단위: 매 / 원)',
];
return $this->renderWorkPage($year . '년 판매 현황', 'admin/sales_report/yearly_sales', [
'year' => $year,
'gugunCode' => $gugunCode,
'saIdx' => $saIdx,
'agencies' => $agencies,
'gugunOptions' => $gugunOptions,
'colSpec' => $built['colSpec'],
'itemBlocks' => $built['itemBlocks'],
'footerBlock' => $built['footerBlock'],
'hasBsFee' => $hasBsFee,
'lgName' => $lgName,
'gugunLabel' => $gugunLabel,
'agencyLabel' => $agencyLabel,
'printExtraLines' => $printExtraLines,
'hasYearlyData' => $built['itemBlocks'] !== [],
]);
}
/**
* @return list<array{id: string, label: string}>
*/
private function yearlySalesColumnSpec(): array
{
return [
['id' => 'total', 'label' => '합계'],
['id' => 'm1', 'label' => '1월'],
['id' => 'm2', 'label' => '2월'],
['id' => 'm3', 'label' => '3월'],
['id' => 'q1', 'label' => '1분기'],
['id' => 'm4', 'label' => '4월'],
['id' => 'm5', 'label' => '5월'],
['id' => 'm6', 'label' => '6월'],
['id' => 'q2', 'label' => '2분기'],
['id' => 'm7', 'label' => '7월'],
['id' => 'm8', 'label' => '8월'],
['id' => 'm9', 'label' => '9월'],
['id' => 'q3', 'label' => '3분기'],
['id' => 'm10', 'label' => '10월'],
['id' => 'm11', 'label' => '11월'],
['id' => 'm12', 'label' => '12월'],
['id' => 'q4', 'label' => '4분기'],
];
}
private function codeKindIdxByCkCodeForSalesReport(string $ckCode): ?int
{
$k = model(CodeKindModel::class)
->where('ck_code', $ckCode)
->where('ck_state', 1)
->first();
return $k !== null ? (int) $k->ck_idx : null;
}
/**
* @return list<array{code: string, name: string}>
*/
private function yearlySalesGugunDropdownRows(int $lgIdx): array
{
$map = [];
$ckIdx = $this->codeKindIdxByCkCodeForSalesReport('C');
if ($ckIdx !== null) {
foreach (model(CodeDetailModel::class)->getByKind($ckIdx, true, $lgIdx) as $r) {
$map[trim((string) ($r->cd_code ?? ''))] = trim((string) ($r->cd_name ?? ''));
}
}
$db = \Config\Database::connect();
$codes = $db->query(
"SELECT DISTINCT TRIM(ds_gugun_code) AS c FROM designated_shop WHERE ds_lg_idx = ? AND TRIM(ds_gugun_code) != '' ORDER BY c",
[$lgIdx]
)->getResult();
$out = [];
foreach ($codes as $o) {
$c = trim((string) ($o->c ?? ''));
if ($c === '') {
continue;
}
$name = $map[$c] ?? $c;
$out[$c] = ['code' => $c, 'name' => $name !== '' ? $name : $c];
}
foreach ($map as $c => $name) {
if ($c === '' || isset($out[$c])) {
continue;
}
$out[$c] = ['code' => $c, 'name' => $name !== '' ? $name : $c];
}
$rows = array_values($out);
usort($rows, static fn (array $a, array $b): int => strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? '')));
return $rows;
}
/**
* 품목·월별 판매 집계 (판매만)
*
* @return list<object>
*/
private function fetchYearlySalesMonthlyAggregates(
\CodeIgniter\Database\BaseConnection $db,
int $lgIdx,
int $year,
string $gugunCode,
int $saIdx,
bool $hasBsFee
): array {
$feeExpr = $hasBsFee
? 'SUM(COALESCE(bs.bs_fee, 0))'
: 'CAST(0 AS DECIMAL(14,2))';
$sql = 'SELECT bs.bs_bag_code AS c, bs.bs_bag_name AS n, MONTH(bs.bs_sale_date) AS mo, '
. 'SUM(ABS(bs.bs_qty)) AS qty, SUM(bs.bs_amount) AS amt, '
. $feeExpr . ' AS fee '
. 'FROM bag_sale bs '
. 'LEFT JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx '
. 'WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? AND bs.bs_type = ? ';
$params = [$lgIdx, $year, 'sale'];
if ($gugunCode !== '') {
$sql .= ' AND ds.ds_gugun_code = ? ';
$params[] = $gugunCode;
}
if ($saIdx > 0) {
$sql .= ' AND ds.ds_sa_idx = ? ';
$params[] = $saIdx;
}
$sql .= 'GROUP BY bs.bs_bag_code, bs.bs_bag_name, MONTH(bs.bs_sale_date) '
. 'ORDER BY bs.bs_bag_code, MONTH(bs.bs_sale_date)';
return $db->query($sql, $params)->getResult();
}
/**
* @param list<object> $monthRows
*
* @return array{
* colSpec: list<array{id: string, label: string}>,
* itemBlocks: list<array{name: string, lines: list<array{measure: string, cells: array<string, array{qty: int, amt: float, fee: float, levy: float}>, exportCells: list<string>}>>},
* footerBlock: array{name: string, lines: list<array{measure: string, cells: array<string, array{qty: int, amt: float, fee: float, levy: float}>, exportCells: list<string>}>}
* }
*/
private function buildYearlySalesPresentation(array $monthRows, bool $hasBsFee): array
{
$colSpec = $this->yearlySalesColumnSpec();
/** @var array<string, array{code: string, name: string, m: array<int, array{qty: int, amt: float, fee: float}>}> $products */
$products = [];
foreach ($monthRows as $row) {
$code = trim((string) ($row->c ?? ''));
$name = trim((string) ($row->n ?? ''));
$mo = (int) ($row->mo ?? 0);
if ($code === '' || $mo < 1 || $mo > 12) {
continue;
}
$pkey = $code . "\x1f" . ($name !== '' ? $name : $code);
if (! isset($products[$pkey])) {
$products[$pkey] = [
'code' => $code,
'name' => $name !== '' ? $name : $code,
'm' => [],
];
for ($i = 1; $i <= 12; $i++) {
$products[$pkey]['m'][$i] = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0];
}
}
$products[$pkey]['m'][$mo]['qty'] = (int) round((float) ($row->qty ?? 0));
$products[$pkey]['m'][$mo]['amt'] = (float) ($row->amt ?? 0);
$products[$pkey]['m'][$mo]['fee'] = $hasBsFee ? (float) ($row->fee ?? 0) : 0.0;
}
$sumMonths = static function (array $prod, array $months): array {
$q = 0;
$a = 0.0;
$f = 0.0;
foreach ($months as $mi) {
$b = $prod['m'][$mi] ?? ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0];
$q += (int) ($b['qty'] ?? 0);
$a += (float) ($b['amt'] ?? 0);
$f += (float) ($b['fee'] ?? 0);
}
return [
'qty' => $q,
'amt' => $a,
'fee' => $f,
'levy' => $a + $f,
];
};
$bucketForSpec = static function (array $prod, array $col) use ($sumMonths): array {
$id = (string) ($col['id'] ?? '');
if ($id === 'total') {
return $sumMonths($prod, range(1, 12));
}
if (preg_match('/^m(\d{1,2})$/', $id, $m)) {
$mi = (int) $m[1];
return $sumMonths($prod, [$mi]);
}
if ($id === 'q1') {
return $sumMonths($prod, [1, 2, 3]);
}
if ($id === 'q2') {
return $sumMonths($prod, [4, 5, 6]);
}
if ($id === 'q3') {
return $sumMonths($prod, [7, 8, 9]);
}
if ($id === 'q4') {
return $sumMonths($prod, [10, 11, 12]);
}
return ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0];
};
$measureDefs = [
['key' => 'qty', 'label' => '수량'],
['key' => 'amt', 'label' => '판매금액'],
['key' => 'fee', 'label' => '수수료'],
['key' => 'levy', 'label' => '징수액'],
];
$cellsToExportRow = static function (array $cellsByCol, array $colSpecList, array $measure, bool $hasBsFee): array {
$key = (string) ($measure['key'] ?? '');
$out = [];
foreach ($colSpecList as $col) {
$cid = (string) ($col['id'] ?? '');
$cell = $cellsByCol[$cid] ?? ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0];
if ($key === 'fee' && ! $hasBsFee) {
$out[] = '';
} elseif ($key === 'qty') {
$out[] = (string) ((int) ($cell['qty'] ?? 0));
} else {
$out[] = (string) (int) round((float) ($cell[$key] ?? 0));
}
}
return $out;
};
uasort($products, static function (array $a, array $b): int {
$ca = strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? ''));
if ($ca !== 0) {
return $ca;
}
return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
$itemBlocks = [];
$footerCells = [];
foreach ($colSpec as $col) {
$footerCells[(string) $col['id']] = ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0];
}
foreach ($products as $prod) {
$cellsByCol = [];
foreach ($colSpec as $col) {
$cid = (string) $col['id'];
$cellsByCol[$cid] = $bucketForSpec($prod, $col);
$footerCells[$cid]['qty'] += (int) $cellsByCol[$cid]['qty'];
$footerCells[$cid]['amt'] += (float) $cellsByCol[$cid]['amt'];
$footerCells[$cid]['fee'] += (float) $cellsByCol[$cid]['fee'];
$footerCells[$cid]['levy'] += (float) $cellsByCol[$cid]['levy'];
}
$lines = [];
foreach ($measureDefs as $md) {
$lineCells = [];
foreach ($colSpec as $col) {
$cid = (string) $col['id'];
$lineCells[$cid] = $cellsByCol[$cid];
}
$lines[] = [
'measure' => (string) ($md['label'] ?? ''),
'measureKey' => (string) ($md['key'] ?? ''),
'cells' => $lineCells,
'exportCells' => $cellsToExportRow($cellsByCol, $colSpec, $md, $hasBsFee),
];
}
$itemBlocks[] = [
'name' => (string) ($prod['name'] ?? ''),
'lines' => $lines,
];
}
$footerLines = [];
foreach ($measureDefs as $md) {
$key = (string) ($md['key'] ?? '');
$fc = [];
foreach ($colSpec as $col) {
$cid = (string) $col['id'];
$fc[$cid] = $footerCells[$cid] ?? ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0];
}
$footerLines[] = [
'measure' => (string) ($md['label'] ?? ''),
'measureKey' => (string) ($md['key'] ?? ''),
'cells' => $fc,
'exportCells' => $cellsToExportRow($footerCells, $colSpec, $md, $hasBsFee),
];
}
return [
'colSpec' => $colSpec,
'itemBlocks' => $itemBlocks,
'footerBlock' => [
'name' => '전체 합계',
'lines' => $footerLines,
],
];
}
/**
* P5-05: 지정판매소별 판매현황 (기간·읍면동·봉투종류·구분·수량/금액, 월별 12, 엑셀·인쇄)
*/
public function shopSales()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-01-01'));
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
if ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
$zoneCode = trim((string) ($this->request->getGet('zone_code') ?? ''));
$bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
$catFilter = trim((string) ($this->request->getGet('cat') ?? ''));
$metric = trim((string) ($this->request->getGet('metric') ?? 'qty'));
if ($metric !== 'amt') {
$metric = 'qty';
}
$export = (int) ($this->request->getGet('export') ?? 0) === 1;
$lgRow = model(LocalGovernmentModel::class)->find($lgIdx);
$lgName = $lgRow ? trim((string) ($lgRow->lg_name ?? '')) : '';
$fixedGugun = $lgRow ? trim((string) ($lgRow->lg_code ?? '')) : '';
$db = \Config\Database::connect();
$zoneOptions = $this->shopSalesDistinctZoneCodes($db, $lgIdx, $fixedGugun);
if ($zoneCode !== '' && ! in_array($zoneCode, $zoneOptions, true)) {
$zoneCode = '';
}
$bagOptions = $this->shopSalesDistinctBagCodes($db, $lgIdx);
if ($bagCode !== '' && ! array_key_exists($bagCode, $bagOptions)) {
$bagCode = '';
}
$catLabels = [
'general' => '일반용',
'food' => '음식물',
'sticker' => '스티커',
'reuse' => '재사용',
'apt' => '공동주택용',
'public_use' => '공공용',
'container' => '용기',
'waste' => '폐기물',
];
$isAmountMetric = $metric === 'amt';
$fifoStart = $isAmountMetric
? $startDate
: min($startDate, date('Y-01-01', strtotime($endDate)));
$fifoRows = $this->fetchShopSalesFifoRowsBetween(
$db,
$lgIdx,
$fifoStart,
$endDate,
$zoneCode,
$bagCode,
$fixedGugun
);
if ($isAmountMetric) {
$perShop = $this->shopSalesMonthlyGrossSaleAmountByShop(
$fifoRows,
$startDate,
$endDate,
$catFilter
);
} else {
$perShop = $this->shopSalesFifoMonthlyByShop(
$fifoRows,
$startDate,
$endDate,
$catFilter,
$metric
);
if (((int) date('Y', strtotime($startDate))) === ((int) date('Y', strtotime($endDate)))) {
$this->shopSalesRollOffWindowMonthsIntoFirstVisibleMonth($perShop, $startDate, $endDate);
$this->shopSalesAbsorbNegativeVisibleMonthsIntoEarlierPositive($perShop, $startDate, $endDate);
}
}
$designatedShopIds = $this->shopSalesDesignatedShopIds($db, $lgIdx, $fixedGugun, $zoneCode);
foreach ($designatedShopIds as $sid) {
if (! isset($perShop[$sid])) {
$perShop[$sid] = ['total' => 0.0];
for ($i = 1; $i <= 12; $i++) {
$perShop[$sid][$i] = 0.0;
}
}
}
$reportRows = [];
if ($designatedShopIds !== []) {
$shops = model(DesignatedShopModel::class)
->where('ds_lg_idx', $lgIdx)
->whereIn('ds_idx', $designatedShopIds)
->orderBy('ds_shop_no', 'ASC')
->orderBy('ds_idx', 'ASC')
->findAll();
foreach ($shops as $ds) {
$id = (int) ($ds->ds_idx ?? 0);
if ($id <= 0) {
continue;
}
$m = $perShop[$id] ?? null;
if ($m === null) {
$m = ['total' => 0.0];
for ($i = 1; $i <= 12; $i++) {
$m[$i] = 0.0;
}
}
$addrParts = array_filter([
trim((string) ($ds->ds_addr ?? '')),
trim((string) ($ds->ds_addr_detail ?? '')),
], static fn (string $s): bool => $s !== '');
$reportRows[] = [
'ds_idx' => $id,
'name' => trim((string) ($ds->ds_name ?? '')),
'rep' => trim((string) ($ds->ds_rep_name ?? '')),
'address' => implode(' ', $addrParts),
'months' => array_map(static fn (int $mi): float => (float) ($m[$mi] ?? 0.0), range(1, 12)),
'total' => (float) ($m['total'] ?? 0.0),
];
}
}
$grandMonths = array_fill(0, 12, 0.0);
$grandTotal = 0.0;
foreach ($reportRows as $rw) {
foreach (range(0, 11) as $i) {
$grandMonths[$i] += (float) ($rw['months'][$i] ?? 0.0);
}
$grandTotal += (float) ($rw['total'] ?? 0.0);
}
$zoneLabel = '전체';
if ($zoneCode !== '') {
$zoneLabel = $zoneCode;
}
$bagLabel = '전체';
if ($bagCode !== '') {
$bagLabel = $bagCode . (isset($bagOptions[$bagCode]) && $bagOptions[$bagCode] !== '' && $bagOptions[$bagCode] !== $bagCode
? ' (' . $bagOptions[$bagCode] . ')'
: '');
}
$catLabelFilter = ($catFilter !== '' && isset($catLabels[$catFilter])) ? $catLabels[$catFilter] : '전체';
$metricLabel = $metric === 'amt' ? '금액' : '수량';
if ($export) {
$headers = array_merge(
['지정판매소', '대표자명', '주소', '합계'],
array_map(static fn (int $m): string => $m . '월', range(1, 12))
);
$exportRows = [];
foreach ($reportRows as $rw) {
$line = [
(string) ($rw['name'] ?? ''),
(string) ($rw['rep'] ?? ''),
(string) ($rw['address'] ?? ''),
$metric === 'amt'
? (string) (int) round((float) ($rw['total'] ?? 0))
: (string) (int) round((float) ($rw['total'] ?? 0)),
];
foreach (($rw['months'] ?? []) as $mv) {
$line[] = $metric === 'amt'
? (string) (int) round((float) $mv)
: (string) (int) round((float) $mv);
}
$exportRows[] = $line;
}
if ($reportRows !== []) {
$gLine = ['전체 합계', '', '', $metric === 'amt' ? (string) (int) round($grandTotal) : (string) (int) round($grandTotal)];
foreach ($grandMonths as $gm) {
$gLine[] = $metric === 'amt' ? (string) (int) round($gm) : (string) (int) round($gm);
}
$exportRows[] = $gLine;
}
$exportStamp = date('Ymd_His');
export_excel_2003_xml(
'지정판매소별판매현황_' . $exportStamp,
'지정판매소별',
$headers,
$exportRows
);
}
$printExtraLines = [
'조회기간: ' . $startDate . ' ~ ' . $endDate . ' · 읍면동: ' . $zoneLabel . ' · 봉투종류: ' . $bagLabel . ' · 구분: ' . $catLabelFilter . ' · 집계: ' . $metricLabel,
$metric === 'amt' ? '(단위: 원)' : '(단위: 매)',
$metric === 'amt'
? '금액은 조회기간 내 판매(sale) 건의 판매금액을 거래 월별로 합산합니다(반품·취소는 제외).'
: '수량은 반품·판매취소를 연초~조회 종료일 판매와 품목별 FIFO로 맞추고, 반품취소·판매는 원복합니다. 월별 표시는 조회기간 안만 반영하며, 조회 밖 달의 합은 기간 내 첫 달에 합산됩니다.',
];
return $this->renderWorkPage('지정 판매소별 판매현황', 'admin/sales_report/shop_sales', [
'startDate' => $startDate,
'endDate' => $endDate,
'zoneCode' => $zoneCode,
'bagCode' => $bagCode,
'catFilter' => $catFilter,
'metric' => $metric,
'zoneOptions' => $zoneOptions,
'bagOptions' => $bagOptions,
'catLabels' => $catLabels,
'reportRows' => $reportRows,
'grandMonths' => $grandMonths,
'grandTotal' => $grandTotal,
'lgName' => $lgName,
'zoneLabel' => $zoneLabel,
'bagLabel' => $bagLabel,
'catLabelFilter' => $catLabelFilter,
'metricLabel' => $metricLabel,
'printExtraLines' => $printExtraLines,
]);
}
/**
* 판매 집계와 무관하게, 조회 조건(지자체·구군·읍면동) 해당하는 지정 판매소 ID 목록
*
* @return list<int>
*/
private function shopSalesDesignatedShopIds(
\CodeIgniter\Database\BaseConnection $db,
int $lgIdx,
string $fixedGugunCode,
string $zoneCode
): array {
$sql = 'SELECT ds_idx FROM designated_shop WHERE ds_lg_idx = ? ';
$params = [$lgIdx];
if ($fixedGugunCode !== '') {
$sql .= ' AND ds_gugun_code = ? ';
$params[] = $fixedGugunCode;
}
if ($zoneCode !== '') {
$sql .= ' AND TRIM(ds_zone_code) = ? ';
$params[] = $zoneCode;
}
$sql .= ' ORDER BY ds_shop_no ASC, ds_idx ASC';
$ids = [];
foreach ($db->query($sql, $params)->getResult() as $row) {
$id = (int) ($row->ds_idx ?? 0);
if ($id > 0) {
$ids[] = $id;
}
}
return $ids;
}
/**
* @return list<string>
*/
private function shopSalesDistinctZoneCodes(
\CodeIgniter\Database\BaseConnection $db,
int $lgIdx,
string $fixedGugunCode
): array {
$sql = "SELECT DISTINCT TRIM(ds_zone_code) AS z FROM designated_shop WHERE ds_lg_idx = ? AND TRIM(ds_zone_code) != '' ";
$params = [$lgIdx];
if ($fixedGugunCode !== '') {
$sql .= ' AND ds_gugun_code = ? ';
$params[] = $fixedGugunCode;
}
$sql .= ' ORDER BY z';
$out = [];
foreach ($db->query($sql, $params)->getResult() as $o) {
$z = trim((string) ($o->z ?? ''));
if ($z !== '') {
$out[] = $z;
}
}
return $out;
}
/**
* @return array<string, string> code => name
*/
private function shopSalesDistinctBagCodes(\CodeIgniter\Database\BaseConnection $db, int $lgIdx): array
{
$rows = $db->query(
'SELECT DISTINCT TRIM(bs_bag_code) AS c, TRIM(bs_bag_name) AS n FROM bag_sale WHERE bs_lg_idx = ? AND TRIM(bs_bag_code) != \'\' ORDER BY c LIMIT 400',
[$lgIdx]
)->getResult();
$map = [];
foreach ($rows as $o) {
$c = trim((string) ($o->c ?? ''));
if ($c === '') {
continue;
}
$map[$c] = trim((string) ($o->n ?? ''));
}
return $map;
}
/**
* 지정판매소별 판매현황 FIFO용: 연초~end 또는 조회 시작 이른 날부터 end까지 로드(선입선출 재고 맞춤)
*
* @return list<object>
*/
private function fetchShopSalesFifoRowsBetween(
\CodeIgniter\Database\BaseConnection $db,
int $lgIdx,
string $startDate,
string $endDate,
string $zoneCode,
string $bagCode,
string $fixedGugunCode
): array {
$sql = 'SELECT bs.bs_idx, bs.bs_ds_idx AS ds_idx, bs.bs_sale_date AS d, bs.bs_type AS t, '
. 'TRIM(bs.bs_bag_code) AS bag_code, TRIM(bs.bs_bag_name) AS bag_name, '
. 'ABS(bs.bs_qty) AS q, bs.bs_unit_price AS unit_p, ABS(bs.bs_amount) AS line_amt '
. 'FROM bag_sale bs '
. 'INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx '
. 'WHERE bs.bs_lg_idx = ? AND bs.bs_sale_date BETWEEN ? AND ? '
. "AND bs.bs_type IN ('sale','return','cancel','return_cancel') ";
$params = [$lgIdx, $startDate, $endDate];
if ($fixedGugunCode !== '') {
$sql .= ' AND ds.ds_gugun_code = ? ';
$params[] = $fixedGugunCode;
}
if ($zoneCode !== '') {
$sql .= ' AND TRIM(ds.ds_zone_code) = ? ';
$params[] = $zoneCode;
}
if ($bagCode !== '') {
$sql .= ' AND TRIM(bs.bs_bag_code) = ? ';
$params[] = $bagCode;
}
$sql .= 'ORDER BY bs.bs_sale_date ASC, bs.bs_idx ASC';
return $db->query($sql, $params)->getResult();
}
/**
* 금액 집계: 조회기간 sale 건의 판매금액을 거래 월별 합산(기능목록 「판매된 금액」)
*
* @param list<object> $rows
*
* @return array<int, array<int|float>>
*/
private function shopSalesMonthlyGrossSaleAmountByShop(
array $rows,
string $startDate,
string $endDate,
string $catFilter
): array {
/** @var array<int, array<int|float>> $perShop */
$perShop = [];
foreach ($rows as $r) {
if ((string) ($r->t ?? '') !== 'sale') {
continue;
}
$name = (string) ($r->bag_name ?? '');
$code = (string) ($r->bag_code ?? '');
$ck = $this->classifyLedgerProductCategory($name, $code);
if ($catFilter !== '' && $ck !== $catFilter) {
continue;
}
$ds = (int) ($r->ds_idx ?? 0);
if ($ds <= 0) {
continue;
}
$dStr = (string) ($r->d ?? '');
if ($dStr < $startDate || $dStr > $endDate) {
continue;
}
$saleMonth = (int) date('n', strtotime($dStr));
if ($saleMonth < 1 || $saleMonth > 12) {
continue;
}
$add = $this->shopSalesResolveLineAmount($r);
if ($add <= 0.0) {
continue;
}
if (! isset($perShop[$ds])) {
$perShop[$ds] = ['total' => 0.0];
for ($i = 1; $i <= 12; $i++) {
$perShop[$ds][$i] = 0.0;
}
}
$perShop[$ds][$saleMonth] += $add;
$perShop[$ds]['total'] += $add;
}
return $perShop;
}
/**
* @param object $row fetchShopSalesFifoRowsBetween 결과
*/
private function shopSalesResolveLineAmount(object $row): float
{
$q = (float) ($row->q ?? 0);
$unit = (float) ($row->unit_p ?? 0);
$line = (float) ($row->line_amt ?? 0);
if ($q > 0.0 && $unit > 0.0) {
return $q * $unit;
}
return $line > 0.0 ? $line : 0.0;
}
/**
* 판매·반품취소는 조회기간 월별 가산(+FIFO 적재), 반품·판매취소는 FIFO로 원판매월 차감
*
* @param list<object> $rows
*
* @return array<int, array<int|float>>
*/
private function shopSalesFifoMonthlyByShop(
array $rows,
string $startDate,
string $endDate,
string $catFilter,
string $metric
): array {
/** @var array<int, array<int|float>> $perShop */
$perShop = [];
/** @var array<int, array<string, list<array{qty: float, m: int, unit: float}>>> $queues */
$queues = [];
foreach ($rows as $r) {
$name = (string) ($r->bag_name ?? '');
$code = (string) ($r->bag_code ?? '');
$ck = $this->classifyLedgerProductCategory($name, $code);
if ($catFilter !== '' && $ck !== $catFilter) {
continue;
}
$ds = (int) ($r->ds_idx ?? 0);
$bag = trim((string) ($r->bag_code ?? ''));
if ($ds <= 0 || $bag === '') {
continue;
}
$dStr = (string) ($r->d ?? '');
$t = (string) ($r->t ?? '');
$q = (float) ($r->q ?? 0);
if ($q <= 0.0) {
continue;
}
$saleMonth = (int) date('n', strtotime($dStr));
if ($saleMonth < 1 || $saleMonth > 12) {
continue;
}
$inWindow = ($dStr >= $startDate && $dStr <= $endDate);
if ($t === 'sale' || $t === 'return_cancel') {
$this->shopSalesFifoApplyInbound($perShop, $queues, $ds, $bag, $q, $saleMonth, $inWindow, $metric, (float) ($r->line_amt ?? 0), (float) ($r->unit_p ?? 0));
} elseif ($t === 'return' || $t === 'cancel') {
$this->shopSalesFifoApplyOutbound($perShop, $queues, $ds, $bag, $q, $saleMonth, $metric, (float) ($r->line_amt ?? 0));
}
}
foreach (array_keys($perShop) as $ds) {
$sum = 0.0;
for ($i = 1; $i <= 12; $i++) {
$sum += (float) ($perShop[$ds][$i] ?? 0.0);
}
$perShop[$ds]['total'] = $sum;
}
return $perShop;
}
/**
* @param array<int, array<int|float>> $perShop
* @param array<int, array<string, list<array{qty: float, m: int, unit: float}>>> $queues
*/
private function shopSalesFifoApplyInbound(
array &$perShop,
array &$queues,
int $ds,
string $bag,
float $q,
int $saleMonth,
bool $inWindow,
string $metric,
float $lineAmt,
float $unitPrice
): void {
if (! isset($perShop[$ds])) {
$perShop[$ds] = ['total' => 0.0];
for ($i = 1; $i <= 12; $i++) {
$perShop[$ds][$i] = 0.0;
}
}
if (! isset($queues[$ds][$bag])) {
$queues[$ds][$bag] = [];
}
$queues[$ds][$bag][] = [
'qty' => $q,
'm' => $saleMonth,
'unit' => $unitPrice,
];
if ($inWindow) {
$perShop[$ds][$saleMonth] += $q;
$perShop[$ds]['total'] += $q;
}
}
/**
* @param array<int, array<int|float>> $perShop
* @param array<int, array<string, list<array{qty: float, m: int, unit: float}>>> $queues
*/
private function shopSalesFifoApplyOutbound(
array &$perShop,
array &$queues,
int $ds,
string $bag,
float $q,
int $eventMonth,
string $metric,
float $lineAmt
): void {
if (! isset($perShop[$ds])) {
$perShop[$ds] = ['total' => 0.0];
for ($i = 1; $i <= 12; $i++) {
$perShop[$ds][$i] = 0.0;
}
}
if (! isset($queues[$ds][$bag])) {
$queues[$ds][$bag] = [];
}
$need = $q;
$origRQty = $q;
$origRAmt = $lineAmt;
$returnMonth = $eventMonth;
while ($need > 1e-9 && ($queues[$ds][$bag] ?? []) !== []) {
$lot0 = &$queues[$ds][$bag][0];
$take = min($need, $lot0['qty']);
$deduct = $take;
$lotMonth = (int) ($lot0['m'] ?? $returnMonth);
if ($lotMonth >= 1 && $lotMonth <= 12) {
$perShop[$ds][$lotMonth] -= $deduct;
$perShop[$ds]['total'] -= $deduct;
}
$need -= $take;
$lot0['qty'] -= $take;
if ($lot0['qty'] <= 1e-9) {
array_shift($queues[$ds][$bag]);
}
unset($lot0);
}
if ($need > 1e-9) {
$deduct = $need;
if ($returnMonth >= 1 && $returnMonth <= 12) {
$perShop[$ds][$returnMonth] -= $deduct;
$perShop[$ds]['total'] -= $deduct;
}
}
}
/**
* 조회기간과 겹치지 않는 달의 합계를, 겹치는 달로 옮김(표시상 조회 달에만 음수가 남는 것을 방지)
*
* @param array<int, array<int|float>> $perShop
*/
private function shopSalesRollOffWindowMonthsIntoFirstVisibleMonth(array &$perShop, string $displayStart, string $displayEnd): void
{
$year = (int) date('Y', strtotime($displayStart));
if ($year !== (int) date('Y', strtotime($displayEnd))) {
return;
}
foreach (array_keys($perShop) as $ds) {
$roll = 0.0;
$firstVisible = 0;
for ($m = 1; $m <= 12; $m++) {
$monthStart = sprintf('%04d-%02d-01', $year, $m);
$monthEnd = date('Y-m-t', strtotime($monthStart));
$overlaps = ! ($monthEnd < $displayStart || $monthStart > $displayEnd);
if ($overlaps) {
if ($firstVisible === 0) {
$firstVisible = $m;
}
continue;
}
$roll += (float) ($perShop[$ds][$m] ?? 0.0);
$perShop[$ds][$m] = 0.0;
}
if ($firstVisible >= 1 && abs($roll) > 1e-12) {
$perShop[$ds][$firstVisible] = (float) ($perShop[$ds][$firstVisible] ?? 0.0) + $roll;
}
$sum = 0.0;
for ($i = 1; $i <= 12; $i++) {
$sum += (float) ($perShop[$ds][$i] ?? 0.0);
}
$perShop[$ds]['total'] = $sum;
}
}
/**
* 조회 구간에 겹치는 음수는, 같은 구간 안에서 이른 달의 양수부터 상쇄하고 남은 부분은 마지막 달에 반영(0 미만은 0으로 맞춤)
*
* @param array<int, array<int|float>> $perShop
*/
private function shopSalesAbsorbNegativeVisibleMonthsIntoEarlierPositive(array &$perShop, string $displayStart, string $displayEnd): void
{
$year = (int) date('Y', strtotime($displayStart));
if ($year !== (int) date('Y', strtotime($displayEnd))) {
return;
}
$visible = [];
for ($m = 1; $m <= 12; $m++) {
$monthStart = sprintf('%04d-%02d-01', $year, $m);
$monthEnd = date('Y-m-t', strtotime($monthStart));
if (! ($monthEnd < $displayStart || $monthStart > $displayEnd)) {
$visible[] = $m;
}
}
if ($visible === []) {
return;
}
$lastVisible = $visible[array_key_last($visible)];
foreach (array_keys($perShop) as $ds) {
$need = 0.0;
foreach ($visible as $m) {
$v = (float) ($perShop[$ds][$m] ?? 0.0);
if ($v < 0.0) {
$need -= $v;
$perShop[$ds][$m] = 0.0;
}
}
if ($need <= 1e-12) {
$sum = 0.0;
for ($i = 1; $i <= 12; $i++) {
$sum += (float) ($perShop[$ds][$i] ?? 0.0);
}
$perShop[$ds]['total'] = $sum;
continue;
}
foreach ($visible as $m) {
if ($need <= 1e-12) {
break;
}
$v = (float) ($perShop[$ds][$m] ?? 0.0);
if ($v <= 0.0) {
continue;
}
$take = min($v, $need);
$perShop[$ds][$m] = $v - $take;
$need -= $take;
}
if ($need > 1e-12) {
$tail = (float) ($perShop[$ds][$lastVisible] ?? 0.0) - $need;
$perShop[$ds][$lastVisible] = $tail < 0.0 ? 0.0 : $tail;
}
$sum = 0.0;
for ($i = 1; $i <= 12; $i++) {
$sum += (float) ($perShop[$ds][$i] ?? 0.0);
}
$perShop[$ds]['total'] = $sum;
}
}
/**
* P5-06: 홈택스 처리 판매기간·작성일자 조회, 일괄발급 엑셀 양식 , 엑셀·인쇄
*/
public function hometaxExport()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-01-01'));
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
$writeDate = (string) ($this->request->getGet('write_date') ?? date('Y-m-d'));
if ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
// 최초 진입(search 미지정)도 기본 기간·작성일로 즉시 조회. 명시적으로 search=0이면 미조회.
$searchGet = (string) ($this->request->getGet('search') ?? '');
$searched = $searchGet !== '0';
$export = (int) ($this->request->getGet('export') ?? 0) === 1;
if ($export && ! $searched) {
return redirect()->to(mgmt_url('reports/hometax-export?' . http_build_query([
'start_date' => $startDate,
'end_date' => $endDate,
'write_date' => $writeDate,
'search' => '1',
'export' => '1',
])));
}
$lg = model(LocalGovernmentModel::class)->find($lgIdx);
if (! $lg) {
return redirect()->to(work_area_home_url())->with('error', '지자체 정보를 찾을 수 없습니다.');
}
$lgName = trim((string) ($lg->lg_name ?? ''));
$headers = $this->hometaxBulkColumnHeaders();
$writeYmd = str_replace('-', '', $writeDate);
/** @var list<object> $rows */
$rows = [];
$totalCount = 0;
$totalSupplyAmount = 0.0;
$totalTaxAmount = 0.0;
$missingBizCount = 0;
if ($searched) {
$db = \Config\Database::connect();
$rows = $db->query(
'SELECT bs.bs_sale_date, bs.bs_bag_name, ABS(bs.bs_qty) AS qty, bs.bs_unit_price, bs.bs_amount, '
. 'ds.ds_biz_no, ds.ds_name, ds.ds_rep_name, ds.ds_branch_no, '
. 'ds.ds_addr, ds.ds_addr_detail, ds.ds_biz_type, ds.ds_biz_kind, ds.ds_email '
. 'FROM bag_sale bs '
. 'INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_lg_idx = bs.bs_lg_idx '
. 'WHERE bs.bs_lg_idx = ? AND bs.bs_sale_date BETWEEN ? AND ? AND bs.bs_type = \'sale\' '
. 'ORDER BY bs.bs_sale_date ASC, ds.ds_name ASC, bs.bs_idx ASC',
[$lgIdx, $startDate, $endDate]
)->getResult();
$totalCount = count($rows);
foreach ($rows as $r) {
$amt = (float) ($r->bs_amount ?? 0);
$totalSupplyAmount += $amt;
$totalTaxAmount += (int) round($amt * 0.1);
if ($this->hometaxBuyerBizMissing((string) ($r->ds_biz_no ?? ''))) {
$missingBizCount++;
}
}
}
$displayRows = [];
foreach ($rows as $r) {
$displayRows[] = $this->hometaxBuildBulkRow($lg, $r, $writeYmd);
}
$hometaxPrintPages = $this->hometaxPrintPageDefinitions(count($headers));
if ($export) {
export_excel_2003_xml_workbook(
'홈택스처리_' . date('Ymd_His'),
$this->hometaxBuildPrintStyleExcelSheets($headers, $displayRows, $hometaxPrintPages)
);
}
$printExtraLines = [
'판매일자: ' . $startDate . ' ~ ' . $endDate . ' · 작성일자: ' . $writeDate,
'엑셀·인쇄는 동일한 2쪽 열 구성(1쪽 공급자·공급받는자, 2쪽 금액·품목)입니다.',
];
return $this->renderWorkPage('홈택스 처리', 'admin/sales_report/hometax_process', [
'startDate' => $startDate,
'endDate' => $endDate,
'writeDate' => $writeDate,
'searched' => $searched,
'headers' => $headers,
'displayRows' => $displayRows,
'hometaxPrintPages' => $hometaxPrintPages,
'hometaxColMinPx' => $this->hometaxColumnMinWidthsPx(),
'totalCount' => $totalCount,
'totalSupplyAmount' => $totalSupplyAmount,
'totalTaxAmount' => $totalTaxAmount,
'missingBizCount' => $missingBizCount,
'lgName' => $lgName,
'printExtraLines' => $printExtraLines,
]);
}
/**
* 인쇄·엑셀 공통: 1( 14) / 2(식별 3 + 나머지)
*
* @return list<array{label: string, sheet_name: string, cols: list<int>}>
*/
private function hometaxPrintPageDefinitions(int $colCount): array
{
if ($colCount <= 0) {
return [];
}
$splitEnd = 13;
$pages = [
[
'label' => '1쪽 — 전자세금계산서·공급자·공급받는자',
'sheet_name' => '1쪽 공급자공급받는자',
'cols' => range(0, min($splitEnd, $colCount - 1)),
],
];
if ($splitEnd + 1 < $colCount) {
$page2Cols = [0, 1, 2];
for ($i = $splitEnd + 1; $i < $colCount; $i++) {
$page2Cols[] = $i;
}
$pages[] = [
'label' => '2쪽 — 식별정보·금액·품목',
'sheet_name' => '2쪽 금액품목',
'cols' => $page2Cols,
];
}
return $pages;
}
/**
* @return list<int>
*/
private function hometaxColumnMinWidthsPx(): array
{
return [
52, 72, 72, 88, 48, 80, 56, 140, 48, 48, 100,
88, 48, 80, 56, 140, 48, 48, 100,
72, 72, 48, 72, 48, 48, 56, 56, 56,
];
}
/**
* @param list<string> $headers
* @param list<list<string>> $displayRows
* @param list<array<string,mixed>> $printPages
*
* @return list<array{name: string, headers: list<string>, rows: list<list<string>>, col_widths: list<int>}>
*/
private function hometaxBuildPrintStyleExcelSheets(array $headers, array $displayRows, array $printPages): array
{
$minPx = $this->hometaxColumnMinWidthsPx();
$sheets = [];
foreach ($printPages as $page) {
/** @var list<int> $cols */
$cols = array_values((array) ($page['cols'] ?? []));
if ($cols === []) {
continue;
}
$sheetHeaders = [];
$colWidths = [];
foreach ($cols as $ci) {
$sheetHeaders[] = (string) ($headers[$ci] ?? '');
$colWidths[] = (int) ($minPx[$ci] ?? 72);
}
$sheetRows = [];
foreach ($displayRows as $row) {
$line = [];
foreach ($cols as $ci) {
$line[] = (string) ($row[$ci] ?? '');
}
$sheetRows[] = $line;
}
$sheets[] = [
'name' => (string) ($page['sheet_name'] ?? 'Sheet'),
'headers' => $sheetHeaders,
'rows' => $sheetRows,
'col_widths' => $colWidths,
];
}
return $sheets;
}
/**
* 홈택스 일괄발급 엑셀과 동일한 제목(순서 고정)
*
* @return list<string>
*/
private function hometaxBulkColumnHeaders(): array
{
return [
'전자세금계산서종류',
'작성일자',
'공급일자',
'공급자등록번호',
'공급자종사업장번호',
'공급자상호',
'공급자성명',
'공급자사업장주소',
'공급자업태',
'공급자종목',
'공급자이메일1',
'공급받는자사업자등록번호',
'공급받는자종사업장번호',
'공급받는자상호',
'공급받는자성명',
'공급받는자사업장주소',
'공급받는자업태',
'공급받는자종목',
'공급받는자이메일1',
'공급가액합계',
'세액합계',
'영수청구구분',
'품목명',
'규격',
'수량',
'단가',
'공급가액',
'세액',
'비고',
];
}
private function hometaxOnlyDigits(?string $s): string
{
return (string) preg_replace('/\D+/', '', (string) ($s ?? ''));
}
private function hometaxBuyerBizMissing(string $raw): bool
{
$d = $this->hometaxOnlyDigits($raw);
return $d === '' || strlen($d) < 10;
}
/**
* @return list<string>
*/
private function hometaxBuildBulkRow(object $lg, object $r, string $writeYmd): array
{
$supplyYmd = str_replace('-', '', (string) ($r->bs_sale_date ?? ''));
$amount = (int) round((float) ($r->bs_amount ?? 0));
$tax = (int) round($amount * 0.1);
$qty = (int) ($r->qty ?? 0);
$unit = (int) round((float) ($r->bs_unit_price ?? 0));
$supplierBiz = $this->hometaxOnlyDigits((string) ($lg->lg_biz_no ?? ''));
$buyerBiz = $this->hometaxOnlyDigits((string) ($r->ds_biz_no ?? ''));
$buyerAddr = trim(trim((string) ($r->ds_addr ?? '')) . ' ' . trim((string) ($r->ds_addr_detail ?? '')));
return [
'01',
$writeYmd,
$supplyYmd,
$supplierBiz,
'',
trim((string) ($lg->lg_name ?? '')),
'',
trim((string) ($lg->lg_addr ?? '')),
'',
'',
'',
$buyerBiz,
trim((string) ($r->ds_branch_no ?? '')),
trim((string) ($r->ds_name ?? '')),
trim((string) ($r->ds_rep_name ?? '')),
$buyerAddr,
trim((string) ($r->ds_biz_type ?? '')),
trim((string) ($r->ds_biz_kind ?? '')),
trim((string) ($r->ds_email ?? '')),
(string) $amount,
(string) $tax,
'02',
trim((string) ($r->bs_bag_name ?? '')),
'',
(string) $qty,
(string) $unit,
(string) $amount,
(string) $tax,
'',
];
}
/**
* P5-08: 반품/파기 현황 (레거시 sm805r)
*/
public function returns()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$queried = $this->request->getGet('search') === '1';
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
if ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
$ioType = (string) ($this->request->getGet('io_type') ?? 'out');
if (! in_array($ioType, ['in', 'out'], true)) {
$ioType = 'out';
}
$result = $queried
? $this->fetchReturnDisposeRows($lgIdx, $startDate, $endDate, $ioType)
: [];
return $this->renderWorkPage('반품/파기 현황', 'admin/sales_report/returns', [
'result' => $result,
'startDate' => $startDate,
'endDate' => $endDate,
'ioType' => $ioType,
'queried' => $queried,
'exportQuery' => $this->returnsExportQueryString(),
]);
}
public function returnsExport()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(mgmt_url('reports/returns'))->with('error', '지자체를 선택해 주세요.');
}
if ($this->request->getGet('search') !== '1') {
return redirect()->to(mgmt_url('reports/returns'))->with('error', '조회 후 엑셀 저장을 이용해 주세요.');
}
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
$ioType = (string) ($this->request->getGet('io_type') ?? 'out');
if (! in_array($ioType, ['in', 'out'], true)) {
$ioType = 'out';
}
$rows = [];
foreach ($this->fetchReturnDisposeRows($lgIdx, $startDate, $endDate, $ioType) as $row) {
$rows[] = [
(string) $row->bs_sale_date,
(string) $row->bs_ds_name,
$this->returnDisposeKindLabel($row),
(string) (int) $row->qty,
$this->returnDisposeTypeLabel((string) $row->bs_type),
];
}
$ioLabel = $ioType === 'in' ? '입고' : '출고';
export_excel_2003_xml(
'반품파기현황_' . $startDate . '_' . $endDate . '.xls',
'반품파기현황',
['일자', '반품처', '종류', '수량', '구분'],
$rows
);
return null;
}
/**
* @return list<object>
*/
private function fetchReturnDisposeRows(int $lgIdx, string $startDate, string $endDate, string $ioType): array
{
$bsTypes = $ioType === 'in' ? ['return'] : ['cancel'];
$typePlaceholders = implode(',', array_fill(0, count($bsTypes), '?'));
$db = \Config\Database::connect();
return $db->query("
SELECT bs_sale_date, bs_ds_name, bs_bag_code, bs_bag_name, bs_type,
ABS(bs_qty) AS qty
FROM bag_sale
WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ?
AND bs_type IN ({$typePlaceholders})
ORDER BY bs_sale_date ASC, bs_ds_name ASC, bs_bag_code ASC
", array_merge([$lgIdx, $startDate, $endDate], $bsTypes))->getResult();
}
private function returnDisposeKindLabel(object $row): string
{
$name = trim((string) ($row->bs_bag_name ?? ''));
$code = trim((string) ($row->bs_bag_code ?? ''));
if ($name !== '') {
return $name;
}
return $code !== '' ? $code : '-';
}
private function returnDisposeTypeLabel(string $bsType): string
{
return match ($bsType) {
'return' => '반품',
'cancel' => '파기',
default => $bsType,
};
}
private function returnsExportQueryString(): string
{
$params = array_filter([
'search' => '1',
'start_date' => $this->request->getGet('start_date'),
'end_date' => $this->request->getGet('end_date'),
'io_type' => $this->request->getGet('io_type'),
], static fn ($v) => $v !== null && $v !== '');
return http_build_query($params);
}
/**
* P5-10: LOT 수불 조회 (레거시 w_gd033a 바코드/봉투번호)
*/
public function lotFlow()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$barcode = trim((string) ($this->request->getGet('barcode') ?? $this->request->getGet('bag_no') ?? ''));
$lotNo = trim((string) ($this->request->getGet('lot_no') ?? ''));
$queried = $this->request->getGet('search') === '1'
|| $barcode !== ''
|| $lotNo !== '';
$builder = new \App\Libraries\BagLotFlowBuilder();
if ($barcode !== '') {
$result = $builder->buildByBarcode($lgIdx, $barcode, true);
} elseif ($lotNo !== '') {
$result = $builder->buildByLotNo($lgIdx, $lotNo, true);
$barcode = $lotNo;
} else {
$result = $builder->buildByBarcode($lgIdx, '', $queried);
}
return $this->renderWorkPage('LOT 수불 조회', 'admin/sales_report/lot_flow', [
'barcode' => $barcode,
'lotNo' => $lotNo,
'queried' => $queried,
'result' => $result,
'testSamples' => $builder->loadTestSamples($lgIdx),
]);
}
/**
* P5-11: 기타 입출고 (레거시 w_gb401e 수불년월·구분·마스터/상세)
*/
public function miscFlow()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$flowPeriod = $this->miscFlowParsePeriodFromRequest($this->request);
$flowYear = $flowPeriod['y'];
$flowMonthNum = $flowPeriod['m'];
$bagCodeFilter = trim((string) ($this->request->getGet('bag_code') ?? ''));
$bagKind = trim((string) ($this->request->getGet('bag_kind') ?? ''));
$bagCancelOnly = (int) ($this->request->getGet('bag_cancel') ?? 0) === 1;
$selKey = trim((string) ($this->request->getGet('sel_key') ?? ''));
$db = \Config\Database::connect();
$tableExists = $db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() > 0;
$kindE = model(CodeKindModel::class)->where('ck_code', 'E')->first();
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagKindOptions = $kindE
? model(CodeDetailModel::class)->getByKind((int) $kindE->ck_idx, true, $lgIdx)
: [];
$bagCodes = $kindO ? model(CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) : [];
$groups = [];
$rawRows = [];
$totalRowsForLg = 0;
if ($tableExists) {
$totalRow = $db->query(
'SELECT COUNT(*) AS c FROM bag_misc_flow WHERE bmf_lg_idx = ?',
[$lgIdx]
)->getRow();
$totalRowsForLg = (int) ($totalRow->c ?? 0);
$rawRows = $this->miscFlowFetchRows($db, $lgIdx, $flowYear, $flowMonthNum);
foreach ($rawRows as $row) {
if ($bagCodeFilter !== '' && ! str_contains((string) $row->bmf_bag_code, $bagCodeFilter)) {
continue;
}
if ($bagKind !== '' && ! $this->miscFlowBagCodeMatchesKind((string) $row->bmf_bag_code, $bagKind)) {
continue;
}
if ($bagCancelOnly && (string) $row->bmf_type !== 'out') {
continue;
}
$key = $this->miscFlowGroupKey($row);
if (! isset($groups[$key])) {
$groups[$key] = [
'key' => $key,
'date' => (string) $row->bmf_date,
'type' => (string) $row->bmf_type,
'typeLabel' => (string) $row->bmf_type === 'in' ? '입고' : '출고',
'reason' => (string) $row->bmf_reason,
'totalQty' => 0,
'lines' => [],
];
}
$groups[$key]['totalQty'] += (int) $row->bmf_qty;
$groups[$key]['lines'][] = $row;
}
}
$groupList = array_values($groups);
$selectedGroup = null;
$detailLines = [];
$selectedBagKind = '';
$selectedBagKindLabel = '';
if ($groupList !== []) {
if ($selKey !== '' && isset($groups[$selKey])) {
$selectedGroup = $groups[$selKey];
} else {
$selectedGroup = $groupList[0];
$selKey = (string) $selectedGroup['key'];
}
$detailLines = $selectedGroup['lines'];
if ($detailLines !== []) {
$selectedBagKind = $this->miscFlowInferBagKindFromCode((string) $detailLines[0]->bmf_bag_code);
foreach ($bagKindOptions as $opt) {
if ((string) $opt->cd_code === $selectedBagKind) {
$selectedBagKindLabel = (string) $opt->cd_name;
break;
}
}
}
}
$packagingMap = model(PackagingUnitModel::class)->latestActiveMapByBagCode($lgIdx);
$filters = [
'flow_y' => $flowYear,
'flow_m' => $flowMonthNum,
'bag_code' => $bagCodeFilter,
'bag_kind' => $bagKind,
'bag_cancel' => $bagCancelOnly,
'sel_key' => $selKey,
];
return $this->renderWorkPage('기타 입출고', 'admin/sales_report/misc_flow', [
'groupList' => $groupList,
'selectedGroup' => $selectedGroup,
'detailLines' => $detailLines,
'filters' => $filters,
'dateYearMin' => (int) date('Y') - 12,
'dateYearMax' => (int) date('Y') + 2,
'bagCodes' => $bagCodes,
'bagKindOptions' => $bagKindOptions,
'packagingMap' => $packagingMap,
'selectedBagKind' => $selectedBagKind,
'selectedBagKindLabel' => $selectedBagKindLabel,
'tableExists' => $tableExists,
'totalRowsForLg' => $totalRowsForLg,
'fetchedRowCount' => count($rawRows),
]);
}
/**
* P5-11: 기타 입출고 품목 등록
*/
public function miscFlowStore()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(mgmt_url('reports/misc-flow'))->with('error', '지자체를 선택해 주세요.');
}
$rules = [
'bmf_type' => 'required|in_list[in,out]',
'bmf_bag_code' => 'required|max_length[50]',
'bmf_qty' => 'required|is_natural_no_zero',
'bmf_date' => 'required|valid_date[Y-m-d]',
'bmf_reason' => 'required|max_length[200]',
];
if (! $this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$bagCode = (string) $this->request->getPost('bmf_bag_code');
$bagKindFilter = trim((string) $this->request->getPost('bmf_bag_kind'));
if ($bagKindFilter !== '' && ! $this->miscFlowBagCodeMatchesKind($bagCode, $bagKindFilter)) {
return redirect()->back()->withInput()->with('error', '선택한 봉투구분과 봉투코드가 일치하지 않습니다.');
}
$qty = (int) $this->request->getPost('bmf_qty');
$type = (string) $this->request->getPost('bmf_type');
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$detail = $kindO
? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, $bagCode, $lgIdx)
: null;
$bagName = $detail ? (string) $detail->cd_name : '';
$db = \Config\Database::connect();
if ($db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() === 0) {
return redirect()->to(mgmt_url('reports/misc-flow'))->with('error', 'bag_misc_flow 테이블이 없습니다.');
}
$db->transStart();
$db->query("
INSERT INTO bag_misc_flow (bmf_lg_idx, bmf_type, bmf_bag_code, bmf_bag_name, bmf_qty, bmf_date, bmf_reason, bmf_regdate)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
", [
$lgIdx,
$type,
$bagCode,
$bagName,
$qty,
$this->request->getPost('bmf_date'),
$this->request->getPost('bmf_reason'),
date('Y-m-d H:i:s'),
]);
$delta = ($type === 'in') ? $qty : -$qty;
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, $delta);
$db->transComplete();
$redirectQs = $this->miscFlowRedirectQueryFromPost();
$newKey = $this->miscFlowGroupKey((object) [
'bmf_date' => $this->request->getPost('bmf_date'),
'bmf_type' => $type,
'bmf_reason' => $this->request->getPost('bmf_reason'),
]);
return redirect()->to(mgmt_url('reports/misc-flow') . '?' . $redirectQs . '&sel_key=' . rawurlencode($newKey))
->with('success', '기타 입출고가 등록되었습니다.');
}
/**
* P5-11: 선택 (그룹) 삭제 + 재고 복원
*/
public function miscFlowDelete()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(mgmt_url('reports/misc-flow'))->with('error', '지자체를 선택해 주세요.');
}
$selKey = trim((string) $this->request->getPost('sel_key'));
if ($selKey === '') {
return redirect()->back()->with('error', '삭제할 입출고를 선택해 주세요.');
}
$db = \Config\Database::connect();
if ($db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() === 0) {
return redirect()->to(mgmt_url('reports/misc-flow'))->with('error', 'bag_misc_flow 테이블이 없습니다.');
}
$flowPeriod = $this->miscFlowParsePeriodFromRequest($this->request);
$rows = $this->miscFlowFetchRows($db, $lgIdx, $flowPeriod['y'], $flowPeriod['m']);
$toDelete = [];
foreach ($rows as $row) {
if ($this->miscFlowGroupKey($row) === $selKey) {
$toDelete[] = $row;
}
}
if ($toDelete === []) {
return redirect()->back()->with('error', '삭제할 내역을 찾을 수 없습니다.');
}
$inventory = model(BagInventoryModel::class);
$db->transStart();
foreach ($toDelete as $row) {
$qty = (int) $row->bmf_qty;
$delta = ((string) $row->bmf_type === 'in') ? -$qty : $qty;
$inventory->adjustQty(
$lgIdx,
(string) $row->bmf_bag_code,
(string) $row->bmf_bag_name,
$delta
);
$db->query('DELETE FROM bag_misc_flow WHERE bmf_idx = ? AND bmf_lg_idx = ?', [(int) $row->bmf_idx, $lgIdx]);
}
$db->transComplete();
$redirectQs = $this->miscFlowRedirectQueryFromPost(false);
return redirect()->to(mgmt_url('reports/misc-flow') . ($redirectQs !== '' ? '?' . $redirectQs : ''))
->with('success', '선택한 기타 입출고가 삭제되었습니다.');
}
/**
* @return array{y: string, m: string}
*/
private function miscFlowParsePeriodFromRequest($request): array
{
$flowYear = trim((string) ($request->getGet('flow_y') ?? $request->getPost('flow_y') ?? ''));
$flowMonthNum = trim((string) ($request->getGet('flow_m') ?? $request->getPost('flow_m') ?? ''));
if ($flowYear === '' && $flowMonthNum === '') {
$legacy = $this->miscFlowNormalizeMonth((string) ($request->getGet('flow_month') ?? $request->getPost('flow_month') ?? ''));
if ($legacy !== '' && preg_match('/^(\d{4})-(\d{1,2})$/', $legacy, $m) === 1) {
$flowYear = $m[1];
$flowMonthNum = (string) (int) $m[2];
}
}
if ($flowYear !== '' && ctype_digit($flowYear)) {
$flowYear = sprintf('%04d', (int) $flowYear);
} else {
$flowYear = '';
}
if ($flowMonthNum !== '' && ctype_digit($flowMonthNum)) {
$monthInt = (int) $flowMonthNum;
$flowMonthNum = ($monthInt >= 1 && $monthInt <= 12) ? (string) $monthInt : '';
} else {
$flowMonthNum = '';
}
if ($flowYear === '') {
$flowMonthNum = '';
}
return ['y' => $flowYear, 'm' => $flowMonthNum];
}
private function miscFlowNormalizeMonth(string $raw): string
{
$raw = str_replace('.', '-', trim($raw));
if ($raw === '') {
return '';
}
if (preg_match('/^\d{4}-\d{1,2}$/', $raw) === 1) {
[$y, $m] = explode('-', $raw, 2);
return sprintf('%04d-%02d', (int) $y, (int) $m);
}
if (preg_match('/^\d{6}$/', $raw) === 1) {
return substr($raw, 0, 4) . '-' . substr($raw, 4, 2);
}
return '';
}
/**
* @param \CodeIgniter\Database\BaseConnection $db
* @return list<object>
*/
private function miscFlowFetchRows($db, int $lgIdx, string $flowYear, string $flowMonthNum): array
{
if ($flowYear !== '' && $flowMonthNum !== '') {
$monthStart = sprintf('%04d-%02d-01', (int) $flowYear, (int) $flowMonthNum);
$monthEnd = date('Y-m-t', strtotime($monthStart));
return $db->query("
SELECT * FROM bag_misc_flow
WHERE bmf_lg_idx = ? AND bmf_date BETWEEN ? AND ?
ORDER BY bmf_date DESC, bmf_regdate DESC, bmf_idx DESC
", [$lgIdx, $monthStart, $monthEnd])->getResult();
}
if ($flowYear !== '') {
$yearStart = sprintf('%04d-01-01', (int) $flowYear);
$yearEnd = sprintf('%04d-12-31', (int) $flowYear);
return $db->query("
SELECT * FROM bag_misc_flow
WHERE bmf_lg_idx = ? AND bmf_date BETWEEN ? AND ?
ORDER BY bmf_date DESC, bmf_regdate DESC, bmf_idx DESC
", [$lgIdx, $yearStart, $yearEnd])->getResult();
}
return $db->query("
SELECT * FROM bag_misc_flow
WHERE bmf_lg_idx = ?
ORDER BY bmf_date DESC, bmf_regdate DESC, bmf_idx DESC
", [$lgIdx])->getResult();
}
private function miscFlowGroupKey(object $row): string
{
$date = (string) ($row->bmf_date ?? '');
$type = (string) ($row->bmf_type ?? '');
$reason = (string) ($row->bmf_reason ?? '');
return md5($date . '|' . $type . '|' . $reason);
}
private function miscFlowInferBagKindFromCode(string $bagCode): string
{
$bagCode = trim($bagCode);
if ($bagCode === '') {
return '';
}
if (strlen($bagCode) >= 2 && ctype_digit(substr($bagCode, 0, 2))) {
return substr($bagCode, 0, 2);
}
return '';
}
private function miscFlowBagCodeMatchesKind(string $bagCode, string $bagKind): bool
{
if ($bagKind === '') {
return true;
}
return str_starts_with($bagCode, $bagKind);
}
private function miscFlowRedirectQueryFromPost(bool $includeSelKey = true): string
{
$params = array_filter([
'flow_y' => $this->request->getPost('flow_y') ?? $this->request->getGet('flow_y'),
'flow_m' => $this->request->getPost('flow_m') ?? $this->request->getGet('flow_m'),
'bag_code' => $this->request->getPost('bag_code') ?? $this->request->getGet('bag_code'),
'bag_kind' => $this->request->getPost('bag_kind') ?? $this->request->getGet('bag_kind'),
'bag_cancel' => $this->request->getPost('bag_cancel') ?? $this->request->getGet('bag_cancel'),
], static fn ($v) => $v !== null && $v !== '' && $v !== '0');
if ($includeSelKey) {
$sel = $this->request->getPost('sel_key');
if ($sel !== null && $sel !== '') {
$params['sel_key'] = $sel;
}
}
return http_build_query($params);
}
/**
* 쓰레기봉투 수급 계획 (레거시 w_gm820r)
*/
public function supplyDemand()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$refDate = (string) ($this->request->getGet('ref_date') ?? date('Y-m-d'));
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $refDate)) {
$refDate = date('Y-m-d');
}
$leadDays = (int) ($this->request->getGet('lead_days') ?? 40);
if ($leadDays < 1 || $leadDays > 365) {
$leadDays = 40;
}
$stockScope = (string) ($this->request->getGet('stock_scope') ?? 'all');
$salesScope = (string) ($this->request->getGet('sales_scope') ?? 'all');
if (! in_array($stockScope, ['all', 'legacy', 'barcode'], true)) {
$stockScope = 'all';
}
if (! in_array($salesScope, ['all', 'legacy', 'barcode'], true)) {
$salesScope = 'all';
}
$queried = $this->request->getGet('search') === '1';
$built = (new \App\Libraries\BagSupplyPlanBuilder())->build(
$lgIdx,
$refDate,
$leadDays,
$stockScope,
$salesScope,
$queried
);
$scopeLabel = static fn (string $s): string => match ($s) {
'legacy' => '기존 봉투',
'barcode' => '바코드 봉투',
default => 'ALL',
};
return $this->renderWorkPage('쓰레기봉투 수급 계획', 'admin/sales_report/supply_demand', [
'refDate' => $refDate,
'leadDays' => $leadDays,
'stockScope' => $stockScope,
'salesScope' => $salesScope,
'rows' => $built['rows'],
'queried' => $built['queried'],
'stockLabel' => $scopeLabel($stockScope),
'salesLabel' => $scopeLabel($salesScope),
]);
}
}