feat: 财务分析按开票口径拆分费用

This commit is contained in:
yueyang 2026-10-23 18:49:03 +08:00
parent cb158d5c99
commit f973153f76
4 changed files with 281 additions and 20 deletions

View File

@ -48,6 +48,7 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\View\BillOffsetPageData;
use Modules\EcommerceFinance\View\BillingDetailsPageData;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
@ -2398,6 +2399,7 @@ class Pages extends Controller
* 收入 = 业绩明细里(收入金额 + 优惠金额 + 冲减金额 + 退款金额)按各字段记账符号求和
* (冲减、退款取数本身带负号,直接相加即为净收入);
* 成本 = 商品成本表的商品成本汇总;运费暂不取数,列上留空。
* 费用 = 店铺收款账户账单中财务类型为「费用」的支出减收入,按票据口径拆为开票 / 不开票。
*/
protected function contentForFinancialAnalysis(): array
{
@ -2441,11 +2443,15 @@ class Pages extends Controller
$incomeTotal = 0.0;
$costTotal = 0.0;
$invoiceExpenseTotal = 0.0;
$nonInvoiceExpenseTotal = 0.0;
/** @var array<string,true> $platformOptions */
$platformOptions = [];
foreach ($rows as $row) {
$incomeTotal += (float) $row['income'];
$costTotal += (float) $row['cost'];
$invoiceExpenseTotal += (float) $row['invoice_expense'];
$nonInvoiceExpenseTotal += (float) $row['non_invoice_expense'];
if ($row['platform'] !== '') {
$platformOptions[(string) $row['platform']] = true;
}
@ -2466,6 +2472,8 @@ class Pages extends Controller
'rows' => count($rows),
'income_total' => round($incomeTotal, 2),
'cost_total' => round($costTotal, 2),
'invoice_expense_total' => round($invoiceExpenseTotal, 2),
'non_invoice_expense_total' => round($nonInvoiceExpenseTotal, 2),
],
'financialAnalysisMonths' => $months,
'financialAnalysisPlatforms' => $platformOptions,
@ -2479,7 +2487,7 @@ class Pages extends Controller
* 页面与导出共用,保证两边口径一致。
*
* @param array{month:string,platform:string,store_name:string} $filters
* @return list<array{month:string,platform:string,store_name:string,income:float,cost:float}>
* @return list<array{month:string,platform:string,store_name:string,income:float,cost:float,invoice_expense:float,non_invoice_expense:float}>
*/
private function financialAnalysisRows(array $filters): array
{
@ -2534,14 +2542,14 @@ class Pages extends Controller
*
* 两张表都是几十万行,聚合结果按月份缓存 5 分钟;筛选在内存里做,避免每次开页重跑分组。
*
* @return array<string,array{platform:string,store_name:string,income:float,cost:float}>
* @return array<string,array{platform:string,store_name:string,income:float,cost:float,invoice_expense:float,non_invoice_expense:float}>
*/
private function financialAnalysisAggregates(string $reportMonth): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
return cache()->remember(
'financial-analysis-aggregates-v1:' . $reportMonth,
'financial-analysis-aggregates-v3:' . $reportMonth . ':' . $this->financialAnalysisMappingSignature(),
now()->addMinutes(5),
static function () use ($connection, $reportMonth): array {
$rows = [];
@ -2559,9 +2567,27 @@ class Pages extends Controller
'store_name' => (string) $row->store_name,
'income' => round((float) $row->income, 2),
'cost' => 0.0,
'invoice_expense' => 0.0,
'non_invoice_expense' => 0.0,
];
}
$expenseBuckets = app(BillingDetailsPageData::class)
->expenseBucketsForMonth(substr($reportMonth, 0, 7));
foreach ($expenseBuckets as $bucket) {
$key = $bucket['platform'] . "\0" . $bucket['store_name'];
$rows[$key] ??= [
'platform' => $bucket['platform'],
'store_name' => $bucket['store_name'],
'income' => 0.0,
'cost' => 0.0,
'invoice_expense' => 0.0,
'non_invoice_expense' => 0.0,
];
$rows[$key]['invoice_expense'] = round((float) $bucket['invoice_expense'], 2);
$rows[$key]['non_invoice_expense'] = round((float) $bucket['non_invoice_expense'], 2);
}
$costRows = DB::connection($connection)->table('store_receipt_product_cost_rows')
->where('report_month', $reportMonth)
->selectRaw('platform, store_name, SUM(COALESCE(product_cost, 0)) as cost')
@ -2574,6 +2600,8 @@ class Pages extends Controller
'store_name' => $rows[$key]['store_name'] ?? (string) $row->store_name,
'income' => $rows[$key]['income'] ?? 0.0,
'cost' => round((float) $row->cost, 2),
'invoice_expense' => $rows[$key]['invoice_expense'] ?? 0.0,
'non_invoice_expense' => $rows[$key]['non_invoice_expense'] ?? 0.0,
];
}
@ -2582,6 +2610,17 @@ class Pages extends Controller
);
}
/** 科目映射变化时自动换缓存键,避免费用拆票结果在 5 分钟缓存内继续使用旧口径。 */
private function financialAnalysisMappingSignature(): string
{
$rules = app(AccountMappingStore::class)->all();
return substr(hash(
'sha256',
(string) json_encode($rules, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
), 0, 16);
}
/**
* 财务经营分析表导出:按当前筛选条件导出「时间 / 平台 / 店铺 / 收入 / 成本 / 运费」。
*/
@ -2607,7 +2646,7 @@ class Pages extends Controller
@set_time_limit(0);
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['时间', '平台', '店铺', '收入', '成本', '运费']);
fputcsv($out, ['时间', '平台', '店铺', '收入', '成本', '运费', '开票费用', '不开票费用']);
foreach ($rows as $row) {
fputcsv($out, [
$row['month'],
@ -2617,6 +2656,8 @@ class Pages extends Controller
number_format((float) $row['cost'], 2, '.', ''),
// 运费暂不取数,先留空列,等口径确认后补。
'',
number_format((float) $row['invoice_expense'], 2, '.', ''),
number_format((float) $row['non_invoice_expense'], 2, '.', ''),
]);
}
fclose($out);

View File

@ -42,6 +42,16 @@ class BillingDetailsPageData
'唯品会MP' => '唯品会MP', '唯品会JX' => '唯品会JX', '支付宝' => '支付宝',
];
/** 账单来源页签 → 财务经营分析表使用的平台列。 */
private const ACCOUNT_LABELS = [
'淘系支付宝' => '淘系支付宝',
'淘系微信' => '淘系微信',
'拼多多' => '拼多多',
'京东钱包' => '京东网银钱包',
'抖音' => '抖音',
'得物' => '得物',
];
/**
* 平台筛选:按「账户 / 数据来源」分类,与「店铺收款账户账单」各页签一致。
* source 对应 self::SOURCES 的分支;prefix 非空时再按店铺前缀切分(京东账单 / 京喜账单同属一张表)。
@ -330,6 +340,193 @@ class BillingDetailsPageData
];
}
/**
* 财务类型为「费用」的账单金额,按财务经营分析表的「平台-店铺」及票据口径汇总。
*
* 金额口径为账单「支出 - 收入」,费用返还/退回以负数抵减费用。
* 票据口径 0.06 / 0.09 归入开票费用,不开票归入不开票费用。
*
* @return array<string,array{platform:string,store_name:string,invoice_expense:float,non_invoice_expense:float}>
*/
public function expenseBucketsForMonth(string $reportMonth): array
{
if (preg_match('/^\d{4}-\d{2}$/', $reportMonth) !== 1) {
return [];
}
$filters = $this->parseFilters(['month' => $reportMonth]);
$filters['platform'] = [];
$filters['store'] = [];
$filters['item'] = [];
$filters['type'] = [];
$filters['remark'] = [];
$filters['keyword'] = '';
$connection = $this->connection();
$buckets = [];
$rulesByPlatform = $this->expenseRulesByPlatform();
foreach (self::SOURCES as $label => $meta) {
$branch = $this->branch($label, $meta, $filters);
$conditions = ['invoice_expense' => [], 'non_invoice_expense' => []];
foreach (self::BRANCH_PLATFORMS[$label] ?? [] as $platform) {
foreach ($conditions as $field => $_) {
$items = array_keys($rulesByPlatform[$platform][$field] ?? []);
if ($items === []) {
continue;
}
$conditions[$field][] = [
'sql' => '(' . $this->storePrefixCondition($platform)
. ' AND bill_item IN (' . $this->literals($this->expenseItemVariants($items)) . '))',
'account' => $this->accountLabelForPlatform($label, $platform),
];
}
}
if ($conditions['invoice_expense'] === [] && $conditions['non_invoice_expense'] === []) {
continue;
}
$amount = 'COALESCE(' . $branch['expense'] . ', 0) - COALESCE(' . $branch['income'] . ', 0)';
foreach ($branch['scopes'] as $scope) {
$parts = [];
$bind = [];
foreach ($conditions as $field => $items) {
foreach ($items as $condition) {
$parts[] = 'SELECT ' . $this->literal((string) $condition['account']) . ' AS account_label, store_name, '
. ($field === 'invoice_expense' ? 'COALESCE(SUM(' . $amount . '), 0)' : '0') . ' AS invoice_expense, '
. ($field === 'non_invoice_expense' ? 'COALESCE(SUM(' . $amount . '), 0)' : '0') . ' AS non_invoice_expense '
. 'FROM ' . $branch['table'] . ' WHERE ' . $scope['where']
. ' AND ' . $condition['sql'] . ' GROUP BY store_name';
$bind = array_merge($bind, $scope['bind']);
}
}
$rows = $connection->select(
'SELECT account_label, store_name, '
. 'COALESCE(SUM(invoice_expense), 0) AS invoice_expense, '
. 'COALESCE(SUM(non_invoice_expense), 0) AS non_invoice_expense '
. 'FROM (' . implode(' UNION ALL ', $parts) . ') expense_rows '
. 'GROUP BY account_label, store_name',
$bind,
);
foreach ($rows as $row) {
$store = trim((string) ($row->store_name ?? ''));
$account = trim((string) ($row->account_label ?? ''));
if ($store === '' || $account === '') {
continue;
}
$key = $account . "\0" . $store;
$buckets[$key] ??= [
'platform' => $account,
'store_name' => $store,
'invoice_expense' => 0.0,
'non_invoice_expense' => 0.0,
];
$buckets[$key]['invoice_expense'] = round(
(float) $buckets[$key]['invoice_expense'] + (float) $row->invoice_expense,
2,
);
$buckets[$key]['non_invoice_expense'] = round(
(float) $buckets[$key]['non_invoice_expense'] + (float) $row->non_invoice_expense,
2,
);
}
}
}
foreach ($buckets as &$bucket) {
$bucket['invoice_expense'] = round((float) $bucket['invoice_expense'], 2);
$bucket['non_invoice_expense'] = round((float) $bucket['non_invoice_expense'], 2);
}
unset($bucket);
return $buckets;
}
private function accountLabelForPlatform(string $source, string $platform): string
{
if ($source === '京东账单') {
return $platform === '京喜' ? '京喜账单' : '京东账单';
}
return self::ACCOUNT_LABELS[$source] ?? $source;
}
/**
* 财务类型为「费用」的规则,按业务平台和票据口径分组。
*
* 当前科目映射保证同一平台同一账单项目的「费用」规则票据口径一致;
* 若以后出现同项目既开票又不开票,直接报错,避免静默算错。
*
* @return array<string,array{invoice_expense:array<string,true>,non_invoice_expense:array<string,true>}>
*/
private function expenseRulesByPlatform(): array
{
$itemMeta = [];
$feeRules = [];
foreach (app(AccountMappingStore::class)->all() as $rule) {
$platform = trim((string) ($rule['platform'] ?? ''));
$item = $this->normalizeItem((string) ($rule['item'] ?? ''));
$category = trim((string) ($rule['category'] ?? ''));
$invoice = trim((string) ($rule['invoice'] ?? ''));
if ($platform === '' || $item === '') {
continue;
}
$key = mb_strtolower($platform) . "\0" . mb_strtolower($item);
$itemMeta[$key]['categories'][$category] = true;
$itemMeta[$key]['invoices'][$invoice] = true;
if ($category === '费用') {
$feeRules[] = [$platform, $item, $invoice];
}
}
foreach ($feeRules as [$platform, $item, $invoice]) {
$item = $this->normalizeItem($item);
$meta = $itemMeta[mb_strtolower($platform) . "\0" . mb_strtolower($item)] ?? [];
if (count($meta['categories'] ?? []) > 1 || count($meta['invoices'] ?? []) > 1) {
throw new \RuntimeException(
'费用票据口径无法唯一确定:' . $platform . ' / ' . $item,
);
}
$bucket = match ($invoice) {
'0.06', '0.09' => 'invoice_expense',
'不开票' => 'non_invoice_expense',
default => throw new \RuntimeException(
'费用票据口径未支持:' . $platform . ' / ' . $item . ' / ' . $invoice,
),
};
$groups[$platform][$bucket][$item] = true;
}
return $groups ?? [];
}
/**
* 科目映射表里的账单项目同时生成带 / 不带 wx 前缀的查找值,
* 保留各账单表的 bill_item 索引,避免对整月数据执行 SUBSTRING。
*
* @param list<string> $items
* @return list<string>
*/
private function expenseItemVariants(array $items): array
{
$variants = [];
foreach ($items as $item) {
$item = trim((string) $item);
if ($item === '') {
continue;
}
$variants[$item] = true;
$variants[preg_match('/^wx/i', $item) === 1 ? $this->normalizeItem($item) : 'wx' . $item] = true;
}
$variants = array_keys($variants);
sort($variants);
return $variants;
}
/**
* 筛选联动:按「除该筛选项以外的其它条件」统计各筛选项的可选值与行数,行数为 0 的取值不返回。
* 结果按筛选条件签名缓存 60 秒,避免连续勾选时反复扫同一批数据。
@ -594,7 +791,7 @@ class BillingDetailsPageData
/**
* 账单项目 → [平台 => ['memo'=>..,'category'=>..]] 预索引,避免逐项遍历全部规则。
*
* @param array<string,array{memo:string,category:string,catch:bool}> $rules
* @param array<string,array{memo:string,category:string,invoice:string,catch:bool}> $rules
* @return array<string,array<string,array{memo:string,category:string}>>
*/
private function ruleIndexByItem(array $rules): array
@ -896,6 +1093,7 @@ class BillingDetailsPageData
$row['platform'] = $platform;
$row['finance_remark'] = (string) ($rule['memo'] ?? '');
$row['finance_type'] = (string) ($rule['category'] ?? '');
$row['finance_invoice'] = (string) ($rule['invoice'] ?? '');
$row['matched'] = $rule !== null;
// 同一账单项目多条规则时按业务备注/商品名称关键词取最具体的一条(与店铺收款账户账单页同一口径)
@ -921,6 +1119,7 @@ class BillingDetailsPageData
if ($matched !== null) {
$row['finance_remark'] = (string) ($matched['rule']['memo'] ?? '');
$row['finance_type'] = (string) ($matched['rule']['category'] ?? '');
$row['finance_invoice'] = (string) ($matched['rule']['invoice'] ?? '');
$row['matched'] = true;
}
}
@ -1140,7 +1339,7 @@ class BillingDetailsPageData
return $this->columnCache[$table];
}
/** 科目映射表:平台|账单项目 → [memo, category](共享数据库,随页面请求实时读取) */
/** 科目映射表:平台|账单项目 → [memo, category, invoice](共享数据库,随页面请求实时读取) */
private function rules(): array
{
static $cache = null;
@ -1156,7 +1355,12 @@ class BillingDetailsPageData
$prev = $cache[$key] ?? null;
$isCatchAll = trim((string) ($rule['memo'] ?? '')) === '' && trim((string) ($rule['goods'] ?? '')) === '';
if ($prev === null || ($isCatchAll && ! $prev['catch'])) {
$cache[$key] = ['memo' => (string) ($rule['memo'] ?? ''), 'category' => (string) ($rule['category'] ?? ''), 'catch' => $isCatchAll];
$cache[$key] = [
'memo' => (string) ($rule['memo'] ?? ''),
'category' => (string) ($rule['category'] ?? ''),
'invoice' => (string) ($rule['invoice'] ?? ''),
'catch' => $isCatchAll,
];
}
}
return $cache;

View File

@ -27,7 +27,7 @@
.financial-analysis-summary .item span { color:var(--vip-content-muted,#595959); font-size:.7rem; }
.financial-analysis-summary .item b { font-size:.95rem; font-variant-numeric:tabular-nums; }
.financial-analysis-table-wrap { overflow:auto; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; }
.financial-analysis-table { width:100%; min-width:56rem; border-collapse:separate; border-spacing:0; font-size:.78rem; }
.financial-analysis-table { width:100%; min-width:68rem; border-collapse:separate; border-spacing:0; font-size:.78rem; }
.financial-analysis-table th,.financial-analysis-table td { padding:.55rem .65rem; border-right:1px solid var(--vip-border,#D8D2C7); border-bottom:1px solid var(--vip-border,#D8D2C7); white-space:nowrap; vertical-align:top; }
.financial-analysis-table th { position:sticky; top:0; z-index:1; background:var(--vip-content-accent-soft,#F7F0DE); text-align:left; font-weight:700; }
.financial-analysis-table tr:hover td { background:#fcfaf4; }
@ -75,7 +75,8 @@
<div class="financial-analysis-note">
口径:一行 = 一个月的一个平台的一个店铺。收入 = 业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」,
按各字段自身的记账符号求和(冲减、退款在取数时已是负数,相加即为净收入);
成本 = 商品成本表按店铺汇总的商品成本(四舍五入两位小数);运费暂不取数,列上留空。
成本 = 商品成本表按店铺汇总的商品成本(四舍五入两位小数);运费暂不取数,列上留空;
开票费用 / 不开票费用 = 店铺收款账户账单中财务类型为「费用」的支出减收入,按科目映射表票据口径 0.06 / 0.09 与不开票分别汇总。
</div>
<div class="financial-analysis-summary">
@ -83,6 +84,8 @@
<div class="item"><span>收入合计</span><b>{{ $money($summary['income_total'] ?? null) }}</b></div>
<div class="item"><span>成本合计</span><b>{{ $money($summary['cost_total'] ?? null) }}</b></div>
<div class="item"><span>运费合计</span><b class="muted">暂不取数</b></div>
<div class="item"><span>开票费用合计</span><b>{{ $money($summary['invoice_expense_total'] ?? null) }}</b></div>
<div class="item"><span>不开票费用合计</span><b>{{ $money($summary['non_invoice_expense_total'] ?? null) }}</b></div>
</div>
<div class="financial-analysis-table-wrap">
@ -94,6 +97,8 @@
<th class="num">收入</th>
<th class="num">成本</th>
<th class="num">运费</th>
<th class="num">开票费用</th>
<th class="num">不开票费用</th>
</tr></thead>
<tbody>
@forelse ($rows ?? [] as $row)
@ -104,9 +109,11 @@
<td class="num">{{ $money($row['income'] ?? null) }}</td>
<td class="num">{{ $money($row['cost'] ?? null) }}</td>
<td class="num muted">—</td>
<td class="num">{{ $money($row['invoice_expense'] ?? null) }}</td>
<td class="num">{{ $money($row['non_invoice_expense'] ?? null) }}</td>
</tr>
@empty
<tr><td colspan="6"><div class="financial-analysis-empty">
<tr><td colspan="8"><div class="financial-analysis-empty">
{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的财务经营分析数据;请确认业绩明细与商品成本已生成。' }}
</div></td></tr>
@endforelse

View File

@ -83,7 +83,7 @@ class NavigationTest extends FeatureTestCase
public function testPerformanceDetailsPageIsAvailable(): void
{
$response = $this->loginAs()
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'performance-details',
@ -121,17 +121,26 @@ class NavigationTest extends FeatureTestCase
->assertSee('name="match_status"', false)
->assertSee('/workspace/financial-operations/performance-details/export', false)
->assertSeeText('店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。');
}
$html = (string) $response->getContent();
$offsetPosition = strpos($html, 'data-column-key="offset_amount"');
$compensationPosition = strpos($html, 'data-column-key="default_compensation_amount"');
$refundPosition = strpos($html, 'data-column-key="refund_amount"');
public function testFinancialAnalysisPageIncludesInvoiceExpenseColumns(): void
{
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'financial-analysis',
]))
->assertOk()
->assertSeeTextInOrder(['收入', '成本', '运费', '开票费用', '不开票费用'])
->assertSeeText('开票费用合计')
->assertSeeText('不开票费用合计')
->assertSee('/workspace/financial-operations/financial-analysis/export', false);
$this->assertNotFalse($offsetPosition);
$this->assertNotFalse($compensationPosition);
$this->assertNotFalse($refundPosition);
$this->assertLessThan($compensationPosition, $offsetPosition);
$this->assertLessThan($refundPosition, $compensationPosition);
$csv = $this->loginAs()
->get(route('business.financial-operations.financial-analysis.export'))
->streamedContent();
$this->assertStringContainsString('时间,平台,店铺,收入,成本,运费,开票费用,不开票费用', $csv);
}
public function testFinancialRiskControlPageIsAvailableBetweenFinancialReports(): void