财务分析新增开票/不开票费用,并补齐违约赔付预览 #480
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\StoreReceiptDefaultCompensationSnapshotBuilder;
|
||||
use Throwable;
|
||||
|
||||
class PreviewStoreReceiptDefaultCompensation extends Command
|
||||
{
|
||||
protected $signature = 'store-receipt:default-compensation:preview
|
||||
{month : 账期 YYYY-MM}
|
||||
{mapping : 最新科目映射表 xlsx 路径}
|
||||
{--output= : 输出 JSON 路径;默认写入 storage/app/local/performance-details/default-compensation}';
|
||||
|
||||
protected $description = '本地预览:按最新科目映射表生成业绩明细「违约赔付(冲减收入)」金额快照,不写数据库';
|
||||
|
||||
public function handle(StoreReceiptDefaultCompensationSnapshotBuilder $builder): int
|
||||
{
|
||||
$startedAt = hrtime(true);
|
||||
$month = trim((string) $this->argument('month'));
|
||||
$mapping = (string) $this->argument('mapping');
|
||||
|
||||
try {
|
||||
$snapshot = $builder->build($month, $mapping);
|
||||
$path = (string) ($this->option('output') ?: storage_path(
|
||||
'app/local/performance-details/default-compensation/' . $month . '.json'
|
||||
));
|
||||
$directory = dirname($path);
|
||||
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
|
||||
throw new \RuntimeException('无法创建快照目录:' . $directory);
|
||||
}
|
||||
$temporary = $path . '.tmp';
|
||||
$json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
if ($json === false || file_put_contents($temporary, $json, LOCK_EX) === false) {
|
||||
throw new \RuntimeException('无法写入本地快照。');
|
||||
}
|
||||
rename($temporary, $path);
|
||||
|
||||
$this->info('本地预览快照已生成:' . $path);
|
||||
$this->line('映射条数:' . $snapshot['mapping_rows']);
|
||||
$this->line('关联行数:' . count($snapshot['amounts']));
|
||||
$this->line('金额合计:' . number_format((float) $snapshot['total'], 2, '.', ''));
|
||||
$this->line('耗时:' . number_format((hrtime(true) - $startedAt) / 1e6, 0) . ' ms');
|
||||
$this->line('PHP 峰值内存:' . number_format(memory_get_peak_usage(true) / 1024 / 1024, 1) . ' MB');
|
||||
foreach ($snapshot['source_stats'] as $platform => $stats) {
|
||||
$this->line(sprintf(
|
||||
' %s:账单行 %d,金额 %s',
|
||||
$platform,
|
||||
$stats['rows'],
|
||||
number_format((float) $stats['amount'], 2, '.', ''),
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -121,9 +121,13 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
'优惠收入',
|
||||
];
|
||||
|
||||
/** 「冲减金额」列口径:账单的总账备注命中以下取值,不参与收入金额与差额计算。 */
|
||||
/**
|
||||
* 「冲减金额」列口径:账单的总账备注命中以下取值,不参与收入金额与差额计算。
|
||||
* 「违约赔付(冲减收入)」是冲减收入的细分项,总额仍计入冲减金额。
|
||||
*/
|
||||
private const OFFSET_REMARKS = [
|
||||
'冲减收入',
|
||||
'违约赔付(冲减收入)',
|
||||
];
|
||||
|
||||
/** 「退款金额」列口径:账单的总账备注命中以下取值,单独列示,不参与收入金额与差额计算。 */
|
||||
|
||||
@ -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;
|
||||
@ -1328,6 +1329,7 @@ class Pages extends Controller
|
||||
$unmatchedReasonFilter = $this->receiptOrderMatchFilterValues(request('unmatched_reason'));
|
||||
$differenceState = $this->receiptOrderMatchFilterValues(request('difference_state'));
|
||||
$orderNo = trim((string) request('online_order_no', ''));
|
||||
$defaultCompensationAmounts = self::localReceiptDefaultCompensationAmounts($month);
|
||||
|
||||
// 闭包里要用 $this->applyReceiptRemarkFilter()(差异原因备注按公式判定),所以不能声明为 static
|
||||
$applyFilters = function ($query) use ($platformFilter, $storeFilter, $orderNo, $statusFilter, $remarkFilter, $unmatchedReasonFilter, $differenceState): void {
|
||||
@ -1479,8 +1481,9 @@ class Pages extends Controller
|
||||
|
||||
// 差额异常备注列:只展示「差额为什么产生」的原因备注;未匹配原因单独一列;
|
||||
// 未匹配行(差额为空)按比对基准金额=0 计入差额。
|
||||
$items = $items->map(static function (object $row): object {
|
||||
$items = $items->map(static function (object $row) use ($defaultCompensationAmounts): object {
|
||||
$rawRemark = trim((string) ($row->remark ?? ''));
|
||||
$row->default_compensation_amount = $defaultCompensationAmounts[(string) ($row->row_key ?? '')] ?? null;
|
||||
// 未匹配原因列:未匹配行照旧;「订单实付按卖家实收核对」这类口径标识在已匹配行同样展示。
|
||||
$row->unmatched_reason = $rawRemark === ''
|
||||
? null
|
||||
@ -1653,6 +1656,44 @@ class Pages extends Controller
|
||||
return array_keys($clean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地预览快照:读取按最新科目映射表生成的「违约赔付(冲减收入)」金额。
|
||||
* 文件不存在或内容无效时返回空数组,不影响正式页面。
|
||||
*
|
||||
* @return array<string,float>
|
||||
*/
|
||||
private static function localReceiptDefaultCompensationAmounts(?string $month): array
|
||||
{
|
||||
static $snapshots = [];
|
||||
|
||||
$month = substr(trim((string) $month), 0, 7);
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
|
||||
return [];
|
||||
}
|
||||
if (isset($snapshots[$month])) {
|
||||
return $snapshots[$month];
|
||||
}
|
||||
|
||||
$path = storage_path('app/local/performance-details/default-compensation/' . $month . '.json');
|
||||
if (! is_file($path)) {
|
||||
return $snapshots[$month] = [];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
if (! is_array($decoded) || ! is_array($decoded['amounts'] ?? null)) {
|
||||
return $snapshots[$month] = [];
|
||||
}
|
||||
|
||||
$amounts = [];
|
||||
foreach ($decoded['amounts'] as $rowKey => $amount) {
|
||||
if (is_string($rowKey) && $rowKey !== '' && is_numeric($amount)) {
|
||||
$amounts[$rowKey] = round((float) $amount, 2);
|
||||
}
|
||||
}
|
||||
|
||||
return $snapshots[$month] = $amounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业绩明细的账期集合与当前账期:未指定月份时取镜像里最新的月份。
|
||||
*
|
||||
@ -2080,6 +2121,7 @@ class Pages extends Controller
|
||||
'income_amount' => ['income_amount', '收入金额', 'money'],
|
||||
'discount_amount' => ['discount_amount', '优惠金额', 'money'],
|
||||
'offset_amount' => ['offset_amount', '冲减金额', 'money'],
|
||||
'default_compensation_amount' => ['default_compensation_amount', '违约赔付(冲减收入)', 'money'],
|
||||
'refund_amount' => ['refund_amount', '退款金额', 'money'],
|
||||
'planting_amount' => ['planting_amount', '种菜金额', 'money'],
|
||||
'planting_label' => ['planting_label', '标签', 'text'],
|
||||
@ -2091,6 +2133,7 @@ class Pages extends Controller
|
||||
];
|
||||
|
||||
[, $month] = $this->receiptOrderMatchMonths();
|
||||
$defaultCompensationAmounts = self::localReceiptDefaultCompensationAmounts($month);
|
||||
|
||||
$query = DB::connection($connection)->table('store_receipt_order_match_rows')
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
|
||||
@ -2158,7 +2201,7 @@ class Pages extends Controller
|
||||
|
||||
$filename = '店铺收款匹配订单明细_' . ($month ?? 'all') . '.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($query, $selected, $map): void {
|
||||
return response()->streamDownload(function () use ($query, $selected, $map, $defaultCompensationAmounts): void {
|
||||
// 单表几十万行:用游标一次查询顺序吐出行,避免 chunk 的 OFFSET 反复重跑
|
||||
// (29 万行时会退化成几百次排序查询,导出要几分钟)。
|
||||
@set_time_limit(0);
|
||||
@ -2171,7 +2214,9 @@ class Pages extends Controller
|
||||
$line = [];
|
||||
foreach ($selected as $key) {
|
||||
$format = $map[$key][2];
|
||||
$value = $row->{$map[$key][0]} ?? '';
|
||||
$value = $key === 'default_compensation_amount'
|
||||
? ($defaultCompensationAmounts[(string) ($row->row_key ?? '')] ?? '')
|
||||
: ($row->{$map[$key][0]} ?? '');
|
||||
// 「差额异常备注」列与页面一致:只写差额产生的原因
|
||||
if ($key === 'remark') {
|
||||
$value = self::receiptDifferenceRemark($row) ?? '';
|
||||
@ -2354,6 +2399,7 @@ class Pages extends Controller
|
||||
* 收入 = 业绩明细里(收入金额 + 优惠金额 + 冲减金额 + 退款金额)按各字段记账符号求和
|
||||
* (冲减、退款取数本身带负号,直接相加即为净收入);
|
||||
* 成本 = 商品成本表的商品成本汇总;运费暂不取数,列上留空。
|
||||
* 费用 = 店铺收款账户账单中财务类型为「费用」的支出减收入,按票据口径拆为开票 / 不开票。
|
||||
*/
|
||||
protected function contentForFinancialAnalysis(): array
|
||||
{
|
||||
@ -2397,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;
|
||||
}
|
||||
@ -2422,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,
|
||||
@ -2435,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
|
||||
{
|
||||
@ -2490,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 = [];
|
||||
@ -2515,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')
|
||||
@ -2530,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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -2538,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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务经营分析表导出:按当前筛选条件导出「时间 / 平台 / 店铺 / 收入 / 成本 / 运费」。
|
||||
*/
|
||||
@ -2563,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'],
|
||||
@ -2573,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);
|
||||
|
||||
@ -832,6 +832,7 @@ $navigation = [
|
||||
['name' => 'income_amount', 'label' => '收入金额', 'kind' => 'number'],
|
||||
['name' => 'discount_amount', 'label' => '优惠金额', 'kind' => 'number'],
|
||||
['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'],
|
||||
['name' => 'default_compensation_amount', 'label' => '违约赔付(冲减收入)', 'kind' => 'number'],
|
||||
['name' => 'refund_amount', 'label' => '退款金额', 'kind' => 'number'],
|
||||
['name' => 'planting_amount', 'label' => '种菜金额', 'kind' => 'number'],
|
||||
['name' => 'planting_label', 'label' => '标签'],
|
||||
|
||||
@ -466,7 +466,9 @@ class AccountLedgerRemarkMap
|
||||
|
||||
$index = [];
|
||||
try {
|
||||
foreach (app(AccountMappingStore::class)->all() as $rule) {
|
||||
$rules = $this->accountMappingOverrideRules()
|
||||
?? app(AccountMappingStore::class)->all();
|
||||
foreach ($rules as $rule) {
|
||||
if (! is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
@ -485,6 +487,27 @@ class AccountLedgerRemarkMap
|
||||
return $this->accountMappingIndex = $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地模拟映射覆盖:仅在显式设置 ACCOUNT_MAPPING_OVERRIDE_FILE 时启用。
|
||||
* 正常页面和定时任务不设置该变量,继续读取共享系统的科目映射表。
|
||||
*
|
||||
* @return list<array<string,mixed>>|null
|
||||
*/
|
||||
private function accountMappingOverrideRules(): ?array
|
||||
{
|
||||
$path = trim((string) (getenv('ACCOUNT_MAPPING_OVERRIDE_FILE') ?: ''));
|
||||
if ($path === '' || ! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
if (! is_array($decoded) || ! array_is_list($decoded)) {
|
||||
throw new \RuntimeException('本地科目映射覆盖文件格式无效:' . $path);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/** 同平台同账单项目的「财务备注」;没有规则或财务备注为空时返回 null(由调用方回落)。 */
|
||||
private function accountMappingRemark(string $platform, string $item, array $businessRemarks, string $goodsName): ?string
|
||||
{
|
||||
|
||||
@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 本地预览用:直接从最新科目映射表和账单镜像生成违约赔付金额快照。
|
||||
*
|
||||
* 不写共享数据库,只输出 storage/app/local 下的 JSON。正式接入系统前用于核对
|
||||
* 「种菜金额」后面的「违约赔付(冲减收入)」列。
|
||||
*/
|
||||
class StoreReceiptDefaultCompensationSnapshotBuilder
|
||||
{
|
||||
public const REMARK = '违约赔付(冲减收入)';
|
||||
|
||||
private const SOURCES = [
|
||||
'taobao-alipay' => [
|
||||
'label' => '淘系支付宝', 'table' => 'taobao_alipay_bill_rows',
|
||||
'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount',
|
||||
'real_store' => 'online_real_store',
|
||||
],
|
||||
'taobao-wechat' => [
|
||||
'label' => '淘系微信', 'table' => 'taobao_wechat_bill_rows',
|
||||
'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount',
|
||||
],
|
||||
'pinduoduo' => [
|
||||
'label' => '拼多多', 'table' => 'pdd_settlement_bill_rows',
|
||||
'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount',
|
||||
],
|
||||
'dewu' => [
|
||||
'label' => '得物', 'table' => 'dewu_settlement_bill_rows',
|
||||
'order' => 'online_order_no', 'time' => 'bill_date', 'amount' => 'income_amount',
|
||||
'real_store' => 'online_real_store',
|
||||
],
|
||||
'jd-transactions' => [
|
||||
'label' => '京东账单', 'table' => 'jd_daily_bill_rows',
|
||||
'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income',
|
||||
'store_exclude' => '京喜', 'periods' => 'jd_daily_bill_periods',
|
||||
],
|
||||
'jingxi' => [
|
||||
'label' => '京喜账单', 'table' => 'jd_daily_bill_rows',
|
||||
'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income',
|
||||
'store_include' => '京喜', 'periods' => 'jd_daily_bill_periods',
|
||||
],
|
||||
'douyin' => [
|
||||
'label' => '抖音', 'table' => 'douyin_authoritative_bills',
|
||||
'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income',
|
||||
'periods' => 'douyin_authoritative_periods',
|
||||
],
|
||||
];
|
||||
|
||||
/** 页签内多平台按店铺名前缀拆分;单平台页签无需拆分。 */
|
||||
private const TAB_PLATFORM_GROUPS = [
|
||||
'taobao-alipay' => [
|
||||
['platform' => '淘宝', 'prefixes' => ['淘宝'], 'include_default' => true],
|
||||
['platform' => '天猫', 'prefixes' => ['天猫']],
|
||||
['platform' => '阿里巴巴', 'prefixes' => ['阿里巴巴', '1688', '新佰广']],
|
||||
],
|
||||
'taobao-wechat' => [
|
||||
['platform' => '淘宝', 'prefixes' => ['淘宝'], 'include_default' => true],
|
||||
['platform' => '天猫', 'prefixes' => ['天猫']],
|
||||
],
|
||||
'pinduoduo' => [['platform' => '拼多多']],
|
||||
'dewu' => [['platform' => '得物']],
|
||||
'jd-transactions' => [['platform' => '京东']],
|
||||
'jingxi' => [['platform' => '京喜']],
|
||||
'douyin' => [['platform' => '抖音']],
|
||||
];
|
||||
|
||||
private const STORE_PREFIXES = [
|
||||
'唯品会JX', '唯品会MP', '阿里巴巴', '1688', '新佰广',
|
||||
'拼多多', '天猫', '淘宝', '抖音', '得物', '京喜', '京东',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* month:string,remark:string,generated_at:string,mapping_sha256:string,mapping_rows:int,
|
||||
* source_stats:array<string,array{rows:int,amount:float,groups:int}>,
|
||||
* amounts:array<string,float>,total:float
|
||||
* }
|
||||
*/
|
||||
public function build(string $month, string $mappingPath): array
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
|
||||
throw new RuntimeException('月份格式必须是 YYYY-MM。');
|
||||
}
|
||||
if (! is_file($mappingPath)) {
|
||||
throw new RuntimeException('科目映射表不存在:' . $mappingPath);
|
||||
}
|
||||
|
||||
$start = CarbonImmutable::parse($month . '-01')->startOfMonth();
|
||||
$end = $start->addMonth();
|
||||
$reportMonth = $start->toDateString();
|
||||
$itemsByPlatform = $this->readMappingItems($mappingPath);
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$amounts = [];
|
||||
$sourceStats = [];
|
||||
|
||||
foreach (self::SOURCES as $tab => $source) {
|
||||
$groups = $this->groupsForSource($tab, $itemsByPlatform);
|
||||
if ($groups === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$table = (string) $source['table'];
|
||||
$orderColumn = (string) $source['order'];
|
||||
$timeColumn = (string) $source['time'];
|
||||
$amountColumn = (string) $source['amount'];
|
||||
$realStoreColumn = (string) ($source['real_store'] ?? '');
|
||||
$expenseColumn = '';
|
||||
if (Schema::connection($connection)->hasColumn($table, 'expense_amount')) {
|
||||
$expenseColumn = 'expense_amount';
|
||||
} elseif (Schema::connection($connection)->hasColumn($table, 'expense')) {
|
||||
$expenseColumn = 'expense';
|
||||
}
|
||||
|
||||
$select = array_values(array_unique(array_filter([
|
||||
'store_name', 'bill_item', $orderColumn, $timeColumn, $amountColumn,
|
||||
$expenseColumn, $realStoreColumn,
|
||||
])));
|
||||
|
||||
$query = DB::connection($connection)->table($table)
|
||||
->select($select)
|
||||
->where($timeColumn, '>=', $start->toDateTimeString())
|
||||
->where($timeColumn, '<', $end->toDateTimeString());
|
||||
|
||||
if (isset($source['store_include'])) {
|
||||
$query->where('store_name', 'like', '%' . $source['store_include'] . '%');
|
||||
}
|
||||
if (isset($source['store_exclude'])) {
|
||||
$query->where('store_name', 'not like', '%' . $source['store_exclude'] . '%');
|
||||
}
|
||||
|
||||
$periodsTable = (string) ($source['periods'] ?? '');
|
||||
if ($periodsTable !== '') {
|
||||
$generations = DB::connection($connection)->table($periodsTable)
|
||||
->where('period_start', '<', $end->toDateString())
|
||||
->where('period_end', '>', $start->toDateString())
|
||||
->pluck('active_generation')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
if ($generations === []) {
|
||||
continue;
|
||||
}
|
||||
$query->whereIn('generation', $generations);
|
||||
}
|
||||
|
||||
$query->where(function ($outer) use ($groups): void {
|
||||
foreach ($groups as $group) {
|
||||
$outer->orWhere(function ($sub) use ($group): void {
|
||||
if ($group['prefixes'] !== []) {
|
||||
$sub->where(function ($scope) use ($group): void {
|
||||
$scope->where(function ($own) use ($group): void {
|
||||
foreach ($group['prefixes'] as $prefix) {
|
||||
$own->orWhere('store_name', 'like', $prefix . '%');
|
||||
}
|
||||
});
|
||||
if ($group['include_default']) {
|
||||
$scope->orWhere(function ($other): void {
|
||||
foreach (self::STORE_PREFIXES as $prefix) {
|
||||
$other->where('store_name', 'not like', $prefix . '%');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
$sub->whereIn('bill_item', $group['items']);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$rows = 0;
|
||||
$amountTotal = 0.0;
|
||||
foreach ($query->orderBy($timeColumn)->cursor() as $row) {
|
||||
$rows++;
|
||||
$store = $realStoreColumn !== ''
|
||||
? trim((string) ($row->{$realStoreColumn} ?? ''))
|
||||
: '';
|
||||
if ($store === '') {
|
||||
$store = trim((string) ($row->store_name ?? ''));
|
||||
}
|
||||
$orderNo = trim((string) ($row->{$orderColumn} ?? ''));
|
||||
$amount = (float) ($row->{$amountColumn} ?? 0)
|
||||
- ($expenseColumn !== '' ? (float) ($row->{$expenseColumn} ?? 0) : 0.0);
|
||||
$key = $this->rowKey(
|
||||
$reportMonth,
|
||||
(string) $source['label'],
|
||||
$store,
|
||||
$orderNo,
|
||||
(string) ($row->bill_item ?? ''),
|
||||
);
|
||||
$amounts[$key] = round(($amounts[$key] ?? 0.0) + $amount, 2);
|
||||
$amountTotal += $amount;
|
||||
}
|
||||
|
||||
$sourceStats[(string) $source['label']] = [
|
||||
'rows' => $rows,
|
||||
'amount' => round($amountTotal, 2),
|
||||
'groups' => count($groups),
|
||||
];
|
||||
}
|
||||
|
||||
ksort($amounts);
|
||||
|
||||
return [
|
||||
'month' => $month,
|
||||
'remark' => self::REMARK,
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'mapping_sha256' => hash_file('sha256', $mappingPath),
|
||||
'mapping_rows' => array_sum(array_map('count', $itemsByPlatform)),
|
||||
'source_stats' => $sourceStats,
|
||||
'amounts' => $amounts,
|
||||
'total' => round(array_sum($amounts), 2),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,list<string>> */
|
||||
private function readMappingItems(string $mappingPath): array
|
||||
{
|
||||
$sheet = IOFactory::load($mappingPath)->getActiveSheet();
|
||||
$rows = $sheet->toArray(null, true, true, false);
|
||||
if ($rows === []) {
|
||||
throw new RuntimeException('科目映射表没有数据。');
|
||||
}
|
||||
|
||||
$headers = array_map(static fn ($value): string => trim((string) $value), array_shift($rows));
|
||||
$platformIndex = array_search('平台', $headers, true);
|
||||
$itemIndex = array_search('账单项目', $headers, true);
|
||||
$remarkIndex = array_search('财务备注', $headers, true);
|
||||
if ($platformIndex === false || $itemIndex === false || $remarkIndex === false) {
|
||||
throw new RuntimeException('科目映射表缺少「平台」「账单项目」或「财务备注」列。');
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($rows as $row) {
|
||||
if (trim((string) ($row[$remarkIndex] ?? '')) !== self::REMARK) {
|
||||
continue;
|
||||
}
|
||||
$platform = trim((string) ($row[$platformIndex] ?? ''));
|
||||
$item = trim((string) ($row[$itemIndex] ?? ''));
|
||||
if ($platform !== '' && $item !== '') {
|
||||
$items[$platform][$item] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($items as $platform => $values) {
|
||||
$result[$platform] = array_keys($values);
|
||||
sort($result[$platform], SORT_NATURAL | SORT_FLAG_CASE);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,list<string>> $itemsByPlatform
|
||||
* @return list<array{prefixes:list<string>,include_default:bool,items:list<string>}>
|
||||
*/
|
||||
private function groupsForSource(string $tab, array $itemsByPlatform): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach (self::TAB_PLATFORM_GROUPS[$tab] ?? [] as $definition) {
|
||||
$platform = (string) $definition['platform'];
|
||||
$items = $itemsByPlatform[$platform] ?? [];
|
||||
if ($items === []) {
|
||||
continue;
|
||||
}
|
||||
if ($tab === 'taobao-wechat') {
|
||||
$items = $this->withWxVariants($items);
|
||||
}
|
||||
$groups[] = [
|
||||
'prefixes' => array_values((array) ($definition['prefixes'] ?? [])),
|
||||
'include_default' => (bool) ($definition['include_default'] ?? false),
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/** @param list<string> $items @return list<string> */
|
||||
private function withWxVariants(array $items): array
|
||||
{
|
||||
$variants = [];
|
||||
foreach ($items as $item) {
|
||||
$variants[$item] = true;
|
||||
if (stripos($item, 'wx') === 0) {
|
||||
$without = ltrim(mb_substr($item, 2));
|
||||
if ($without !== '') {
|
||||
$variants[$without] = true;
|
||||
}
|
||||
} else {
|
||||
$variants['wx' . $item] = true;
|
||||
}
|
||||
}
|
||||
$list = array_keys($variants);
|
||||
sort($list, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
private function rowKey(string $month, string $platform, string $storeName, string $orderNo, string $billItem): string
|
||||
{
|
||||
$shopKey = $this->normalizeShop($storeName);
|
||||
$identity = $this->orderIdentity($orderNo);
|
||||
if ($identity === '') {
|
||||
return md5($month . '|' . $platform . '|' . $shopKey . '|promo|' . $billItem);
|
||||
}
|
||||
|
||||
return md5($month . '|' . $platform . '|' . $shopKey . '|' . $identity);
|
||||
}
|
||||
|
||||
private function normalizeShop(string $value): string
|
||||
{
|
||||
$value = str_replace(["\u{3000}", '_'], [' ', '-'], trim($value));
|
||||
$value = preg_replace('/\s+/u', '', $value) ?? '';
|
||||
|
||||
return strtoupper($value);
|
||||
}
|
||||
|
||||
private function orderIdentity(string $value): string
|
||||
{
|
||||
$value = str_replace("\u{3000}", ' ', trim($value));
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
$value = preg_replace('/[\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', '-', $value) ?? $value;
|
||||
$value = preg_replace('/\s+/u', '', $value) ?? '';
|
||||
|
||||
return strtoupper($value);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -100,6 +100,7 @@ class NavigationTest extends FeatureTestCase
|
||||
->assertSee('data-column-key="offset_amount"', false)
|
||||
->assertSee('data-column-key="planting_amount"', false)
|
||||
->assertSee('data-column-key="planting_label"', false)
|
||||
->assertSee('data-column-key="default_compensation_amount"', false)
|
||||
->assertSee('data-column-key="product_amount"', false)
|
||||
->assertSee('data-column-key="difference"', false)
|
||||
->assertSee('data-column-key="match_status"', false)
|
||||
@ -122,6 +123,26 @@ class NavigationTest extends FeatureTestCase
|
||||
->assertSeeText('店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。');
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
$csv = $this->loginAs()
|
||||
->get(route('business.financial-operations.financial-analysis.export'))
|
||||
->streamedContent();
|
||||
|
||||
$this->assertStringContainsString('时间,平台,店铺,收入,成本,运费,开票费用,不开票费用', $csv);
|
||||
}
|
||||
|
||||
public function testFinancialRiskControlPageIsAvailableBetweenFinancialReports(): void
|
||||
{
|
||||
$navigation = BusinessNavigation::all();
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use App\Console\Commands\SyncStoreReceiptOrderMatches;
|
||||
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
|
||||
use ReflectionClass;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
class StoreReceiptMappingPreviewOverrideTest extends TestCase
|
||||
{
|
||||
public function test_default_compensation_is_included_in_offset_remarks(): void
|
||||
{
|
||||
$reflection = new ReflectionClass(SyncStoreReceiptOrderMatches::class);
|
||||
$remarks = $reflection->getConstant('OFFSET_REMARKS');
|
||||
|
||||
$this->assertContains('冲减收入', $remarks);
|
||||
$this->assertContains('违约赔付(冲减收入)', $remarks);
|
||||
}
|
||||
|
||||
public function test_local_mapping_override_file_is_used_when_configured(): void
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'caiwu-mapping-override-');
|
||||
$this->assertNotFalse($path);
|
||||
file_put_contents($path, json_encode([
|
||||
['platform' => '京东', 'item' => '结算单', 'memo' => '订单收入'],
|
||||
['platform' => '京东', 'item' => '售后卖家赔付费', 'memo' => '违约赔付(冲减收入)'],
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$previous = getenv('ACCOUNT_MAPPING_OVERRIDE_FILE');
|
||||
putenv('ACCOUNT_MAPPING_OVERRIDE_FILE=' . $path);
|
||||
|
||||
try {
|
||||
$method = new ReflectionMethod(AccountLedgerRemarkMap::class, 'accountMappingOverrideRules');
|
||||
$rules = $method->invoke(new AccountLedgerRemarkMap());
|
||||
|
||||
$this->assertCount(2, $rules);
|
||||
$this->assertSame('违约赔付(冲减收入)', $rules[1]['memo']);
|
||||
} finally {
|
||||
putenv($previous === false
|
||||
? 'ACCOUNT_MAPPING_OVERRIDE_FILE'
|
||||
: 'ACCOUNT_MAPPING_OVERRIDE_FILE=' . $previous);
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user