业绩明细:增加跨月对平备注并拆分费用明细 #504

Merged
yueyang merged 2 commits from feature/yueyang-cross-month-expense-details into main 2026-10-24 17:27:30 +08:00
14 changed files with 446 additions and 18 deletions

View File

@ -210,13 +210,96 @@ class SyncStoreReceiptOrderMatches extends Command
}
try {
$result = $this->syncRows();
if ($result === self::SUCCESS && ! (bool) $this->option('planting-only')) {
$this->refreshCrossMonthBalances(
trim((string) $this->argument('month')),
(string) $this->option('platform'),
);
}
} finally {
$lock->release();
}
if ($result === self::SUCCESS && ! (bool) $this->option('planting-only')) $this->refreshStoreReportPaidSales();
if ($result === self::SUCCESS && ! (bool) $this->option('planting-only')) {
$this->refreshStoreReportPaidSales();
}
return $result;
}
/**
* 同一平台、店铺、交易单号在相邻月份的收入/优惠/冲减/违约赔付合并后,
* 与订单实付(各月重复为同一订单级金额,取最大值)在 2 分钱内对平,
* 才把涉及的月份标记为“跨月对平”。未对平不标记。
*/
private function refreshCrossMonthBalances(string $month, string $platform): void
{
$source = self::SOURCES[$platform] ?? null;
if ($source === null || in_array($platform, self::EXCLUDED_TARGET_SOURCES, true)) {
return;
}
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
if (! Schema::connection($connection)->hasColumn(self::TARGET_TABLE, 'cross_month_balanced')) {
return;
}
$start = Carbon::createFromFormat('Y-m-d', $month . '-01')->startOfMonth();
$label = (string) $source['label'];
$table = DB::connection($connection)->getQueryGrammar()->wrapTable(self::TARGET_TABLE);
$net = 'COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(default_compensation_amount, 0)';
$affected = 0;
// 分别重算上个月、本月、下个月。每行只根据自身前后各一个月判断,
// 后续同步新月份时也能同步修正相邻月份,不会因只重置整段范围留下旧标记。
foreach ([
$start->copy()->subMonthNoOverflow(),
$start->copy(),
$start->copy()->addMonthNoOverflow(),
] as $targetMonth) {
$targetDate = $targetMonth->toDateString();
$rangeStart = $targetMonth->copy()->subMonthNoOverflow()->toDateString();
$rangeEnd = $targetMonth->copy()->addMonthsNoOverflow(2)->toDateString();
$affected += DB::connection($connection)->transaction(
function () use ($connection, $table, $net, $label, $targetDate, $rangeStart, $rangeEnd): int {
DB::connection($connection)->update(
"UPDATE {$table}
SET cross_month_balanced = 0
WHERE platform = ? AND report_month = ?",
[$label, $targetDate],
);
return DB::connection($connection)->update(
"UPDATE {$table} AS target
JOIN (
SELECT platform, store_name, online_order_no
FROM {$table}
WHERE platform = ? AND report_month >= ? AND report_month < ?
AND COALESCE(store_name, '') <> ''
AND COALESCE(online_order_no, '') <> ''
GROUP BY platform, store_name, online_order_no
HAVING COUNT(DISTINCT report_month) >= 2
AND SUM(ABS(COALESCE(income_amount, 0))
+ ABS(COALESCE(discount_amount, 0))
+ ABS(COALESCE(offset_amount, 0))
+ ABS(COALESCE(default_compensation_amount, 0))) > 0.005
AND MAX(COALESCE(paid_amount, 0)) > 0.005
AND ABS(SUM({$net}) - MAX(COALESCE(paid_amount, 0))) <= 0.02
) AS balanced
ON balanced.platform = target.platform
AND balanced.store_name = target.store_name
AND balanced.online_order_no = target.online_order_no
SET target.cross_month_balanced = 1
WHERE target.platform = ? AND target.report_month = ?",
[$label, $rangeStart, $rangeEnd, $label, $targetDate],
);
}
);
}
$this->line(sprintf(' 跨月合并对平:重算 %s 前后月,标记 %d 行', $start->format('Y-m'), $affected));
}
private function syncRows(): int
{
// 账单逐行读取,但全月订单聚合与匹配索引仍可能超过 1G(拼多多 / 淘系支付宝),

View File

@ -339,6 +339,9 @@ class Pages extends Controller
$rules = [];
$adjustment = self::receiptAdjustmentSql();
$effective = 'COALESCE(difference, ' . self::receiptNetSql() . ')';
// 同一交易单号在相邻月份的收入/优惠/冲减合并后与订单实付对平时,
// 这是最能解释单月差额的原因,必须排在其它逐月规则之前。
$rules['差额=跨月收款(合并已对平)'] = 'cross_month_balanced = 1';
$rules[$label . '未匹配'] = "match_status = '未匹配'";
$rules[$label . '取数为0(种菜)'] = "match_status = '已匹配' AND planting_amount IS NOT NULL AND ABS(COALESCE($column, 0)) < 0.005";
$rules[$label . '取数为0'] = "match_status = '已匹配' AND planting_amount IS NULL AND ABS(COALESCE($column, 0)) < 0.005";
@ -654,6 +657,10 @@ class Pages extends Controller
return null;
}
if ((int) ($row->cross_month_balanced ?? 0) === 1) {
return '差额=跨月收款(合并已对平)';
}
if (($row->match_status ?? '') === '未匹配') {
// 细分原因放到「未匹配原因」列,这里只说明差额是怎么来的。
return $label . '未匹配';
@ -1058,8 +1065,22 @@ class Pages extends Controller
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'billing-details'), 403, '当前账号没有该页面权限。');
return $this->streamBillingDetailsExport($request, [], '账单明细');
}
/** 费用明细导出:财务类型固定为「费用」。 */
public function exportExpenseDetails(Request $request): StreamedResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'expense-details'), 403, '当前账号没有该页面权限。');
return $this->streamBillingDetailsExport($request, ['type' => ['费用']], '费用明细');
}
private function streamBillingDetailsExport(Request $request, array $forcedFilters, string $filenamePrefix): StreamedResponse
{
$pageData = app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class);
$filters = $pageData->exportFilters($request->all());
$filters = $pageData->exportFilters($request->all(), $forcedFilters);
$columns = $pageData->exportColumns(array_values(array_filter(
(array) $request->get('columns', []),
static fn ($key): bool => is_string($key) && $key !== '',
@ -1117,7 +1138,7 @@ class Pages extends Controller
fclose($csv);
$putProgress(['status' => 'done', 'rows' => $written, 'total' => max($total, $written)]);
}, '账单明细_' . $filters['start'] . '_' . $filters['end'] . '.csv', [
}, $filenamePrefix . '_' . $filters['start'] . '_' . $filters['end'] . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
@ -1136,6 +1157,21 @@ class Pages extends Controller
]);
}
/** 费用明细筛选联动:财务类型固定为「费用」。 */
public function expenseDetailsFilterOptions(): \Illuminate\Http\JsonResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'expense-details'), 403, '当前账号没有该页面权限。');
$data = app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class);
return response()->json([
'facets' => $data->facets(request()->all(), ['type' => ['费用']]),
], 200, [
'Cache-Control' => 'private, no-store',
]);
}
/** 账单明细导出进度(前端每秒轮询一次) */
public function billingDetailsExportProgress(Request $request): \Illuminate\Http\JsonResponse
{
@ -4002,6 +4038,7 @@ class Pages extends Controller
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
'account-mapping' => $this->contentForAccountMapping(),
'ledger' => app(\Modules\EcommerceFinance\View\FinanceLedgerPageData::class)->page($tab),
'expense-details' => app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class)->expensePage(),
'billing-details' => app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class)->page(),
'store-violation-alert' => $this->contentForStoreViolationAlerts(),
'preview-statistics' => $this->contentForPreviewStatistics(),

View File

@ -107,6 +107,15 @@ class SidebarPermissions
return $navigation;
}
// 费用明细承接原「广告费用 / 营销费用」入口,并复用账单明细的数据权限。
// 已保存过精确侧边栏权限的账号不会自动获得新页面键,因此按旧权限兼容显示。
$inheritsExpenseDetails = array_intersect([
'financial-operations/performance-details',
'financial-operations/billing-details',
'financial-operations/advertising-expenses',
'financial-operations/marketing-expenses',
], $allowed) !== [];
$navigation = array_values(array_filter(array_map(function (array $section) use ($allowed) {
$sectionKey = $section['slug'];
@ -150,6 +159,50 @@ class SidebarPermissions
return null;
}, $navigation)));
if ($inheritsExpenseDetails) {
$configuredFinance = collect(BusinessNavigation::all())
->firstWhere('slug', 'financial-operations');
$expensePage = collect($configuredFinance['pages'] ?? [])
->firstWhere('slug', 'expense-details');
$financeSectionFound = false;
foreach ($navigation as &$financeSection) {
if (($financeSection['slug'] ?? null) !== 'financial-operations') {
continue;
}
$financeSectionFound = true;
if (! $expensePage
|| collect($financeSection['pages'] ?? [])->contains('slug', 'expense-details')) {
continue;
}
$pageSlugs = array_column($financeSection['pages'] ?? [], 'slug');
$performanceIndex = array_search('performance-details', $pageSlugs, true);
$billingIndex = array_search('billing-details', $pageSlugs, true);
$insertAt = is_int($performanceIndex)
? $performanceIndex + 1
: (is_int($billingIndex) ? $billingIndex : 0);
array_splice($financeSection['pages'], $insertAt, 0, [$expensePage]);
}
unset($financeSection);
// 只被授予旧费用入口的账号仍应保留这个替代页面。
if (! $financeSectionFound && $expensePage) {
$fallbackSection = $configuredFinance;
$fallbackSection['pages'] = [$expensePage];
$riskIndex = collect($navigation)->search(
static fn (array $section): bool => ($section['slug'] ?? null) === 'financial-risk-control'
);
array_splice(
$navigation,
is_int($riskIndex) ? $riskIndex : count($navigation),
0,
[$fallbackSection],
);
}
}
// 新增的公司成本安排、税金预测表与主体经营报表共用公司经营报表权限。
// 对已经保存过精确权限的账号兼容旧配置,避免新增页面被旧权限集合隐藏。
foreach ($navigation as &$section) {

View File

@ -822,7 +822,7 @@ $navigation = [
'directory' => [
'icon' => 'receipt_long',
'status' => '共享镜像',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」「违约赔付(冲减收入)」分别取对应总账备注的账单净额,不计入收入金额;「退款金额」只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额,只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额+违约赔付(冲减收入))−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的抵减项会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定;具体规则均未命中且相关金额在 2 分钱内精确对平时,退款及其与抵减项的组合复用「订单实付含已退款部分」,优惠、冲减与违约赔付组合复用「订单实付未减优惠和冲减」;没有可复用的原备注时继续标「差额待核实」。都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」「违约赔付(冲减收入)」分别取对应总账备注的账单净额,不计入收入金额;「退款金额」只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额,只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额+违约赔付(冲减收入))−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的抵减项会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定;同一平台、店铺、交易单号在相邻月份合并收入/优惠/冲减/违约赔付后与订单实付对平的,优先标「差额=跨月收款(合并已对平)」,未对平仍标「差额待核实」;其余规则均未命中且相关金额在 2 分钱内精确对平时,退款及其与抵减项的组合复用「订单实付含已退款部分」,优惠、冲减与违约赔付组合复用「订单实付未减优惠和冲减」;没有可复用的原备注时继续标「差额待核实」。都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。',
'fields' => [
['name' => 'platform', 'label' => '平台'],
@ -844,6 +844,7 @@ $navigation = [
],
],
],
['slug' => 'expense-details', 'title' => '费用明细'],
['slug' => 'billing-details', 'title' => '账单明细'],
['slug' => 'product-costs', 'title' => '商品成本'],
[
@ -871,8 +872,6 @@ $navigation = [
],
['slug' => 'installation-details', 'title' => '安装明细'],
['slug' => 'promotion-expenses', 'title' => '推广费用'],
['slug' => 'advertising-expenses', 'title' => '广告费用'],
['slug' => 'marketing-expenses', 'title' => '营销费用'],
['slug' => 'after-sales-expenses', 'title' => '售后费用'],
['slug' => 'small-payment-work-orders', 'title' => '小额打款'],
['slug' => 'management-expenses', 'title' => '管理费用'],

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$schema = Schema::connection($connection);
if (! $schema->hasTable('store_receipt_order_match_rows')) {
return;
}
if (! $schema->hasColumn('store_receipt_order_match_rows', 'cross_month_balanced')) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->boolean('cross_month_balanced')->default(false)->after('difference');
});
}
$indexName = 'store_receipt_cross_month_lookup_index';
$hasIndex = collect($schema->getIndexes('store_receipt_order_match_rows'))
->contains(static fn (array $index): bool => ($index['name'] ?? null) === $indexName);
if (! $hasIndex) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table) use ($indexName): void {
$table->index(
['platform', 'report_month', 'store_name', 'online_order_no'],
$indexName,
);
});
}
}
public function down(): void
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$schema = Schema::connection($connection);
if (! $schema->hasTable('store_receipt_order_match_rows')) {
return;
}
$indexName = 'store_receipt_cross_month_lookup_index';
$hasIndex = collect($schema->getIndexes('store_receipt_order_match_rows'))
->contains(static fn (array $index): bool => ($index['name'] ?? null) === $indexName);
if ($hasIndex) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table) use ($indexName): void {
$table->dropIndex($indexName);
});
}
if ($schema->hasColumn('store_receipt_order_match_rows', 'cross_month_balanced')) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('cross_month_balanced');
});
}
}
};

View File

@ -22,6 +22,8 @@ use Throwable;
*/
class BillingDetailsPageData
{
private const EXPENSE_FORCED_FILTERS = ['type' => ['费用']];
/**
* 各平台账单表;periods 非空表示该表按 active_generation 区分版本,必须用 periods 表锁定当前版本。
*/
@ -76,6 +78,18 @@ class BillingDetailsPageData
] + $this->data();
}
/** 费用明细:复用账单明细,但服务端固定财务类型为「费用」。 */
public function expensePage(): array
{
return [
'view' => 'business.pages.financial-operations.billing-details',
'options' => $this->options(),
'columns' => self::COLUMNS,
'pageTitle' => '费用明细',
'expenseOnly' => true,
] + $this->data(null, self::EXPENSE_FORCED_FILTERS);
}
/**
* 页面与导出的列定义(顺序即默认显示顺序):key => [标签, 类型]。
*/
@ -97,9 +111,9 @@ class BillingDetailsPageData
];
/** 导出用:按请求解析筛选条件(与页面同一套口径) */
public function exportFilters(array $input): array
public function exportFilters(array $input, array $forcedFilters = []): array
{
return $this->parseFilters($input);
return array_replace($this->parseFilters($input), $forcedFilters);
}
/** 导出用:当前筛选下的总行数(进度百分比用) */
@ -258,10 +272,10 @@ class BillingDetailsPageData
}
/** @return array<string,mixed> */
public function data(?array $input = null): array
public function data(?array $input = null, array $forcedFilters = []): array
{
$input ??= request()->all();
$filters = $this->parseFilters($input);
$filters = array_replace($this->parseFilters($input), $forcedFilters);
$rows = [];
$total = 0;
@ -533,10 +547,10 @@ class BillingDetailsPageData
*
* @return array<string,array{values:list<string>,counts:array<string,int>}>
*/
public function facets(?array $input = null): array
public function facets(?array $input = null, array $forcedFilters = []): array
{
$input ??= request()->all();
$filters = $this->parseFilters($input);
$filters = array_replace($this->parseFilters($input), $forcedFilters);
$signature = md5((string) json_encode($filters, JSON_UNESCAPED_UNICODE));
try {

View File

@ -1,4 +1,17 @@
<div class="upstream-data-page billing-details-page" style="padding:0;">
@php
$pageTitle = $pageTitle ?? '账单明细';
$expenseOnly = (bool) ($expenseOnly ?? false);
$filterOptionsRoute = $expenseOnly
? route('business.financial-operations.expense-details.filter-options')
: route('business.financial-operations.billing-details.filter-options');
$exportRoute = $expenseOnly
? route('business.financial-operations.expense-details.export')
: route('business.financial-operations.billing-details.export');
$exportProgressRoute = $expenseOnly
? route('business.financial-operations.expense-details.export-progress')
: route('business.financial-operations.billing-details.export-progress');
@endphp
<div class="upstream-data-page billing-details-page {{ $expenseOnly ? 'expense-details-page' : '' }}" style="padding:0;">
@include('business.pages.partials.upstream-data-styles')
@include('business.pages.partials.multi-select')
@include('business.pages.partials.date-range-picker')
@ -84,9 +97,14 @@
@media (max-width:520px) { .pd-stats { grid-template-columns:1fr; } }
</style>
<h2 style="margin:.25rem 0 .5rem;font-size:1.05rem;">账单明细</h2>
<h2 style="margin:.25rem 0 .5rem;font-size:1.05rem;">{{ $pageTitle }}</h2>
<div style="color:#595959;font-size:.8125rem;margin-bottom:.75rem;">
数据源=各平台已导入账单(与「店铺收款账户账单」同源);<b>财务备注</b>、<b>财务类型</b>由科目映射表按「平台+账单项目(wx 自动去前缀)」带出,匹配不到的留空并标红。
@if ($expenseOnly)
本页只展示科目映射表中<b>财务类型=费用</b>的账单。
@else
同一交易单号跨月合并后已与订单实付对平的,差额备注优先显示为<b>差额=跨月收款(合并已对平)</b>;未对平仍显示<b>差额待核实</b>。
@endif
账单项目不再单独筛选,直接在<b>关键词</b>里输入即可(关键词同时匹配记录编码 / 订单号 / 账单项目 / 商户订单号 / 备注)。
</div>
@ -143,6 +161,7 @@
<span class="cw-ms__values" data-role="values"></span>
</span>
</div>
@if (! $expenseOnly)
<div class="upstream-data-field cw-ms pd-f-type" id="pdType" data-name="type"
data-initial="{{ json_encode(array_values($filters['type']), JSON_UNESCAPED_UNICODE) }}">
<span>财务类型</span>
@ -154,6 +173,7 @@
<span class="cw-ms__values" data-role="values"></span>
</span>
</div>
@endif
<div class="upstream-data-field cw-ms pd-f-remark" id="pdRemark" data-name="remark"
data-initial="{{ json_encode(array_values($filters['remark'] ?? []), JSON_UNESCAPED_UNICODE) }}">
<span>财务备注</span>
@ -199,7 +219,7 @@
<div style="padding:.75rem 1rem;border:1px solid #edcccc;border-radius:.75rem;background:#fdf3f3;color:#a32d2d;font-size:.8125rem;">{{ $error }}</div>
@else
<div class="pd-head">
<div><strong>账单明细</strong><span class="pd-head__count">{{ number_format($summary['rows']) }} 条</span></div>
<div><strong>{{ $pageTitle }}</strong><span class="pd-head__count">{{ number_format($summary['rows']) }} 条</span></div>
<div class="pd-tools">
<select id="pdExportMode" title="选择导出范围" aria-label="导出范围">
<option value="page-visible">当前页(25 条)· 当前显示列</option>
@ -309,7 +329,7 @@
// 已勾选的值始终保留在列表里,方便取消。与科目映射表 / 业绩明细同一套交互。
var form = document.getElementById('pdFilterForm');
if (api && form && widgets.length) {
var endpoint = @json(route('business.financial-operations.billing-details.filter-options'));
var endpoint = @json($filterOptionsRoute, JSON_UNESCAPED_SLASHES);
var timer = null;
function currentParams() {
@ -371,7 +391,7 @@
var exportBtn = document.getElementById('pdExportBtn');
if (!table || !modeEl || !exportBtn) { return; }
var exportBase = @json(route('business.financial-operations.billing-details.export'));
var exportBase = @json($exportRoute, JSON_UNESCAPED_SLASHES);
var heads = Array.prototype.slice.call(table.querySelectorAll('thead th[data-col]'));
var keys = heads.map(function (th) { return th.getAttribute('data-col'); });
var labels = heads.map(function (th) { return th.textContent.trim(); });
@ -453,7 +473,7 @@
var progressBox = document.getElementById('pdExportProgress');
var barEl = document.getElementById('pdExportBar');
var textEl = document.getElementById('pdExportText');
var progressUrl = @json(route('business.financial-operations.billing-details.export-progress'));
var progressUrl = @json($exportProgressRoute, JSON_UNESCAPED_SLASHES);
var pollTimer = null;
function stopPolling() {

View File

@ -584,6 +584,18 @@ Route::post('workspace/trademark-management/tax-forecast-data-logic/edit', 'Busi
->middleware('business.page:trademark-management,tax-forecast-data-logic')
->name('business.tax-forecast-logic.update');
Route::get('workspace/financial-operations/expense-details/export', 'Business\Pages@exportExpenseDetails')
->middleware('business.page:financial-operations,expense-details')
->name('business.financial-operations.expense-details.export');
Route::get('workspace/financial-operations/expense-details/export-progress', 'Business\Pages@billingDetailsExportProgress')
->middleware('business.page:financial-operations,expense-details')
->name('business.financial-operations.expense-details.export-progress');
Route::get('workspace/financial-operations/expense-details/filter-options', 'Business\Pages@expenseDetailsFilterOptions')
->middleware('business.page:financial-operations,expense-details')
->name('business.financial-operations.expense-details.filter-options');
Route::get('workspace/financial-operations/billing-details/export', 'Business\Pages@exportBillingDetails')
->middleware('business.page:financial-operations,billing-details')
->name('business.financial-operations.billing-details.export');

View File

@ -93,6 +93,7 @@ class PerformanceDetailsMetricDetailsTest extends FeatureTestCase
'planting_amount' => null,
'paid_amount' => 0,
'difference' => 0,
'cross_month_balanced' => false,
'match_status' => '已匹配',
'remark' => null,
'imported_at' => now(),
@ -133,6 +134,7 @@ class PerformanceDetailsMetricDetailsTest extends FeatureTestCase
$table->decimal('planting_amount', 20, 4)->nullable();
$table->decimal('paid_amount', 20, 4)->nullable();
$table->decimal('difference', 20, 4)->nullable();
$table->boolean('cross_month_balanced')->default(false);
$table->string('match_status', 32)->nullable();
$table->string('remark', 191)->nullable();
$table->dateTime('imported_at');

View File

@ -130,6 +130,21 @@ class NavigationTest extends FeatureTestCase
->assertSeeText('店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。');
}
public function testExpenseDetailsPageIsAvailableAndForcedToExpenseCategory(): void
{
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'expense-details',
]))
->assertOk()
->assertSeeText('费用明细')
->assertSeeText('本页只展示科目映射表中财务类型=费用的账单。')
->assertSee('/workspace/financial-operations/expense-details/export', false)
->assertSee('/workspace/financial-operations/expense-details/filter-options', false)
->assertDontSee('id="pdType"', false);
}
public function testFinancialAnalysisPageIncludesInvoiceExpenseColumns(): void
{
$this->loginAs()
@ -451,6 +466,17 @@ class NavigationTest extends FeatureTestCase
array_column($refund['pages'], 'title')
);
$this->assertNotNull($financialOperations);
$financialTitles = array_column($financialOperations['pages'], 'title');
$this->assertNotContains('广告费用', $financialTitles);
$this->assertNotContains('营销费用', $financialTitles);
$performanceIndex = array_search('业绩明细', $financialTitles, true);
$expenseIndex = array_search('费用明细', $financialTitles, true);
$billingIndex = array_search('账单明细', $financialTitles, true);
$this->assertIsInt($performanceIndex);
$this->assertIsInt($expenseIndex);
$this->assertIsInt($billingIndex);
$this->assertLessThan($expenseIndex, $performanceIndex);
$this->assertLessThan($billingIndex, $expenseIndex);
$smallPayment = collect($financialOperations['pages'])->firstWhere('slug', 'small-payment-work-orders');
$this->assertSame('小额打款', $smallPayment['title'] ?? null);
$this->assertGreaterThan(

View File

@ -169,6 +169,42 @@ class UserPermissionsTest extends FeatureTestCase
);
}
public function test_expense_details_follows_existing_financial_page_permissions(): void
{
$regular = $this->account('user');
$subject = 'user_'.$regular->id;
setting()->set('system.sidebar_permissions', [
'configured' => [$subject],
'subjects' => [$subject => [
'financial-operations/performance-details',
'financial-operations/billing-details',
]],
]);
$navigation = SidebarPermissions::navigationFor($regular);
$this->assertSame(['financial-operations'], array_column($navigation, 'slug'));
$this->assertSame(
['performance-details', 'expense-details', 'billing-details'],
array_column($navigation[0]['pages'], 'slug')
);
}
public function test_expense_details_replaces_legacy_standalone_expense_entries(): void
{
$regular = $this->account('user');
$subject = 'user_'.$regular->id;
setting()->set('system.sidebar_permissions', [
'configured' => [$subject],
'subjects' => [$subject => ['financial-operations/advertising-expenses']],
]);
$navigation = SidebarPermissions::navigationFor($regular);
$this->assertSame(['financial-operations'], array_column($navigation, 'slug'));
$this->assertSame(['expense-details'], array_column($navigation[0]['pages'], 'slug'));
}
public function test_process_statistics_follows_the_violation_alert_page_permission(): void
{
// 只按页面键授权过的账号(旧配置)也要能看到新增的「流程统计」,与店铺违规预警同权限。

View File

@ -0,0 +1,19 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\View\BillingDetailsPageData;
use Tests\TestCase;
class BillingDetailsExpenseFilterTest extends TestCase
{
public function test_expense_filter_overrides_user_selected_finance_type(): void
{
$filters = app(BillingDetailsPageData::class)->exportFilters(
['type' => ['收入', '推广']],
['type' => ['费用']],
);
$this->assertSame(['费用'], $filters['type']);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class CrossMonthBalanceMigrationTest extends TestCase
{
public function test_migration_adds_column_and_lookup_index_and_can_roll_back(): void
{
$connection = 'business_mirror';
$schema = Schema::connection($connection);
$schema->dropIfExists('store_receipt_order_match_rows');
$schema->create('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->increments('id');
$table->date('report_month');
$table->string('platform', 32);
$table->string('store_name', 191)->nullable();
$table->string('online_order_no', 64)->nullable();
$table->decimal('difference', 20, 4)->nullable();
});
$migration = require database_path(
'migrations/2026_10_24_210000_add_cross_month_balanced_to_store_receipt_order_match_rows.php'
);
$migration->up();
$this->assertTrue($schema->hasColumn('store_receipt_order_match_rows', 'cross_month_balanced'));
$this->assertTrue(collect($schema->getIndexes('store_receipt_order_match_rows'))
->contains(static fn (array $index): bool => ($index['name'] ?? null) === 'store_receipt_cross_month_lookup_index'));
$this->assertSame(0, DB::connection($connection)->table('store_receipt_order_match_rows')->count());
$migration->down();
$this->assertFalse($schema->hasColumn('store_receipt_order_match_rows', 'cross_month_balanced'));
$this->assertFalse(collect($schema->getIndexes('store_receipt_order_match_rows'))
->contains(static fn (array $index): bool => ($index['name'] ?? null) === 'store_receipt_cross_month_lookup_index'));
}
}

View File

@ -31,6 +31,7 @@ class PerformanceDifferenceRemarkTest extends TestCase
'product_amount' => 0,
'paid_amount' => 0,
'difference' => 0,
'cross_month_balanced' => 0,
'planting_amount' => null,
'remark' => null,
], $attributes);
@ -50,6 +51,29 @@ class PerformanceDifferenceRemarkTest extends TestCase
]));
}
public function test_cross_month_balanced_rule_is_first_and_only_marks_reconciled_rows(): void
{
$rules = $this->baseRules();
$label = '差额=跨月收款(合并已对平)';
$this->assertSame($label, array_key_first($rules));
$this->assertSame('cross_month_balanced = 1', $rules[$label]);
$this->assertSame($label, $this->remark([
'platform' => '淘系支付宝',
'income_amount' => 2713.20,
'paid_amount' => 3488.40,
'difference' => -775.20,
'cross_month_balanced' => 1,
]));
$this->assertSame('差额待核实', $this->remark([
'platform' => '淘系支付宝',
'income_amount' => 2713.20,
'paid_amount' => 3488.40,
'difference' => -775.20,
'cross_month_balanced' => 0,
]));
}
public function test_pinduoduo_partial_refund_marks_platform_payment_discount(): void
{
$this->assertSame('差额=平台支付立减', $this->remark([