CaiWuXiTong/modules/EcommerceFinance/Services/StoreReceiptDefaultCompensationSnapshotBuilder.php

340 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Services;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
/**
* 人工核对工具:直接从指定科目映射表和账单镜像生成违约赔付金额快照。
*
* 不写共享数据库。正式页面已直接读取 store_receipt_order_match_rows 的
* default_compensation_amount,本工具仅保留用于离线验算。
*/
class StoreReceiptDefaultCompensationSnapshotBuilder
{
public const REMARK = '违约赔付(冲减收入)';
private const SOURCES = [
'taobao-alipay' => [
'label' => '淘系支付宝', 'table' => 'taobao_alipay_bill_rows',
'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount',
'real_store' => 'online_real_store',
],
'taobao-wechat' => [
'label' => '淘系微信', 'table' => 'taobao_wechat_bill_rows',
'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount',
],
'pinduoduo' => [
'label' => '拼多多', 'table' => 'pdd_settlement_bill_rows',
'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount',
],
'dewu' => [
'label' => '得物', 'table' => 'dewu_settlement_bill_rows',
'order' => 'online_order_no', 'time' => 'bill_date', 'amount' => 'income_amount',
'real_store' => 'online_real_store',
],
'jd-transactions' => [
'label' => '京东账单', 'table' => 'jd_daily_bill_rows',
'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income',
'store_exclude' => '京喜', 'periods' => 'jd_daily_bill_periods',
],
'jingxi' => [
'label' => '京喜账单', 'table' => 'jd_daily_bill_rows',
'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income',
'store_include' => '京喜', 'periods' => 'jd_daily_bill_periods',
],
'douyin' => [
'label' => '抖音', 'table' => 'douyin_authoritative_bills',
'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income',
'periods' => 'douyin_authoritative_periods',
],
];
/** 页签内多平台按店铺名前缀拆分;单平台页签无需拆分。 */
private const TAB_PLATFORM_GROUPS = [
'taobao-alipay' => [
['platform' => '淘宝', 'prefixes' => ['淘宝'], 'include_default' => true],
['platform' => '天猫', 'prefixes' => ['天猫']],
['platform' => '阿里巴巴', 'prefixes' => ['阿里巴巴', '1688', '新佰广']],
],
'taobao-wechat' => [
['platform' => '淘宝', 'prefixes' => ['淘宝'], 'include_default' => true],
['platform' => '天猫', 'prefixes' => ['天猫']],
],
'pinduoduo' => [['platform' => '拼多多']],
'dewu' => [['platform' => '得物']],
'jd-transactions' => [['platform' => '京东']],
'jingxi' => [['platform' => '京喜']],
'douyin' => [['platform' => '抖音']],
];
private const STORE_PREFIXES = [
'唯品会JX', '唯品会MP', '阿里巴巴', '1688', '新佰广',
'拼多多', '天猫', '淘宝', '抖音', '得物', '京喜', '京东',
];
/**
* @return array{
* month:string,remark:string,generated_at:string,mapping_sha256:string,mapping_rows:int,
* source_stats:array<string,array{rows:int,amount:float,groups:int}>,
* amounts:array<string,float>,total:float
* }
*/
public function build(string $month, string $mappingPath): array
{
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
throw new RuntimeException('月份格式必须是 YYYY-MM。');
}
if (! is_file($mappingPath)) {
throw new RuntimeException('科目映射表不存在:' . $mappingPath);
}
$start = CarbonImmutable::parse($month . '-01')->startOfMonth();
$end = $start->addMonth();
$reportMonth = $start->toDateString();
$itemsByPlatform = $this->readMappingItems($mappingPath);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$amounts = [];
$sourceStats = [];
foreach (self::SOURCES as $tab => $source) {
$groups = $this->groupsForSource($tab, $itemsByPlatform);
if ($groups === []) {
continue;
}
$table = (string) $source['table'];
$orderColumn = (string) $source['order'];
$timeColumn = (string) $source['time'];
$amountColumn = (string) $source['amount'];
$realStoreColumn = (string) ($source['real_store'] ?? '');
$expenseColumn = '';
if (Schema::connection($connection)->hasColumn($table, 'expense_amount')) {
$expenseColumn = 'expense_amount';
} elseif (Schema::connection($connection)->hasColumn($table, 'expense')) {
$expenseColumn = 'expense';
}
$select = array_values(array_unique(array_filter([
'store_name', 'bill_item', $orderColumn, $timeColumn, $amountColumn,
$expenseColumn, $realStoreColumn,
])));
$query = DB::connection($connection)->table($table)
->select($select)
->where($timeColumn, '>=', $start->toDateTimeString())
->where($timeColumn, '<', $end->toDateTimeString());
if (isset($source['store_include'])) {
$query->where('store_name', 'like', '%' . $source['store_include'] . '%');
}
if (isset($source['store_exclude'])) {
$query->where('store_name', 'not like', '%' . $source['store_exclude'] . '%');
}
$periodsTable = (string) ($source['periods'] ?? '');
if ($periodsTable !== '') {
$generations = DB::connection($connection)->table($periodsTable)
->where('period_start', '<', $end->toDateString())
->where('period_end', '>', $start->toDateString())
->pluck('active_generation')
->filter()
->unique()
->values()
->all();
if ($generations === []) {
continue;
}
$query->whereIn('generation', $generations);
}
$query->where(function ($outer) use ($groups): void {
foreach ($groups as $group) {
$outer->orWhere(function ($sub) use ($group): void {
if ($group['prefixes'] !== []) {
$sub->where(function ($scope) use ($group): void {
$scope->where(function ($own) use ($group): void {
foreach ($group['prefixes'] as $prefix) {
$own->orWhere('store_name', 'like', $prefix . '%');
}
});
if ($group['include_default']) {
$scope->orWhere(function ($other): void {
foreach (self::STORE_PREFIXES as $prefix) {
$other->where('store_name', 'not like', $prefix . '%');
}
});
}
});
}
$sub->whereIn('bill_item', $group['items']);
});
}
});
$rows = 0;
$amountTotal = 0.0;
foreach ($query->orderBy($timeColumn)->cursor() as $row) {
$rows++;
$store = $realStoreColumn !== ''
? trim((string) ($row->{$realStoreColumn} ?? ''))
: '';
if ($store === '') {
$store = trim((string) ($row->store_name ?? ''));
}
$orderNo = trim((string) ($row->{$orderColumn} ?? ''));
$amount = (float) ($row->{$amountColumn} ?? 0)
- ($expenseColumn !== '' ? (float) ($row->{$expenseColumn} ?? 0) : 0.0);
$key = $this->rowKey(
$reportMonth,
(string) $source['label'],
$store,
$orderNo,
(string) ($row->bill_item ?? ''),
);
$amounts[$key] = round(($amounts[$key] ?? 0.0) + $amount, 2);
$amountTotal += $amount;
}
$sourceStats[(string) $source['label']] = [
'rows' => $rows,
'amount' => round($amountTotal, 2),
'groups' => count($groups),
];
}
ksort($amounts);
return [
'month' => $month,
'remark' => self::REMARK,
'generated_at' => now()->toIso8601String(),
'mapping_sha256' => hash_file('sha256', $mappingPath),
'mapping_rows' => array_sum(array_map('count', $itemsByPlatform)),
'source_stats' => $sourceStats,
'amounts' => $amounts,
'total' => round(array_sum($amounts), 2),
];
}
/** @return array<string,list<string>> */
private function readMappingItems(string $mappingPath): array
{
$sheet = IOFactory::load($mappingPath)->getActiveSheet();
$rows = $sheet->toArray(null, true, true, false);
if ($rows === []) {
throw new RuntimeException('科目映射表没有数据。');
}
$headers = array_map(static fn ($value): string => trim((string) $value), array_shift($rows));
$platformIndex = array_search('平台', $headers, true);
$itemIndex = array_search('账单项目', $headers, true);
$remarkIndex = array_search('财务备注', $headers, true);
if ($platformIndex === false || $itemIndex === false || $remarkIndex === false) {
throw new RuntimeException('科目映射表缺少「平台」「账单项目」或「财务备注」列。');
}
$items = [];
foreach ($rows as $row) {
if (trim((string) ($row[$remarkIndex] ?? '')) !== self::REMARK) {
continue;
}
$platform = trim((string) ($row[$platformIndex] ?? ''));
$item = trim((string) ($row[$itemIndex] ?? ''));
if ($platform !== '' && $item !== '') {
$items[$platform][$item] = true;
}
}
$result = [];
foreach ($items as $platform => $values) {
$result[$platform] = array_keys($values);
sort($result[$platform], SORT_NATURAL | SORT_FLAG_CASE);
}
return $result;
}
/**
* @param array<string,list<string>> $itemsByPlatform
* @return list<array{prefixes:list<string>,include_default:bool,items:list<string>}>
*/
private function groupsForSource(string $tab, array $itemsByPlatform): array
{
$groups = [];
foreach (self::TAB_PLATFORM_GROUPS[$tab] ?? [] as $definition) {
$platform = (string) $definition['platform'];
$items = $itemsByPlatform[$platform] ?? [];
if ($items === []) {
continue;
}
if ($tab === 'taobao-wechat') {
$items = $this->withWxVariants($items);
}
$groups[] = [
'prefixes' => array_values((array) ($definition['prefixes'] ?? [])),
'include_default' => (bool) ($definition['include_default'] ?? false),
'items' => $items,
];
}
return $groups;
}
/** @param list<string> $items @return list<string> */
private function withWxVariants(array $items): array
{
$variants = [];
foreach ($items as $item) {
$variants[$item] = true;
if (stripos($item, 'wx') === 0) {
$without = ltrim(mb_substr($item, 2));
if ($without !== '') {
$variants[$without] = true;
}
} else {
$variants['wx' . $item] = true;
}
}
$list = array_keys($variants);
sort($list, SORT_NATURAL | SORT_FLAG_CASE);
return $list;
}
private function rowKey(string $month, string $platform, string $storeName, string $orderNo, string $billItem): string
{
$shopKey = $this->normalizeShop($storeName);
$identity = $this->orderIdentity($orderNo);
if ($identity === '') {
return md5($month . '|' . $platform . '|' . $shopKey . '|promo|' . $billItem);
}
return md5($month . '|' . $platform . '|' . $shopKey . '|' . $identity);
}
private function normalizeShop(string $value): string
{
$value = str_replace(["\u{3000}", '_'], [' ', '-'], trim($value));
$value = preg_replace('/\s+/u', '', $value) ?? '';
return strtoupper($value);
}
private function orderIdentity(string $value): string
{
$value = str_replace("\u{3000}", ' ', trim($value));
if ($value === '') {
return '';
}
$value = preg_replace('/[\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', '-', $value) ?? $value;
$value = preg_replace('/\s+/u', '', $value) ?? '';
return strtoupper($value);
}
}