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