业绩明细:增加跨月对平备注并拆分费用明细 #504
@ -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(拼多多 / 淘系支付宝),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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' => '管理费用'],
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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 {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
{
|
||||
// 只按页面键授权过的账号(旧配置)也要能看到新增的「流程统计」,与店铺违规预警同权限。
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
@ -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'));
|
||||
}
|
||||
}
|
||||
@ -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([
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user