feat: 增加业绩明细金额指标与账单明细汇总 #498
@ -13,6 +13,7 @@ use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingStore;
|
||||
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
|
||||
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@ -143,6 +144,8 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
|
||||
private const TARGET_TABLE = 'store_receipt_order_match_rows';
|
||||
|
||||
private const METRIC_DETAIL_TABLE = 'store_receipt_order_match_metric_details';
|
||||
|
||||
private const STATUS_MATCHED = '已匹配';
|
||||
|
||||
private const STATUS_UNMATCHED = '未匹配';
|
||||
@ -253,6 +256,10 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
->where('report_month', $reportMonth)
|
||||
->where('platform', (string) $source['label'])
|
||||
->delete();
|
||||
DB::connection($connection)->table(self::METRIC_DETAIL_TABLE)
|
||||
->where('report_month', $reportMonth)
|
||||
->where('platform', (string) $source['label'])
|
||||
->delete();
|
||||
}
|
||||
$this->warn(sprintf(
|
||||
'%s 已从业绩明细排除,%s 删除镜像行 %d 行(收、支均不取)。',
|
||||
@ -457,7 +464,13 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
));
|
||||
unset($plantingCores);
|
||||
if ((bool) $this->option('amounts-only')) {
|
||||
return $this->backfillPromoAmounts($month, $source, $groups, $promoOnlyGroups, $reportMonth);
|
||||
$result = $this->backfillPromoAmounts($month, $source, $groups, $promoOnlyGroups, $reportMonth);
|
||||
if ($result === self::SUCCESS && ! (bool) $this->option('dry-run')) {
|
||||
$this->replaceMetricDetails($reportMonth, (string) $source['label'], $groups, $promoOnlyGroups, now());
|
||||
$this->verifyMetricDetailTotals($reportMonth, (string) $source['label']);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 订单侧的单号在不同平台形态不同:淘系/京东是无前缀纯数字,
|
||||
@ -663,6 +676,10 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
->where('report_month', $reportMonth)
|
||||
->where('platform', (string) $source['label'])
|
||||
->delete();
|
||||
DB::connection($connection)->table(self::METRIC_DETAIL_TABLE)
|
||||
->where('report_month', $reportMonth)
|
||||
->where('platform', (string) $source['label'])
|
||||
->delete();
|
||||
}
|
||||
|
||||
$target = DB::connection($connection)->table(self::TARGET_TABLE);
|
||||
@ -869,6 +886,17 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
$imported += count($buffer);
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->replaceMetricDetails(
|
||||
$reportMonth,
|
||||
(string) $source['label'],
|
||||
$groups,
|
||||
$promoOnlyGroups,
|
||||
$now,
|
||||
);
|
||||
$this->verifyMetricDetailTotals($reportMonth, (string) $source['label']);
|
||||
}
|
||||
|
||||
$this->info('写入完成,共 ' . $imported . ' 行(账单收款 ' . $paymentTotal . ' 笔)。');
|
||||
foreach ($stats as $status => $count) {
|
||||
$this->line(' ' . $status . ':' . $count);
|
||||
@ -1290,6 +1318,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
'default_compensation_seen' => false,
|
||||
'refund_amount' => 0.0,
|
||||
'refund_seen' => false,
|
||||
'bill_items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
@ -1318,11 +1347,23 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
'default_compensation_seen' => false,
|
||||
'refund_amount' => 0.0,
|
||||
'refund_seen' => false,
|
||||
'bill_items' => [],
|
||||
];
|
||||
}
|
||||
$promoOnlyGroups[$promoKey][$bucket . '_seen'] = true;
|
||||
$promoOnlyGroups[$promoKey][$bucket . '_amount'] += $amount;
|
||||
}
|
||||
$billItemKey = \Illuminate\Support\Str::limit($billItem !== '' ? $billItem : '未命名账单项目', 191, '');
|
||||
$groups[$key]['bill_items'][$bucket][$billItemKey]['amount'] =
|
||||
($groups[$key]['bill_items'][$bucket][$billItemKey]['amount'] ?? 0.0) + $amount;
|
||||
$groups[$key]['bill_items'][$bucket][$billItemKey]['line_count'] =
|
||||
($groups[$key]['bill_items'][$bucket][$billItemKey]['line_count'] ?? 0) + 1;
|
||||
if ($bucket !== 'income') {
|
||||
$promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['amount'] =
|
||||
($promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['amount'] ?? 0.0) + $amount;
|
||||
$promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['line_count'] =
|
||||
($promoOnlyGroups[$promoKey]['bill_items'][$bucket][$billItemKey]['line_count'] ?? 0) + 1;
|
||||
}
|
||||
if ($bucket === 'income') {
|
||||
$groups[$key]['payment_count']++;
|
||||
$groups[$key]['income_amount'] += $amount;
|
||||
@ -1440,6 +1481,129 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建一个平台×账期的账单项目金额明细。
|
||||
*
|
||||
* 汇总值来自同一遍账单读取,并通过 row_key 绑定主表行;页面按主表当前筛选
|
||||
* 条件关联本表,所以详情合计和指标卡使用同一批订单。
|
||||
*
|
||||
* @param array<string,array<string,mixed>> $groups
|
||||
* @param array<string,array<string,mixed>> $promoOnlyGroups
|
||||
*/
|
||||
private function replaceMetricDetails(
|
||||
string $reportMonth,
|
||||
string $platform,
|
||||
array $groups,
|
||||
array $promoOnlyGroups,
|
||||
mixed $now,
|
||||
): void {
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$target = DB::connection($connection)->table(self::METRIC_DETAIL_TABLE);
|
||||
$target->where('report_month', $reportMonth)
|
||||
->where('platform', $platform)
|
||||
->delete();
|
||||
|
||||
$buffer = [];
|
||||
$append = static function (array $group, string $rowKey) use (&$buffer, $target, $reportMonth, $platform, $now): void {
|
||||
foreach ((array) ($group['bill_items'] ?? []) as $metric => $billItems) {
|
||||
foreach ($billItems as $billItem => $values) {
|
||||
$amount = round((float) ($values['amount'] ?? 0), 4);
|
||||
if (abs($amount) < 0.00005) {
|
||||
continue;
|
||||
}
|
||||
$billItem = trim((string) $billItem) !== '' ? trim((string) $billItem) : '未命名账单项目';
|
||||
$buffer[] = [
|
||||
'report_month' => $reportMonth,
|
||||
'detail_key' => md5($rowKey . '|' . $metric . '|' . $billItem),
|
||||
'row_key' => $rowKey,
|
||||
'platform' => $platform,
|
||||
'metric' => (string) $metric,
|
||||
'bill_item' => $billItem,
|
||||
'amount' => $amount,
|
||||
'line_count' => (int) ($values['line_count'] ?? 1),
|
||||
'imported_at' => $now,
|
||||
];
|
||||
if (count($buffer) >= 1000) {
|
||||
$target->insertOrIgnore($buffer);
|
||||
$buffer = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
foreach ($groups as $group) {
|
||||
$append($group, self::groupRowKey(
|
||||
$reportMonth,
|
||||
$platform,
|
||||
(string) ($group['shop_key'] ?? ''),
|
||||
(string) ($group['order_identity'] ?? ''),
|
||||
));
|
||||
}
|
||||
foreach ($promoOnlyGroups as $group) {
|
||||
$identity = (string) ($group['order_identity'] ?? '');
|
||||
$rowKey = $identity !== ''
|
||||
? self::groupRowKey($reportMonth, $platform, (string) ($group['shop_key'] ?? ''), $identity)
|
||||
: md5($reportMonth . '|' . $platform . '|' . ($group['shop_key'] ?? '') . '|promo|' . ($group['bill_item'] ?? ''));
|
||||
$append($group, $rowKey);
|
||||
}
|
||||
|
||||
if ($buffer !== []) {
|
||||
$target->insertOrIgnore($buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账单项目明细与主表金额列必须逐项相等。任何一分钱差异都视为同步失败,
|
||||
* 避免页面出现“指标总额”和“明细合计”对不上的情况。
|
||||
*/
|
||||
private function verifyMetricDetailTotals(string $reportMonth, string $platform): void
|
||||
{
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$main = DB::connection($connection)->table(self::TARGET_TABLE)
|
||||
->where('report_month', $reportMonth)
|
||||
->where('platform', $platform)
|
||||
->selectRaw(
|
||||
'SUM(COALESCE(income_amount, 0)) as income, '
|
||||
. 'SUM(COALESCE(discount_amount, 0)) as discount, '
|
||||
. 'SUM(COALESCE(offset_amount, 0)) as offset_amount, '
|
||||
. 'SUM(COALESCE(default_compensation_amount, 0)) as default_compensation, '
|
||||
. 'SUM(COALESCE(refund_amount, 0)) as refund'
|
||||
)
|
||||
->first();
|
||||
$details = DB::connection($connection)->table(self::METRIC_DETAIL_TABLE)
|
||||
->where('report_month', $reportMonth)
|
||||
->where('platform', $platform)
|
||||
->selectRaw(
|
||||
"SUM(CASE WHEN metric = 'income' THEN amount ELSE 0 END) as income, "
|
||||
. "SUM(CASE WHEN metric = 'discount' THEN amount ELSE 0 END) as discount, "
|
||||
. "SUM(CASE WHEN metric = 'offset' THEN amount ELSE 0 END) as offset_amount, "
|
||||
. "SUM(CASE WHEN metric = 'default_compensation' THEN amount ELSE 0 END) as default_compensation, "
|
||||
. "SUM(CASE WHEN metric = 'refund' THEN amount ELSE 0 END) as refund"
|
||||
)
|
||||
->first();
|
||||
$labels = [
|
||||
'income' => '收入',
|
||||
'discount' => '优惠',
|
||||
'offset_amount' => '冲减',
|
||||
'default_compensation' => '违约赔付',
|
||||
'refund' => '退款',
|
||||
];
|
||||
foreach ($labels as $column => $label) {
|
||||
$mainTotal = round((float) ($main->{$column} ?? 0), 2);
|
||||
$detailTotal = round((float) ($details->{$column} ?? 0), 2);
|
||||
if (abs($mainTotal - $detailTotal) > 0.005) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'账单项目明细校验失败:%s %s %s,主表 %s,明细 %s。',
|
||||
$reportMonth,
|
||||
$platform,
|
||||
$label,
|
||||
number_format($mainTotal, 2, '.', ''),
|
||||
number_format($detailTotal, 2, '.', ''),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $group */
|
||||
private static function plantingLabel(array $group): ?string
|
||||
{
|
||||
|
||||
@ -111,6 +111,18 @@ class Pages extends Controller
|
||||
'得物',
|
||||
];
|
||||
|
||||
/** 业绩明细可点击指标 → 主表金额列、账单项目明细桶、页面名称。 */
|
||||
private const RECEIPT_METRIC_MAP = [
|
||||
'income_amount' => ['label' => '账单收入', 'metric' => 'income'],
|
||||
'paid_amount' => ['label' => '订单实付', 'metric' => null],
|
||||
'discount_amount' => ['label' => '优惠收入', 'metric' => 'discount'],
|
||||
'offset_amount' => ['label' => '冲减收入', 'metric' => 'offset'],
|
||||
'default_compensation_amount' => ['label' => '违约赔付', 'metric' => 'default_compensation'],
|
||||
'refund_amount' => ['label' => '退款金额', 'metric' => 'refund'],
|
||||
'planting_amount' => ['label' => '种菜金额', 'metric' => null],
|
||||
'difference' => ['label' => '差额合计', 'metric' => null],
|
||||
];
|
||||
|
||||
/** 业绩明细不纳入的收款账户:京东网银钱包的收、支均不取。 */
|
||||
private const RECEIPT_MATCH_EXCLUDED_PLATFORMS = [
|
||||
'京东网银钱包',
|
||||
@ -1537,6 +1549,235 @@ class Pages extends Controller
|
||||
]), $stats];
|
||||
}
|
||||
|
||||
/**
|
||||
* 业绩明细指标卡点击后的明细汇总。
|
||||
*
|
||||
* 收入、优惠、冲减、违约赔付、退款从账单项目明细表按「平台 + 店铺 + 账单项目」
|
||||
* 汇总;订单实付、种菜金额、差额没有账单项目维度,直接按主表行汇总。
|
||||
*/
|
||||
public function receiptOrderMatchMetricDetails(Request $request)
|
||||
{
|
||||
$metric = trim((string) $request->query('metric', ''));
|
||||
$definition = self::RECEIPT_METRIC_MAP[$metric] ?? null;
|
||||
if ($definition === null) {
|
||||
return response()->json(['message' => '不支持的业绩明细指标。'], 422);
|
||||
}
|
||||
|
||||
[, $month] = $this->receiptOrderMatchMonths();
|
||||
if ($month === null) {
|
||||
return response()->json([
|
||||
'metric' => $metric,
|
||||
'label' => $definition['label'],
|
||||
'month' => null,
|
||||
'total' => 0,
|
||||
'detail_total' => 0,
|
||||
'balance' => 0,
|
||||
'balanced' => true,
|
||||
'rows' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
$query = $this->receiptOrderMatchFilteredQuery($month);
|
||||
$total = $this->receiptOrderMatchMetricTotal($query, $metric);
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$detailMetric = $definition['metric'];
|
||||
|
||||
if ($detailMetric !== null) {
|
||||
$filteredRows = (clone $query)->select([
|
||||
'report_month',
|
||||
'row_key',
|
||||
'platform',
|
||||
'store_name',
|
||||
]);
|
||||
$rows = DB::connection($connection)
|
||||
->query()
|
||||
->fromSub($filteredRows, 'filtered_receipt_rows')
|
||||
->join('store_receipt_order_match_metric_details as metric_details', function ($join): void {
|
||||
$join->on('metric_details.report_month', '=', 'filtered_receipt_rows.report_month')
|
||||
->on('metric_details.row_key', '=', 'filtered_receipt_rows.row_key');
|
||||
})
|
||||
->where('metric_details.metric', $detailMetric)
|
||||
->select([
|
||||
'filtered_receipt_rows.platform',
|
||||
'filtered_receipt_rows.store_name',
|
||||
'metric_details.bill_item',
|
||||
])
|
||||
->selectRaw('SUM(metric_details.amount) as amount')
|
||||
->selectRaw('SUM(metric_details.line_count) as line_count')
|
||||
->groupBy([
|
||||
'filtered_receipt_rows.platform',
|
||||
'filtered_receipt_rows.store_name',
|
||||
'metric_details.bill_item',
|
||||
])
|
||||
->havingRaw('ABS(SUM(metric_details.amount)) > 0.004')
|
||||
->orderBy('filtered_receipt_rows.platform')
|
||||
->orderBy('filtered_receipt_rows.store_name')
|
||||
->orderBy('metric_details.bill_item')
|
||||
->get();
|
||||
} elseif ($metric === 'difference') {
|
||||
// 与汇总使用同一套“默认差额 + 口径兜底修正”,避免对整月每一行都执行
|
||||
// 复杂的差额原因子查询;复杂规则只落在实收/应付口径兜底行上。
|
||||
$baseRows = (clone $query)
|
||||
->select(['platform', 'store_name'])
|
||||
->selectRaw('SUM(' . self::receiptDefaultDifferenceSql() . ') as amount')
|
||||
->selectRaw('COUNT(*) as line_count')
|
||||
->groupBy(['platform', 'store_name'])
|
||||
->get();
|
||||
$adjustments = (clone $query)
|
||||
->whereIn('remark', self::RECEIPT_BASIS_FALLBACK_REMARKS)
|
||||
->select(['platform', 'store_name'])
|
||||
->selectRaw('SUM(' . self::receiptFallbackDifferenceAdjustmentSql() . ') as adjustment')
|
||||
->groupBy(['platform', 'store_name'])
|
||||
->get();
|
||||
|
||||
$grouped = [];
|
||||
foreach ($baseRows as $row) {
|
||||
$key = (string) $row->platform . '|' . (string) $row->store_name;
|
||||
$grouped[$key] = [
|
||||
'platform' => (string) $row->platform,
|
||||
'store_name' => (string) $row->store_name,
|
||||
'amount' => (float) $row->amount,
|
||||
'line_count' => (int) $row->line_count,
|
||||
];
|
||||
}
|
||||
foreach ($adjustments as $row) {
|
||||
$key = (string) $row->platform . '|' . (string) $row->store_name;
|
||||
if (! isset($grouped[$key])) {
|
||||
$grouped[$key] = [
|
||||
'platform' => (string) $row->platform,
|
||||
'store_name' => (string) $row->store_name,
|
||||
'amount' => 0.0,
|
||||
'line_count' => 0,
|
||||
];
|
||||
}
|
||||
$grouped[$key]['amount'] += (float) $row->adjustment;
|
||||
}
|
||||
|
||||
ksort($grouped, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
$rows = collect($grouped)
|
||||
->filter(static fn (array $row): bool => abs((float) $row['amount']) > 0.004)
|
||||
->map(static function (array $row) use ($definition): object {
|
||||
return (object) [
|
||||
'platform' => $row['platform'],
|
||||
'store_name' => $row['store_name'],
|
||||
'bill_item' => $definition['label'],
|
||||
'amount' => $row['amount'],
|
||||
'line_count' => $row['line_count'],
|
||||
];
|
||||
})
|
||||
->values();
|
||||
} else {
|
||||
$amountSql = 'COALESCE(' . $metric . ', 0)';
|
||||
$rows = (clone $query)
|
||||
->select(['platform', 'store_name'])
|
||||
->selectRaw('SUM(' . $amountSql . ') as amount')
|
||||
->selectRaw('COUNT(*) as line_count')
|
||||
->groupBy(['platform', 'store_name'])
|
||||
->havingRaw('ABS(SUM(' . $amountSql . ')) > 0.004')
|
||||
->orderBy('platform')
|
||||
->orderBy('store_name')
|
||||
->get()
|
||||
->map(static function (object $row) use ($definition): object {
|
||||
$row->bill_item = $definition['label'];
|
||||
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
$detailTotal = round((float) $rows->sum(static fn (object $row): float => (float) ($row->amount ?? 0)), 2);
|
||||
$balance = round($detailTotal - $total, 2);
|
||||
$rows = $rows->map(static function (object $row): array {
|
||||
return [
|
||||
'platform' => (string) ($row->platform ?? ''),
|
||||
'store_name' => (string) ($row->store_name ?? ''),
|
||||
'bill_item' => (string) ($row->bill_item ?? ''),
|
||||
'amount' => round((float) ($row->amount ?? 0), 2),
|
||||
'line_count' => (int) ($row->line_count ?? 0),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'metric' => $metric,
|
||||
'label' => $definition['label'],
|
||||
'month' => substr($month, 0, 7),
|
||||
'total' => round($total, 2),
|
||||
'detail_total' => $detailTotal,
|
||||
'balance' => $balance,
|
||||
'balanced' => abs($balance) <= 0.005,
|
||||
'rows' => $rows,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 业绩明细当前筛选条件。指标详情与页面统计共用同一组请求参数。
|
||||
*
|
||||
* @return \Illuminate\Database\Query\Builder
|
||||
*/
|
||||
private function receiptOrderMatchFilteredQuery(string $month)
|
||||
{
|
||||
$platformFilter = $this->receiptOrderMatchFilterValues(request('platform'));
|
||||
$storeFilter = $this->receiptOrderMatchFilterValues(request('store_name'));
|
||||
[$statusFilter, $remarkFilter] = $this->receiptOrderMatchStatusFilters(
|
||||
request('match_status'),
|
||||
request('remark'),
|
||||
);
|
||||
$unmatchedReasonFilter = $this->receiptOrderMatchFilterValues(request('unmatched_reason'));
|
||||
$differenceState = $this->receiptOrderMatchFilterValues(request('difference_state'));
|
||||
$orderNo = trim((string) request('online_order_no', ''));
|
||||
|
||||
$query = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
|
||||
->table('store_receipt_order_match_rows')
|
||||
->where('report_month', $month)
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
|
||||
|
||||
if ($platformFilter !== []) {
|
||||
$query->whereIn('platform', $platformFilter);
|
||||
}
|
||||
if ($storeFilter !== []) {
|
||||
$query->whereIn('store_name', $storeFilter);
|
||||
}
|
||||
if ($orderNo !== '') {
|
||||
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
|
||||
}
|
||||
if ($statusFilter !== []) {
|
||||
$query->whereIn('match_status', $statusFilter);
|
||||
}
|
||||
if ($remarkFilter !== []) {
|
||||
$this->applyReceiptRemarkFilter($query, $remarkFilter);
|
||||
}
|
||||
if ($unmatchedReasonFilter !== []) {
|
||||
$this->applyReceiptUnmatchedReasonFilter($query, $unmatchedReasonFilter);
|
||||
}
|
||||
if ($differenceState !== []) {
|
||||
$this->applyReceiptDifferenceStateFilter($query, $differenceState);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function receiptOrderMatchMetricTotal($query, string $metric): float
|
||||
{
|
||||
if ($metric !== 'difference') {
|
||||
return round((float) (clone $query)->sum($metric), 2);
|
||||
}
|
||||
|
||||
$fallbackSql = self::receiptBasisFallbackSqlList();
|
||||
$row = (clone $query)->selectRaw(
|
||||
'SUM(' . self::receiptDefaultDifferenceSql() . ') as difference_base, '
|
||||
. "SUM(CASE WHEN remark IN ({$fallbackSql}) THEN 1 ELSE 0 END) as basis_fallback_rows"
|
||||
)->first();
|
||||
$total = (float) ($row->difference_base ?? 0);
|
||||
if ((int) ($row->basis_fallback_rows ?? 0) > 0) {
|
||||
$adjustment = (clone $query)
|
||||
->whereIn('remark', self::RECEIPT_BASIS_FALLBACK_REMARKS)
|
||||
->selectRaw('SUM(' . self::receiptFallbackDifferenceAdjustmentSql() . ') as adjustment')
|
||||
->first();
|
||||
$total += (float) ($adjustment->adjustment ?? 0);
|
||||
}
|
||||
|
||||
return round($total, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「差额异常备注」筛选:按差额原因的条件判定,多选内部是「或」。
|
||||
*
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 业绩明细金额指标的账单项目拆分。
|
||||
*
|
||||
* 主表仍按「店铺 + 订单号」保留一行,不能直接拆成账单项目行,否则匹配状态、
|
||||
* 差额和分页口径都会变化。本表以 row_key 关联主表,只保存收入、优惠、冲减、
|
||||
* 违约赔付、退款各自的账单项目金额,供页面点击指标后查看可核对的明细汇总。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if ($schema->hasTable('store_receipt_order_match_metric_details')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->create('store_receipt_order_match_metric_details', function (Blueprint $table): void {
|
||||
$table->date('report_month');
|
||||
$table->char('detail_key', 64);
|
||||
$table->char('row_key', 64);
|
||||
$table->string('platform', 64);
|
||||
$table->string('metric', 32);
|
||||
$table->string('bill_item', 191)->default('');
|
||||
$table->decimal('amount', 20, 4)->default(0);
|
||||
$table->unsignedInteger('line_count')->default(1);
|
||||
$table->dateTime('imported_at');
|
||||
|
||||
$table->primary(['report_month', 'detail_key'], 'receipt_metric_details_primary');
|
||||
$table->index(['report_month', 'row_key', 'metric'], 'receipt_metric_details_row_metric_index');
|
||||
$table->index(['report_month', 'platform', 'metric'], 'receipt_metric_details_metric_index');
|
||||
$table->index(['report_month', 'bill_item'], 'receipt_metric_details_item_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::connection($this->connection())
|
||||
->dropIfExists('store_receipt_order_match_metric_details');
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@ -263,6 +263,10 @@ Route::get('workspace/product-inventory/monthly-weighted-average-price/export',
|
||||
->middleware('business.page:financial-operations,performance-details')
|
||||
->name('business.financial-operations.performance-details.filter-options');
|
||||
|
||||
Route::get('workspace/financial-operations/performance-details/metric-details', 'Business\Pages@receiptOrderMatchMetricDetails')
|
||||
->middleware('business.page:financial-operations,performance-details')
|
||||
->name('business.financial-operations.performance-details.metric-details');
|
||||
|
||||
Route::get('workspace/financial-operations/product-costs/export', 'Business\Pages@exportProductCosts')
|
||||
->middleware('business.page:financial-operations,product-costs')
|
||||
->name('business.financial-operations.product-costs.export');
|
||||
|
||||
152
tests/Feature/Business/PerformanceDetailsMetricDetailsTest.php
Normal file
152
tests/Feature/Business/PerformanceDetailsMetricDetailsTest.php
Normal file
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class PerformanceDetailsMetricDetailsTest extends FeatureTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.connections.business_mirror' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
$this->createMirrorTables();
|
||||
}
|
||||
|
||||
public function test_offset_metric_details_group_by_store_and_bill_item_and_reconcile_to_total(): void
|
||||
{
|
||||
$mirror = DB::connection('business_mirror');
|
||||
$mirror->table('store_receipt_order_match_rows')->insert([
|
||||
$this->row('row-a1', '店铺A', -100),
|
||||
$this->row('row-a2', '店铺A', -200),
|
||||
$this->row('row-b1', '店铺B', -50),
|
||||
]);
|
||||
$mirror->table('store_receipt_order_match_metric_details')->insert([
|
||||
$this->detail('detail-a1', 'row-a1', '账单扣款', -60),
|
||||
$this->detail('detail-a2', 'row-a1', '售后扣减', -40),
|
||||
$this->detail('detail-a3', 'row-a2', '账单扣款', -200),
|
||||
$this->detail('detail-b1', 'row-b1', '账单扣款', -50),
|
||||
]);
|
||||
|
||||
$response = $this->loginAs()->getJson(route(
|
||||
'business.financial-operations.performance-details.metric-details',
|
||||
[
|
||||
'metric' => 'offset_amount',
|
||||
'month' => '2026-09',
|
||||
'store_name' => ['店铺A'],
|
||||
],
|
||||
));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('label', '冲减收入')
|
||||
->assertJsonPath('total', -300)
|
||||
->assertJsonPath('detail_total', -300)
|
||||
->assertJsonPath('balance', 0)
|
||||
->assertJsonPath('balanced', true);
|
||||
|
||||
$rows = $response->json('rows');
|
||||
$this->assertCount(2, $rows);
|
||||
$this->assertContains([
|
||||
'platform' => '京东账单',
|
||||
'store_name' => '店铺A',
|
||||
'bill_item' => '账单扣款',
|
||||
'amount' => -260,
|
||||
'line_count' => 2,
|
||||
], $rows);
|
||||
$this->assertContains([
|
||||
'platform' => '京东账单',
|
||||
'store_name' => '店铺A',
|
||||
'bill_item' => '售后扣减',
|
||||
'amount' => -40,
|
||||
'line_count' => 1,
|
||||
], $rows);
|
||||
}
|
||||
|
||||
private function row(string $rowKey, string $storeName, float $offset): array
|
||||
{
|
||||
return [
|
||||
'report_month' => '2026-09-01',
|
||||
'platform' => '京东账单',
|
||||
'row_key' => $rowKey,
|
||||
'occurred_date' => '2026-09-01',
|
||||
'store_name' => $storeName,
|
||||
'online_order_no' => 'ORDER-' . $rowKey,
|
||||
'payment_count' => 1,
|
||||
'income_amount' => 0,
|
||||
'discount_amount' => 0,
|
||||
'offset_amount' => $offset,
|
||||
'default_compensation_amount' => 0,
|
||||
'refund_amount' => 0,
|
||||
'planting_amount' => null,
|
||||
'paid_amount' => 0,
|
||||
'difference' => 0,
|
||||
'match_status' => '已匹配',
|
||||
'remark' => null,
|
||||
'imported_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
private function detail(string $detailKey, string $rowKey, string $billItem, float $amount): array
|
||||
{
|
||||
return [
|
||||
'report_month' => '2026-09-01',
|
||||
'detail_key' => $detailKey,
|
||||
'row_key' => $rowKey,
|
||||
'platform' => '京东账单',
|
||||
'metric' => 'offset',
|
||||
'bill_item' => $billItem,
|
||||
'amount' => $amount,
|
||||
'line_count' => 1,
|
||||
'imported_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
private function createMirrorTables(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->date('report_month');
|
||||
$table->string('platform');
|
||||
$table->string('row_key');
|
||||
$table->date('occurred_date')->nullable();
|
||||
$table->string('store_name')->nullable();
|
||||
$table->string('online_order_no')->nullable();
|
||||
$table->unsignedInteger('payment_count')->default(0);
|
||||
$table->decimal('income_amount', 20, 4)->default(0);
|
||||
$table->decimal('discount_amount', 20, 4)->nullable();
|
||||
$table->decimal('offset_amount', 20, 4)->nullable();
|
||||
$table->decimal('default_compensation_amount', 20, 4)->nullable();
|
||||
$table->decimal('refund_amount', 20, 4)->nullable();
|
||||
$table->decimal('planting_amount', 20, 4)->nullable();
|
||||
$table->decimal('paid_amount', 20, 4)->nullable();
|
||||
$table->decimal('difference', 20, 4)->nullable();
|
||||
$table->string('match_status', 32)->nullable();
|
||||
$table->string('remark', 191)->nullable();
|
||||
$table->dateTime('imported_at');
|
||||
});
|
||||
$schema->create('store_receipt_order_match_metric_details', function (Blueprint $table): void {
|
||||
$table->date('report_month');
|
||||
$table->string('detail_key');
|
||||
$table->string('row_key');
|
||||
$table->string('platform');
|
||||
$table->string('metric');
|
||||
$table->string('bill_item');
|
||||
$table->decimal('amount', 20, 4)->default(0);
|
||||
$table->unsignedInteger('line_count')->default(1);
|
||||
$table->dateTime('imported_at');
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -108,7 +108,9 @@ class NavigationTest extends FeatureTestCase
|
||||
'data-column-key="unmatched_reason"',
|
||||
'data-column-key="planting_label"',
|
||||
], false)
|
||||
->assertSeeText('标签:种菜明细表命中为“种菜报销”,其余聚水潭种菜标签命中为“种菜未报销”')
|
||||
->assertDontSeeText('查看完整字段与差额判定规则')
|
||||
->assertDontSee('receipt-method-guide', false)
|
||||
->assertDontSee('receipt-match-summary', false)
|
||||
->assertDontSee('data-column-key="product_amount"', false)
|
||||
->assertDontSeeText('订单商品金额合计')
|
||||
// 子订单号已按业务要求移除。
|
||||
|
||||
@ -5,7 +5,9 @@ namespace Tests\Unit\EcommerceFinance;
|
||||
use App\Console\Commands\SyncStoreReceiptOrderMatches;
|
||||
use Illuminate\Database\Connectors\ConnectionFactory;
|
||||
use Illuminate\Database\MySqlConnection;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
@ -71,6 +73,12 @@ class StoreReceiptMemoryTest extends TestCase
|
||||
$this->assertSame(-2.0, $paid['offset_amount']);
|
||||
$this->assertSame(-5.0, $paid['default_compensation_amount']);
|
||||
$this->assertSame(-7.0, $paid['refund_amount']);
|
||||
$this->assertSame(125.0, $paid['bill_items']['income']['订单收入']['amount']);
|
||||
$this->assertSame(2, $paid['bill_items']['income']['订单收入']['line_count']);
|
||||
$this->assertSame(-3.0, $paid['bill_items']['discount']['优惠收入']['amount']);
|
||||
$this->assertSame(-2.0, $paid['bill_items']['offset']['冲减收入']['amount']);
|
||||
$this->assertSame(-5.0, $paid['bill_items']['default_compensation']['违约赔付(冲减收入)']['amount']);
|
||||
$this->assertSame(-7.0, $paid['bill_items']['refund']['退款']['amount']);
|
||||
$this->assertTrue($paid['planting_seen']);
|
||||
$this->assertSame('260630-10001', $paid['online_order_no']);
|
||||
$this->assertSame('2026-08-01', $paid['occurred_date']);
|
||||
@ -93,6 +101,14 @@ class StoreReceiptMemoryTest extends TestCase
|
||||
$this->assertSame(-1.0, $result['promoOnlyGroups'][$key . '|20002']['refund_amount']);
|
||||
$this->assertSame(-5.0, $result['promoOnlyGroups'][$key . '||冲减收入']['offset_amount']);
|
||||
$this->assertSame(-2.0, $result['promoOnlyGroups'][$key . '||退款']['refund_amount']);
|
||||
$this->assertSame(
|
||||
-5.0,
|
||||
$result['promoOnlyGroups'][$key . '||冲减收入']['bill_items']['offset']['冲减收入']['amount'],
|
||||
);
|
||||
$this->assertSame(
|
||||
2,
|
||||
$result['promoOnlyGroups'][$key . '||冲减收入']['bill_items']['offset']['冲减收入']['line_count'],
|
||||
);
|
||||
}
|
||||
|
||||
public function test_same_core_with_different_date_prefixes_stays_as_two_orders(): void
|
||||
@ -204,6 +220,65 @@ class StoreReceiptMemoryTest extends TestCase
|
||||
$this->assertSame(0, $this->aggregate([])['billCount']);
|
||||
}
|
||||
|
||||
public function test_metric_details_are_rebuilt_from_collected_bill_items(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
if (! $schema->hasTable('store_receipt_order_match_metric_details')) {
|
||||
$schema->create('store_receipt_order_match_metric_details', function (Blueprint $table): void {
|
||||
$table->date('report_month');
|
||||
$table->string('detail_key');
|
||||
$table->string('row_key');
|
||||
$table->string('platform');
|
||||
$table->string('metric');
|
||||
$table->string('bill_item');
|
||||
$table->decimal('amount', 20, 4)->default(0);
|
||||
$table->unsignedInteger('line_count')->default(1);
|
||||
$table->dateTime('imported_at');
|
||||
});
|
||||
}
|
||||
DB::connection('business_mirror')->table('store_receipt_order_match_metric_details')->delete();
|
||||
|
||||
(new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'replaceMetricDetails'))->invoke(
|
||||
new SyncStoreReceiptOrderMatches(),
|
||||
'2026-09-01',
|
||||
'京东账单',
|
||||
[
|
||||
[
|
||||
'shop_key' => '京东-店铺A',
|
||||
'order_identity' => 'ORDER-A',
|
||||
'bill_items' => [
|
||||
'offset' => [
|
||||
'价款扣减' => ['amount' => -60.0, 'line_count' => 1],
|
||||
'售后扣减' => ['amount' => -40.0, 'line_count' => 1],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
'shop_key' => '京东-店铺B',
|
||||
'order_identity' => '',
|
||||
'bill_item' => '平台扣款',
|
||||
'bill_items' => [
|
||||
'offset' => [
|
||||
'平台扣款' => ['amount' => -25.0, 'line_count' => 2],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
now(),
|
||||
);
|
||||
|
||||
$rows = DB::connection('business_mirror')
|
||||
->table('store_receipt_order_match_metric_details')
|
||||
->orderBy('bill_item')
|
||||
->get();
|
||||
$this->assertCount(3, $rows);
|
||||
$this->assertEqualsWithDelta(-125.0, (float) $rows->sum('amount'), 0.001);
|
||||
$this->assertSame('京东账单', $rows->first()->platform);
|
||||
$this->assertSame('offset', $rows->first()->metric);
|
||||
}
|
||||
|
||||
public function test_planting_label_prefers_reimbursed_and_uses_unreimbursed_fallback(): void
|
||||
{
|
||||
$method = new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'plantingLabel');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user