按页签配置来源表与列映射(得物用 bill_date、京东系列/抖音用 order_no+income、 京东与京喜按店铺名拆分、京东/抖音只取活动代);单号查询同时带原始号与核心号 以匹配拼多多的日期前缀单号;镜像表增加平台维度与平台列/筛选。
514 lines
22 KiB
PHP
514 lines
22 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
use InvalidArgumentException;
|
||
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
|
||
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
|
||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 业绩明细 → 店铺收款匹配订单明细。
|
||
*
|
||
* 以「店铺收款账户账单」为基数(LEFT JOIN 语义,账单每笔收款都保留),
|
||
* 按「店铺 + 订单号」关联订单侧商品金额,输出严格遵循「一个店铺、一个订单号、一个商品金额」。
|
||
*
|
||
* 口径(已与业务确认):
|
||
* - 账单侧:按「总账备注」筛选(与店铺收款账户账单页同一口径),
|
||
* 再按「店铺 + 清洗后订单号」汇总收入金额与收款笔数
|
||
* - 订单侧:销售全量按「店铺 + 线上订单号」先聚合求和(一单多品/多子订单在此合并),金额取 goods_count > 0 的有效行
|
||
* - 单号清洗:去除日期前缀(如 260630-)、空格、横杠后按核心号匹配
|
||
* - 店铺:账单用「线上真实店铺」优先,订单侧用销售全量店铺名,统一把下划线归一为横杠
|
||
* - 同一店铺同一订单号多笔收款 → 标注「一单多笔」,商品金额只挂一次
|
||
*/
|
||
class SyncStoreReceiptOrderMatches extends Command
|
||
{
|
||
protected $signature = 'store-receipt:order-match:sync
|
||
{month : 账单月份,格式 YYYY-MM}
|
||
{--platform=taobao-alipay : 收款账户,目前支持 taobao-alipay}
|
||
{--chunk=1200 : 单次向上游查询的订单号数量}
|
||
{--limit=0 : 仅处理前 N 笔账单(调试用,0 表示全量)}';
|
||
|
||
protected $description = '按店铺收款账户账单匹配订单侧商品金额,生成「业绩明细」核对镜像';
|
||
|
||
/**
|
||
* 收款账户页签 → 账单来源与列映射。
|
||
*
|
||
* 各页签的表结构不一致:淘系/拼多多的订单号列是 online_order_no、金额列是 income_amount;
|
||
* 京东系列与抖音是 order_no / income;得物没有 occurred_at,用 bill_date。
|
||
* 京东账单与京喜账单共用同一张日账单表,用店铺名关键字拆分。
|
||
*/
|
||
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-wallet' => [
|
||
'label' => '京东网银钱包', 'table' => 'jd_wallet_bill_rows',
|
||
'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income',
|
||
'periods' => 'jd_wallet_bill_periods',
|
||
],
|
||
'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',
|
||
],
|
||
];
|
||
|
||
/**
|
||
* 账单侧筛选口径:总账备注命中以下任一取值即计入。
|
||
*
|
||
* 与店铺收款账户账单页的「总账备注」筛选完全一致,账单项目清单由
|
||
* ec_account_ledger_remarks(平台 + 账单项目 → 总账备注)反查得到。
|
||
*/
|
||
private const LEDGER_REMARKS = [
|
||
'订单',
|
||
'收入',
|
||
'交易收入',
|
||
'订单收入',
|
||
'商家结算款',
|
||
'跨境订单结算款',
|
||
'消费订单',
|
||
];
|
||
|
||
private const TARGET_TABLE = 'store_receipt_order_match_rows';
|
||
|
||
private const STATUS_MATCHED = '已匹配';
|
||
|
||
private const STATUS_UNMATCHED = '未匹配';
|
||
|
||
private const REMARK_EMPTY_NO = '空单号';
|
||
|
||
private const REMARK_BAD_FORMAT = '格式不一致';
|
||
|
||
private const REMARK_NO_ORDER = '订单表无此单';
|
||
|
||
private const REMARK_NO_AMOUNT = '订单无商品金额';
|
||
|
||
private const REMARK_SHOP_DIFF = '店铺不一致';
|
||
|
||
private const REMARK_DUP_ORDER = '订单侧同号多条';
|
||
|
||
private const REMARK_MULTI_PAY = '一单多笔';
|
||
|
||
public function handle(UpstreamReadOnlyQueryFactory $factory): int
|
||
{
|
||
$month = trim((string) $this->argument('month'));
|
||
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
|
||
throw new InvalidArgumentException('月份格式应为 YYYY-MM,例如 2026-08。');
|
||
}
|
||
|
||
$platform = (string) $this->option('platform');
|
||
if (! isset(self::SOURCES[$platform])) {
|
||
throw new InvalidArgumentException('暂不支持的收款账户:' . $platform);
|
||
}
|
||
|
||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||
$source = self::SOURCES[$platform];
|
||
$billTable = (string) $source['table'];
|
||
$orderColumn = (string) $source['order'];
|
||
$timeColumn = (string) $source['time'];
|
||
$amountColumn = (string) $source['amount'];
|
||
$realStoreColumn = (string) ($source['real_store'] ?? '');
|
||
$chunkSize = max(50, (int) $this->option('chunk'));
|
||
$limit = max(0, (int) $this->option('limit'));
|
||
|
||
$start = Carbon::createFromFormat('Y-m-d', $month . '-01')->startOfDay();
|
||
$end = $start->copy()->addMonthNoOverflow();
|
||
$reportMonth = $start->toDateString();
|
||
|
||
$this->info(sprintf(
|
||
'账期 %s(%s ~ %s),收款账户 %s(%s)',
|
||
$month,
|
||
$start->toDateString(),
|
||
$end->copy()->subDay()->toDateString(),
|
||
$source['label'],
|
||
$billTable,
|
||
));
|
||
|
||
// 「总账备注」→ 按平台分组的账单项目集合(淘系页签同页混有淘宝/天猫/阿里巴巴店铺,
|
||
// 必须连店铺名前缀一起过滤,才能与页面按行取到的总账备注一致)。
|
||
$groups = $this->ledgerRemarkGroups($platform);
|
||
if ($groups === []) {
|
||
$this->error('「总账备注」映射表没有匹配到任何账单项目,请先导入 ec_account_ledger_remarks。');
|
||
|
||
return self::FAILURE;
|
||
}
|
||
$this->info('总账备注命中平台分组:' . count($groups) . ' 组');
|
||
|
||
$select = ['store_name', 'bill_item', $orderColumn, $timeColumn, $amountColumn];
|
||
if ($realStoreColumn !== '') {
|
||
$select[] = $realStoreColumn;
|
||
}
|
||
$billQuery = DB::connection($connection)->table($billTable)
|
||
->select($select)
|
||
->where($timeColumn, '>=', $start->toDateTimeString())
|
||
->where($timeColumn, '<', $end->toDateTimeString());
|
||
if (isset($source['store_include'])) {
|
||
$billQuery->where('store_name', 'like', '%' . $source['store_include'] . '%');
|
||
}
|
||
if (isset($source['store_exclude'])) {
|
||
$billQuery->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 === []) {
|
||
$this->warn('该账期没有活动代(' . $periodsTable . '),未写入任何数据。');
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
$billQuery->whereIn('generation', $generations);
|
||
$this->info('活动代:' . count($generations) . ' 个');
|
||
}
|
||
LedgerRemarkFilter::applyGroups(
|
||
$billQuery,
|
||
$groups,
|
||
'bill_item',
|
||
static fn ($sub, string $prefix) => $sub->where('store_name', 'like', $prefix . '%'),
|
||
static fn ($sub, string $prefix) => $sub->where('store_name', 'not like', $prefix . '%'),
|
||
);
|
||
$bills = $billQuery->orderBy($timeColumn)->get();
|
||
|
||
if ($limit > 0) {
|
||
$bills = $bills->take($limit);
|
||
}
|
||
|
||
if ($bills->isEmpty()) {
|
||
$this->warn('该账期没有符合条件的账单记录,未写入任何数据。');
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
/** @var array<string,array<string,mixed>> $groups 账单侧按「店铺 + 订单号」聚合 */
|
||
$groups = [];
|
||
$paymentTotal = 0;
|
||
$incomeTotal = 0.0;
|
||
foreach ($bills as $bill) {
|
||
$rawNo = trim((string) ($bill->{$orderColumn} ?? ''));
|
||
$core = self::cleanOrderNo($rawNo);
|
||
$realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : '';
|
||
$shopKey = self::normalizeShop($realStore !== '' ? $realStore : (string) $bill->store_name);
|
||
$key = $shopKey . '|' . $core;
|
||
|
||
if (! isset($groups[$key])) {
|
||
$groups[$key] = [
|
||
'platform' => (string) $source['label'],
|
||
'store_name' => (string) ($bill->store_name ?? ''),
|
||
'shop_key' => $shopKey,
|
||
'online_order_no' => $rawNo,
|
||
'order_core_no' => $core,
|
||
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
|
||
'payment_count' => 0,
|
||
'income_amount' => 0.0,
|
||
];
|
||
}
|
||
|
||
$groups[$key]['payment_count']++;
|
||
$groups[$key]['income_amount'] += (float) $bill->{$amountColumn};
|
||
$paymentTotal++;
|
||
$incomeTotal += (float) $bill->{$amountColumn};
|
||
}
|
||
|
||
$this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行');
|
||
|
||
// 订单侧的单号在不同平台形态不同:淘系/京东是无前缀纯数字,
|
||
// 拼多多等平台带日期前缀(260630-xxx)。这里同时带上原始号与清洗后的
|
||
// 核心号去查订单侧,再统一按核心号聚合,避免漏匹配。
|
||
$cores = [];
|
||
foreach ($groups as $group) {
|
||
foreach ([(string) $group['online_order_no'], (string) $group['order_core_no']] as $candidate) {
|
||
$candidate = trim($candidate);
|
||
if ($candidate !== '') {
|
||
$cores[$candidate] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
$upstream = $factory->forSource('operations_replica');
|
||
$dwsFrom = $start->copy()->subDays(150)->toDateString();
|
||
$dwsTo = $end->copy()->addDays(7)->toDateString();
|
||
|
||
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(仅 goods_count>0 的有效行) */
|
||
$orderAmount = [];
|
||
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 核心单号」(含 0 元行) */
|
||
$orderSeen = [];
|
||
/** @var array<string,array<string,true>> $coresByCore 核心单号 → 出现过的店铺集合 */
|
||
$coresByCore = [];
|
||
/** @var array<string,true> $coreSeen 订单侧出现过的核心单号(含店铺名为空的行) */
|
||
$coreSeen = [];
|
||
$chunks = array_chunk(array_keys($cores), $chunkSize);
|
||
$totalChunks = count($chunks);
|
||
|
||
foreach ($chunks as $index => $chunk) {
|
||
$in = implode(',', array_map(static fn (string $no): string => "'" . str_replace("'", "''", $no) . "'", $chunk));
|
||
|
||
try {
|
||
$rows = $upstream->select(
|
||
"SELECT sourceno, shop_name, sales_amount, goods_count FROM rpt.dws_sn_sales_full_info_di
|
||
WHERE sourceno IN ({$in}) AND stat_date >= '{$dwsFrom}' AND stat_date < '{$dwsTo}'"
|
||
);
|
||
} catch (Throwable $exception) {
|
||
$this->error('订单侧查询失败(第 ' . ($index + 1) . ' 批):' . $exception->getMessage());
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
foreach ($rows as $row) {
|
||
$core = self::cleanOrderNo((string) $row['sourceno']);
|
||
if ($core === '') {
|
||
continue;
|
||
}
|
||
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
|
||
$key = $shopKey . '|' . $core;
|
||
$orderSeen[$key] = true;
|
||
$coreSeen[$core] = true;
|
||
if ($shopKey !== '') {
|
||
$coresByCore[$core][$shopKey] = true;
|
||
}
|
||
if ((int) ($row['goods_count'] ?? 0) <= 0) {
|
||
// 退款/冲减/0 元赠品行不计入商品金额,差额列另行体现。
|
||
continue;
|
||
}
|
||
$orderAmount[$key] = ($orderAmount[$key] ?? 0.0) + (float) $row['sales_amount'];
|
||
}
|
||
|
||
if (($index + 1) % 10 === 0 || $index + 1 === $totalChunks) {
|
||
$this->info(sprintf('订单侧进度 %d/%d 批', $index + 1, $totalChunks));
|
||
}
|
||
}
|
||
|
||
// 只重建「本账期 + 本平台」的数据,避免把同月其它平台的结果一起清掉。
|
||
DB::connection($connection)->table(self::TARGET_TABLE)
|
||
->where('report_month', $reportMonth)
|
||
->where('platform', (string) $source['label'])
|
||
->delete();
|
||
|
||
$target = DB::connection($connection)->table(self::TARGET_TABLE);
|
||
$now = now();
|
||
$buffer = [];
|
||
$imported = 0;
|
||
$stats = ['已匹配' => 0, '未匹配' => 0, '一单多笔' => 0];
|
||
$remarked = [];
|
||
$goodsTotal = 0.0;
|
||
$differenceTotal = 0.0;
|
||
|
||
foreach ($groups as $group) {
|
||
$core = (string) $group['order_core_no'];
|
||
$income = round((float) $group['income_amount'], 2);
|
||
$remark = '';
|
||
$productAmount = null;
|
||
$status = self::STATUS_UNMATCHED;
|
||
|
||
if ($core === '') {
|
||
$remark = self::REMARK_EMPTY_NO;
|
||
} elseif (preg_match('/^\d{8,}$/', $core) !== 1) {
|
||
$remark = self::REMARK_BAD_FORMAT;
|
||
} else {
|
||
$exactKey = $group['shop_key'] . '|' . $core;
|
||
if (isset($orderSeen[$exactKey])) {
|
||
if (isset($orderAmount[$exactKey])) {
|
||
$status = self::STATUS_MATCHED;
|
||
$productAmount = round((float) $orderAmount[$exactKey], 2);
|
||
if (count($coresByCore[$core] ?? []) > 1) {
|
||
$remark = self::REMARK_DUP_ORDER;
|
||
}
|
||
} else {
|
||
// 订单侧有记录,但全是 0 元/退款行:没有商品金额可比对。
|
||
$remark = self::REMARK_NO_AMOUNT;
|
||
}
|
||
} elseif (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) === 1) {
|
||
// 单号一致但店铺名称对不上:仍然带上金额,标注「店铺不一致」便于复核。
|
||
$onlyShop = array_key_first($coresByCore[$core]);
|
||
$otherKey = $onlyShop . '|' . $core;
|
||
if (isset($orderAmount[$otherKey])) {
|
||
$status = self::STATUS_MATCHED;
|
||
$productAmount = round((float) $orderAmount[$otherKey], 2);
|
||
$remark = self::REMARK_SHOP_DIFF;
|
||
} else {
|
||
$remark = self::REMARK_NO_AMOUNT;
|
||
}
|
||
} elseif (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) > 1) {
|
||
$sum = 0.0;
|
||
$hasAmount = false;
|
||
foreach (array_keys($coresByCore[$core]) as $shopKey) {
|
||
$key2 = $shopKey . '|' . $core;
|
||
if (isset($orderAmount[$key2])) {
|
||
$sum += (float) $orderAmount[$key2];
|
||
$hasAmount = true;
|
||
}
|
||
}
|
||
if ($hasAmount) {
|
||
$status = self::STATUS_MATCHED;
|
||
$productAmount = round($sum, 2);
|
||
$remark = self::REMARK_DUP_ORDER;
|
||
} else {
|
||
$remark = self::REMARK_NO_AMOUNT;
|
||
}
|
||
} elseif (isset($coreSeen[$core])) {
|
||
// 订单侧只有店铺名为空的记录:按订单号兜底匹配。
|
||
$blankKey = '|' . $core;
|
||
if (isset($orderAmount[$blankKey])) {
|
||
$status = self::STATUS_MATCHED;
|
||
$productAmount = round((float) $orderAmount[$blankKey], 2);
|
||
} else {
|
||
$remark = self::REMARK_NO_AMOUNT;
|
||
}
|
||
} else {
|
||
$remark = self::REMARK_NO_ORDER;
|
||
}
|
||
}
|
||
|
||
if ($status === self::STATUS_MATCHED && (int) $group['payment_count'] > 1 && $remark === '') {
|
||
$remark = self::REMARK_MULTI_PAY;
|
||
}
|
||
|
||
$difference = $productAmount === null ? null : round($income - $productAmount, 2);
|
||
if ($productAmount !== null) {
|
||
$goodsTotal += $productAmount;
|
||
$differenceTotal += (float) $difference;
|
||
}
|
||
|
||
$stats[$status] = ($stats[$status] ?? 0) + 1;
|
||
if ((int) $group['payment_count'] > 1) {
|
||
$stats['一单多笔']++;
|
||
}
|
||
if ($remark !== '') {
|
||
$remarked[$remark] = ($remarked[$remark] ?? 0) + 1;
|
||
}
|
||
|
||
$buffer[] = [
|
||
'report_month' => $reportMonth,
|
||
'platform' => $group['platform'],
|
||
'row_key' => md5($reportMonth . '|' . $group['platform'] . '|' . $group['shop_key'] . '|' . $core),
|
||
'occurred_date' => $group['occurred_date'],
|
||
'store_name' => $group['store_name'],
|
||
'online_order_no' => $group['online_order_no'] !== '' ? $group['online_order_no'] : null,
|
||
'order_core_no' => $core !== '' ? $core : null,
|
||
'payment_count' => (int) $group['payment_count'],
|
||
'income_amount' => $income,
|
||
'product_amount' => $productAmount,
|
||
'difference' => $difference,
|
||
'match_status' => $status,
|
||
'remark' => $remark !== '' ? $remark : null,
|
||
'imported_at' => $now,
|
||
];
|
||
|
||
if (count($buffer) >= 500) {
|
||
$target->insertOrIgnore($buffer);
|
||
$imported += count($buffer);
|
||
$buffer = [];
|
||
}
|
||
}
|
||
|
||
if ($buffer !== []) {
|
||
$target->insertOrIgnore($buffer);
|
||
$imported += count($buffer);
|
||
}
|
||
|
||
$this->info('写入完成,共 ' . $imported . ' 行(账单收款 ' . $paymentTotal . ' 笔)。');
|
||
foreach ($stats as $status => $count) {
|
||
$this->line(' ' . $status . ':' . $count);
|
||
}
|
||
foreach ($remarked as $remark => $count) {
|
||
$this->line(' 标注 ' . $remark . ':' . $count);
|
||
}
|
||
$this->line(' 账单收入合计:' . number_format($incomeTotal, 2));
|
||
$this->line(' 订单侧商品金额合计:' . number_format($goodsTotal, 2));
|
||
$this->line(' 差额合计:' . number_format($differenceTotal, 2));
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
/** 单号清洗:去掉日期前缀(如 260630-)、空格、横杠,得到核心号。 */
|
||
public static function cleanOrderNo(string $value): string
|
||
{
|
||
$value = str_replace("\u{3000}", ' ', trim($value));
|
||
if ($value === '') {
|
||
return '';
|
||
}
|
||
|
||
// 日期前缀形如「260630-」:只有明确带 6 位数字 + 横杠时才去除,
|
||
// 避免误伤本身就是纯数字的订单号(如 5126322747312081812)。
|
||
if (preg_match('/^\d{6}\s*[-\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', $value) === 1) {
|
||
$value = preg_replace('/^\d{6}\s*[-\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', '', $value) ?? $value;
|
||
}
|
||
|
||
$value = preg_replace('/[\s\-\x{FF0D}\x{2014}\x{2013}\x{2010}]+/u', '', $value) ?? '';
|
||
|
||
return strtoupper($value);
|
||
}
|
||
|
||
/**
|
||
* 「总账备注」集合 → 平台分组条件(复用账单页同一份映射与翻译)。
|
||
*
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
private function ledgerRemarkGroups(string $tab): array
|
||
{
|
||
$map = app(AccountLedgerRemarkMap::class);
|
||
$groups = [];
|
||
$seen = [];
|
||
|
||
foreach (self::LEDGER_REMARKS as $remark) {
|
||
foreach ($map->billItemGroupsForTab($tab, $remark) as $group) {
|
||
$key = (string) ($group['platform'] ?? '') . '|' . implode(',', (array) ($group['items'] ?? []));
|
||
if (isset($seen[$key])) {
|
||
continue;
|
||
}
|
||
$seen[$key] = true;
|
||
$groups[] = $group;
|
||
}
|
||
}
|
||
|
||
return $groups;
|
||
}
|
||
|
||
/** 店铺名归一:下划线视为横杠,去掉空白。 */
|
||
public static function normalizeShop(string $value): string
|
||
{
|
||
$value = str_replace(["\u{3000}", '_'], [' ', '-'], trim($value));
|
||
$value = preg_replace('/\s+/', '', $value) ?? '';
|
||
|
||
return strtoupper($value);
|
||
}
|
||
}
|