降低业绩匹配任务峰值内存
This commit is contained in:
parent
a6e0b2ac7d
commit
bbb967e110
@ -202,7 +202,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
|
||||
private function syncRows(): int
|
||||
{
|
||||
// 全量重建时账单与订单侧映射都在内存里:拼多多 / 淘系支付宝这类大平台需要 1G 以上,
|
||||
// 账单逐行读取,但全月订单聚合与匹配索引仍可能超过 1G(拼多多 / 淘系支付宝),
|
||||
// 容器默认 memory_limit=512M 会在写入阶段 OOM 中断,这里统一放宽,免去手工加 -d 参数。
|
||||
if ((int) ini_get('memory_limit') > 0) {
|
||||
@ini_set('memory_limit', '2G');
|
||||
@ -336,153 +336,32 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
$group->orWhereIn('bill_item', $promoItems);
|
||||
}
|
||||
});
|
||||
$bills = $billQuery->orderBy($timeColumn)->get();
|
||||
|
||||
if ($limit > 0) {
|
||||
$bills = $bills->take($limit);
|
||||
$billQuery->limit($limit);
|
||||
}
|
||||
|
||||
if ($bills->isEmpty()) {
|
||||
// 种菜与备注映射先用正常连接读取,账单游标使用独立的不缓冲连接。
|
||||
$plantingCores = $this->plantingOrderCores();
|
||||
$remarkMap = app(AccountLedgerRemarkMap::class);
|
||||
$tabPlatform = $remarkMap->defaultPlatform($platform);
|
||||
[
|
||||
'groups' => $groups, 'promoOnlyGroups' => $promoOnlyGroups,
|
||||
'paymentTotal' => $paymentTotal, 'incomeTotal' => $incomeTotal,
|
||||
'discountTotal' => $discountTotal, 'offsetTotal' => $offsetTotal, 'refundTotal' => $refundTotal,
|
||||
'moneyRemarkRows' => $moneyRemarkRows, 'attachedDiscount' => $attachedDiscount,
|
||||
'attachedOffset' => $attachedOffset, 'unattachedRows' => $unattachedRows, 'billCount' => $billCount,
|
||||
] = $this->aggregateBills(
|
||||
$this->streamBillRows($billQuery->orderBy($timeColumn)), $source,
|
||||
$orderColumn, $timeColumn, $amountColumn, $expenseColumn, $realStoreColumn,
|
||||
$plantingCores, $remarkMap, $tabPlatform,
|
||||
);
|
||||
if ($billCount === 0) {
|
||||
$this->warn('该账期没有符合条件的账单记录,未写入任何数据。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @var array<string,array<string,mixed>> $groups 账单侧按「店铺 + 订单号」聚合 */
|
||||
$groups = [];
|
||||
$paymentTotal = 0;
|
||||
$incomeTotal = 0.0;
|
||||
$discountTotal = 0.0;
|
||||
$offsetTotal = 0.0;
|
||||
$refundTotal = 0.0;
|
||||
// 种菜订单号:种菜明细表的订单号 ∪ 聚水潭订单标签明细里「是否种菜订单=是」的订单号(或的关系)。
|
||||
$plantingCores = $this->plantingOrderCores();
|
||||
$plantingTotal = 0.0;
|
||||
$plantingRows = 0;
|
||||
$remarkMap = app(AccountLedgerRemarkMap::class);
|
||||
$tabPlatform = $remarkMap->defaultPlatform($platform);
|
||||
/** @var array<string,int> $promoRowsByKey 门店+订单 维度的优惠/冲减账单行数(用于统计未挂单行数) */
|
||||
$promoRowsByKey = [];
|
||||
// 本月账单里真正命中「优惠收入 / 冲减收入 / 退款」的行数(重建后自检用:
|
||||
// 只有账单里确实有这些行,却一行都没写进结果表,才算异常)。
|
||||
$moneyRemarkRows = 0;
|
||||
foreach ($bills as $bill) {
|
||||
$storeName = (string) ($bill->store_name ?? '');
|
||||
$billItem = (string) ($bill->bill_item ?? '');
|
||||
// 收入 / 优惠 / 冲减 / 退款统一按科目映射表的当前财务备注判桶;
|
||||
// 只有当前科目映射表没有规则时,remark() 才回落到旧总账备注表。
|
||||
$remark = $remarkMap->remark(
|
||||
$storeName,
|
||||
$tabPlatform,
|
||||
$billItem,
|
||||
$this->billBusinessRemarks($bill),
|
||||
$this->billGoodsName($bill),
|
||||
);
|
||||
$isPromoRemark = in_array($remark, self::DISCOUNT_REMARKS, true)
|
||||
|| in_array($remark, self::OFFSET_REMARKS, true);
|
||||
$isRefundRemark = in_array($remark, self::REFUND_REMARKS, true);
|
||||
$isIncomeRemark = in_array($remark, self::LEDGER_REMARKS, true);
|
||||
if ($isPromoRemark || $isRefundRemark) {
|
||||
$moneyRemarkRows++;
|
||||
}
|
||||
$bucket = match (true) {
|
||||
$isIncomeRemark => 'income',
|
||||
$isRefundRemark => 'refund',
|
||||
in_array($remark, self::DISCOUNT_REMARKS, true) => 'discount',
|
||||
in_array($remark, self::OFFSET_REMARKS, true) => 'offset',
|
||||
default => null,
|
||||
};
|
||||
if ($bucket === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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'],
|
||||
// 展示名优先用「线上真实店铺」(与匹配键同源):账户名(如「支付宝-XX公司」)只是收款壳。
|
||||
'store_name' => $realStore !== '' ? $realStore : (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,
|
||||
'discount_amount' => 0.0,
|
||||
'discount_seen' => false,
|
||||
'offset_amount' => 0.0,
|
||||
'offset_seen' => false,
|
||||
'refund_amount' => 0.0,
|
||||
'refund_seen' => false,
|
||||
];
|
||||
}
|
||||
|
||||
// 三列统一按「行金额=收入侧 − 支出侧」取数(负数表示净扣减)。
|
||||
// 得物/天猫/淘系这类平台把「卖家承担优惠 / 扣回」记在支出侧,只取收入侧会漏掉扣减。
|
||||
$amount = (float) $bill->{$amountColumn}
|
||||
- ($expenseColumn !== '' ? (float) ($bill->{$expenseColumn} ?? 0) : 0.0);
|
||||
if ($bucket === 'income') {
|
||||
$groups[$key]['payment_count']++;
|
||||
$groups[$key]['income_amount'] += $amount;
|
||||
$paymentTotal++;
|
||||
$incomeTotal += $amount;
|
||||
} elseif ($bucket === 'discount') {
|
||||
$groups[$key]['discount_seen'] = true;
|
||||
$groups[$key]['discount_amount'] += $amount;
|
||||
$discountTotal += $amount;
|
||||
$promoRowsByKey[$key] = ($promoRowsByKey[$key] ?? 0) + 1;
|
||||
} elseif ($bucket === 'refund') {
|
||||
$groups[$key]['refund_seen'] = true;
|
||||
$groups[$key]['refund_amount'] += $amount;
|
||||
$refundTotal += $amount;
|
||||
} else {
|
||||
$groups[$key]['offset_seen'] = true;
|
||||
$groups[$key]['offset_amount'] += $amount;
|
||||
$offsetTotal += $amount;
|
||||
$promoRowsByKey[$key] = ($promoRowsByKey[$key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// 种菜金额只标记「这个订单是不是种菜单」,金额仍取该行的收入金额,
|
||||
// 所以在这里按核心单号打个标记,金额等聚合完再取。
|
||||
if (isset($plantingCores[$core])) {
|
||||
$groups[$key]['planting_seen'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 行口径保持不变:仍然是「有收款账单(收入白名单)的店铺 + 订单号」,
|
||||
// 只有优惠/冲减、没有收款的订单号不单独成行。
|
||||
$groups = array_filter($groups, static fn (array $group): bool => (int) $group['payment_count'] > 0);
|
||||
|
||||
// 口径对照:账单口径合计、已挂到订单行的合计、挂不上的行数(账单没有订单号 / 订单没有收款行)。
|
||||
$attachedDiscount = 0.0;
|
||||
$attachedOffset = 0.0;
|
||||
foreach ($groups as $group) {
|
||||
$attachedDiscount += (float) $group['discount_amount'];
|
||||
$attachedOffset += (float) $group['offset_amount'];
|
||||
}
|
||||
$unattachedRows = 0;
|
||||
foreach ($promoRowsByKey as $key => $count) {
|
||||
if (! isset($groups[$key])) {
|
||||
$unattachedRows += $count;
|
||||
}
|
||||
}
|
||||
// A 方案:挂不到订单行的优惠/冲减也补成行(收入/商品/差额留空),金额不丢。
|
||||
$promoOnlyGroups = $this->promoOnlyGroups(
|
||||
$bills,
|
||||
$groups,
|
||||
$remarkMap,
|
||||
$tabPlatform,
|
||||
$orderColumn,
|
||||
$timeColumn,
|
||||
$amountColumn,
|
||||
$expenseColumn,
|
||||
$realStoreColumn,
|
||||
);
|
||||
$this->info('账单收款笔数:' . $paymentTotal . ',按「店铺 + 订单号」聚合后:' . count($groups) . ' 行');
|
||||
$this->line(' 优惠金额合计:' . number_format($discountTotal, 2) . ',冲减金额合计:' . number_format($offsetTotal, 2) . ',退款金额合计:' . number_format($refundTotal, 2));
|
||||
foreach ($groups as $group) {
|
||||
@ -504,6 +383,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
number_format($unattachedRows) . ' 行',
|
||||
number_format(($discountTotal + $offsetTotal) - ($attachedDiscount + $attachedOffset), 2),
|
||||
));
|
||||
unset($plantingCores);
|
||||
if ((bool) $this->option('amounts-only')) {
|
||||
return $this->backfillPromoAmounts($month, $source, $groups, $promoOnlyGroups, $reportMonth);
|
||||
}
|
||||
@ -539,12 +419,14 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
$mirror->statement('CREATE TEMPORARY TABLE tmp_receipt_order_nos (no varchar(64) NOT NULL, PRIMARY KEY (no)) ENGINE=InnoDB');
|
||||
|
||||
$candidates = array_keys($cores);
|
||||
unset($cores);
|
||||
foreach (array_chunk($candidates, 1000) as $batch) {
|
||||
$mirror->table('tmp_receipt_order_nos')->insertOrIgnore(
|
||||
array_map(static fn (string $no): array => ['no' => $no], $batch)
|
||||
);
|
||||
}
|
||||
$this->info('订单侧候选单号:' . count($candidates));
|
||||
unset($candidates, $batch);
|
||||
|
||||
$orderRows = [];
|
||||
$paidRows = [];
|
||||
@ -642,6 +524,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
foreach ($paid as $row) {
|
||||
$paidRows[] = (array) $row;
|
||||
}
|
||||
unset($result, $paid, $row);
|
||||
}
|
||||
$this->info('订单侧命中(店铺 × 订单)行数:' . count($orderRows) . ',实付行数:' . count($paidRows));
|
||||
|
||||
@ -671,6 +554,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
}
|
||||
|
||||
unset($paidRows, $row);
|
||||
foreach ($orderRows as $row) {
|
||||
$core = self::cleanOrderNo((string) $row['so_id']);
|
||||
if ($core === '') {
|
||||
@ -689,6 +573,8 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
$orderVirtualPaid[$key] = ($orderVirtualPaid[$key] ?? 0.0) + (float) ($row['virtual_paid'] ?? 0.0);
|
||||
}
|
||||
|
||||
unset($orderRows, $row);
|
||||
|
||||
// 只重建「本账期 + 本平台」的数据,避免把同月其它平台的结果一起清掉。
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
if (! $dryRun) {
|
||||
@ -716,6 +602,8 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
$orderPaidNet[$paidKey] = round(max((float) $paidValue - (float) ($orderVirtualPaid[$paidKey] ?? 0.0), 0.0), 2);
|
||||
}
|
||||
|
||||
unset($orderPaid, $orderVirtualPaid);
|
||||
|
||||
// 比对基准(差额 / 匹配状态 / 差额异常备注):paid=订单实付金额,product=订单商品金额(旧口径)。
|
||||
$basis = self::differenceBasis();
|
||||
|
||||
@ -1119,30 +1007,70 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* A 方案:把挂不到订单行的优惠/冲减整理成「补行」。
|
||||
* 独立只读游标只保留当前行;普通镜像连接仍可在备注匹配中查询。
|
||||
* 不更改全局 PDO 状态,结束或异常时关闭专用连接。
|
||||
*
|
||||
* - 有订单号、但该订单没有收款账单(成不了行)→ 一行 = 店铺 + 订单号
|
||||
* - 账单本身没有订单号 → 一行 = 店铺 + 账单项目(按账期汇总)
|
||||
*
|
||||
* @param iterable<int,mixed> $bills
|
||||
* @param array<string,array<string,mixed>> $groups 已有收款行(已按 payment_count 过滤)
|
||||
* @return array<string,array<string,mixed>>
|
||||
* @return \Generator<int,object>
|
||||
*/
|
||||
private function promoOnlyGroups(
|
||||
private function streamBillRows(\Illuminate\Database\Query\Builder $query): \Generator
|
||||
{
|
||||
$connection = $query->getConnection();
|
||||
if (! $connection instanceof \Illuminate\Database\MySqlConnection) {
|
||||
yield from $query->cursor();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$config = $connection->getConfig();
|
||||
$config['options'][\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY] = false;
|
||||
$stream = app('db.factory')->make($config, 'receipt_bill_stream');
|
||||
try {
|
||||
yield from $stream->cursor($query->toSql(), $query->getBindings());
|
||||
} finally {
|
||||
$stream->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按原有金额与补行口径单遍聚合,不保存原始账单集合。
|
||||
*
|
||||
* @param iterable<int,object> $bills
|
||||
* @param array<string,mixed> $source
|
||||
* @param array<string,bool> $plantingCores
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function aggregateBills(
|
||||
iterable $bills,
|
||||
array $groups,
|
||||
AccountLedgerRemarkMap $remarkMap,
|
||||
?string $tabPlatform,
|
||||
array $source,
|
||||
string $orderColumn,
|
||||
string $timeColumn,
|
||||
string $amountColumn,
|
||||
string $expenseColumn,
|
||||
string $realStoreColumn,
|
||||
array $plantingCores,
|
||||
AccountLedgerRemarkMap $remarkMap,
|
||||
?string $tabPlatform,
|
||||
): array {
|
||||
$result = [];
|
||||
/** @var array<string,array<string,mixed>> $groups 账单侧按「店铺 + 订单号」聚合 */
|
||||
$groups = [];
|
||||
$paymentTotal = 0;
|
||||
$incomeTotal = 0.0;
|
||||
$discountTotal = 0.0;
|
||||
$offsetTotal = 0.0;
|
||||
$refundTotal = 0.0;
|
||||
/** @var array<string,int> $promoRowsByKey 门店+订单 维度的优惠/冲减账单行数(用于统计未挂单行数) */
|
||||
$promoRowsByKey = [];
|
||||
// 本月账单里真正命中「优惠收入 / 冲减收入 / 退款」的行数(重建后自检用:
|
||||
// 只有账单里确实有这些行,却一行都没写进结果表,才算异常)。
|
||||
$moneyRemarkRows = 0;
|
||||
$billCount = 0;
|
||||
$promoOnlyGroups = [];
|
||||
foreach ($bills as $bill) {
|
||||
$billCount++;
|
||||
$storeName = (string) ($bill->store_name ?? '');
|
||||
$billItem = (string) ($bill->bill_item ?? '');
|
||||
// 收入 / 优惠 / 冲减 / 退款统一按科目映射表的当前财务备注判桶;
|
||||
// 只有当前科目映射表没有规则时,remark() 才回落到旧总账备注表。
|
||||
$remark = $remarkMap->remark(
|
||||
$storeName,
|
||||
$tabPlatform,
|
||||
@ -1150,31 +1078,41 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
$this->billBusinessRemarks($bill),
|
||||
$this->billGoodsName($bill),
|
||||
);
|
||||
$type = in_array($remark, self::DISCOUNT_REMARKS, true) ? 'discount'
|
||||
: (in_array($remark, self::OFFSET_REMARKS, true) ? 'offset'
|
||||
: (in_array($remark, self::REFUND_REMARKS, true) ? 'refund' : null));
|
||||
if ($type === null) {
|
||||
$isPromoRemark = in_array($remark, self::DISCOUNT_REMARKS, true)
|
||||
|| in_array($remark, self::OFFSET_REMARKS, true);
|
||||
$isRefundRemark = in_array($remark, self::REFUND_REMARKS, true);
|
||||
$isIncomeRemark = in_array($remark, self::LEDGER_REMARKS, true);
|
||||
if ($isPromoRemark || $isRefundRemark) {
|
||||
$moneyRemarkRows++;
|
||||
}
|
||||
$bucket = match (true) {
|
||||
$isIncomeRemark => 'income',
|
||||
$isRefundRemark => 'refund',
|
||||
in_array($remark, self::DISCOUNT_REMARKS, true) => 'discount',
|
||||
in_array($remark, self::OFFSET_REMARKS, true) => 'offset',
|
||||
default => null,
|
||||
};
|
||||
if ($bucket === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rawNo = trim((string) ($bill->{$orderColumn} ?? ''));
|
||||
$core = self::cleanOrderNo($rawNo);
|
||||
$realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : '';
|
||||
$shopKey = self::normalizeShop($realStore !== '' ? $realStore : $storeName);
|
||||
if ($core !== '' && isset($groups[$shopKey . '|' . $core])) {
|
||||
// 该订单已有收款行,优惠/冲减已经挂在那一行上。
|
||||
continue;
|
||||
}
|
||||
$shopKey = self::normalizeShop($realStore !== '' ? $realStore : (string) $bill->store_name);
|
||||
$key = $shopKey . '|' . $core;
|
||||
|
||||
$key = $core === '' ? $shopKey . '||' . $billItem : $shopKey . '|' . $core;
|
||||
if (! isset($result[$key])) {
|
||||
$result[$key] = [
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'platform' => (string) $source['label'],
|
||||
// 展示名优先用「线上真实店铺」(与匹配键同源):账户名(如「支付宝-XX公司」)只是收款壳。
|
||||
'store_name' => $realStore !== '' ? $realStore : (string) ($bill->store_name ?? ''),
|
||||
'shop_key' => $shopKey,
|
||||
'store_name' => $realStore !== '' ? $realStore : $storeName,
|
||||
'online_order_no' => $rawNo,
|
||||
'order_core_no' => $core,
|
||||
'bill_item' => $billItem,
|
||||
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
|
||||
'payment_count' => 0,
|
||||
'income_amount' => 0.0,
|
||||
'discount_amount' => 0.0,
|
||||
'discount_seen' => false,
|
||||
'offset_amount' => 0.0,
|
||||
@ -1184,22 +1122,89 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
];
|
||||
}
|
||||
|
||||
// 与主流程一致:优惠/冲减都按「收入侧 − 支出侧」取数。
|
||||
// 三列统一按「行金额=收入侧 − 支出侧」取数(负数表示净扣减)。
|
||||
// 得物/天猫/淘系这类平台把「卖家承担优惠 / 扣回」记在支出侧,只取收入侧会漏掉扣减。
|
||||
$amount = (float) $bill->{$amountColumn}
|
||||
- ($expenseColumn !== '' ? (float) ($bill->{$expenseColumn} ?? 0) : 0.0);
|
||||
if ($type === 'discount') {
|
||||
$result[$key]['discount_seen'] = true;
|
||||
$result[$key]['discount_amount'] += $amount;
|
||||
} elseif ($type === 'refund') {
|
||||
$result[$key]['refund_seen'] = true;
|
||||
$result[$key]['refund_amount'] += $amount;
|
||||
// 同一遍读取同时累计补行,避免把整月原始账单留到第二遍。
|
||||
// 首笔元数据、无单号按账单项目拆分、收入行随后出现的语义与原有补行一致。
|
||||
if ($bucket !== 'income') {
|
||||
$promoKey = $core === '' ? $shopKey . '||' . $billItem : $key;
|
||||
if (! isset($promoOnlyGroups[$promoKey])) {
|
||||
$promoOnlyGroups[$promoKey] = [
|
||||
'shop_key' => $shopKey,
|
||||
'store_name' => $realStore !== '' ? $realStore : $storeName,
|
||||
'online_order_no' => $rawNo,
|
||||
'order_core_no' => $core,
|
||||
'bill_item' => $billItem,
|
||||
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
|
||||
'discount_amount' => 0.0,
|
||||
'discount_seen' => false,
|
||||
'offset_amount' => 0.0,
|
||||
'offset_seen' => false,
|
||||
'refund_amount' => 0.0,
|
||||
'refund_seen' => false,
|
||||
];
|
||||
}
|
||||
$promoOnlyGroups[$promoKey][$bucket . '_seen'] = true;
|
||||
$promoOnlyGroups[$promoKey][$bucket . '_amount'] += $amount;
|
||||
}
|
||||
if ($bucket === 'income') {
|
||||
$groups[$key]['payment_count']++;
|
||||
$groups[$key]['income_amount'] += $amount;
|
||||
$paymentTotal++;
|
||||
$incomeTotal += $amount;
|
||||
} elseif ($bucket === 'discount') {
|
||||
$groups[$key]['discount_seen'] = true;
|
||||
$groups[$key]['discount_amount'] += $amount;
|
||||
$discountTotal += $amount;
|
||||
$promoRowsByKey[$key] = ($promoRowsByKey[$key] ?? 0) + 1;
|
||||
} elseif ($bucket === 'refund') {
|
||||
$groups[$key]['refund_seen'] = true;
|
||||
$groups[$key]['refund_amount'] += $amount;
|
||||
$refundTotal += $amount;
|
||||
} else {
|
||||
$result[$key]['offset_seen'] = true;
|
||||
$result[$key]['offset_amount'] += $amount;
|
||||
$groups[$key]['offset_seen'] = true;
|
||||
$groups[$key]['offset_amount'] += $amount;
|
||||
$offsetTotal += $amount;
|
||||
$promoRowsByKey[$key] = ($promoRowsByKey[$key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// 种菜金额只标记「这个订单是不是种菜单」,金额仍取该行的收入金额,
|
||||
// 所以在这里按核心单号打个标记,金额等聚合完再取。
|
||||
if (isset($plantingCores[$core])) {
|
||||
$groups[$key]['planting_seen'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
// 行口径保持不变:仍然是「有收款账单(收入白名单)的店铺 + 订单号」,
|
||||
// 只有优惠/冲减、没有收款的订单号不单独成行。
|
||||
$groups = array_filter($groups, static fn (array $group): bool => (int) $group['payment_count'] > 0);
|
||||
|
||||
// 口径对照:账单口径合计、已挂到订单行的合计、挂不上的行数(账单没有订单号 / 订单没有收款行)。
|
||||
$attachedDiscount = 0.0;
|
||||
$attachedOffset = 0.0;
|
||||
foreach ($groups as $group) {
|
||||
$attachedDiscount += (float) $group['discount_amount'];
|
||||
$attachedOffset += (float) $group['offset_amount'];
|
||||
}
|
||||
$unattachedRows = 0;
|
||||
foreach ($promoRowsByKey as $key => $count) {
|
||||
if (! isset($groups[$key])) {
|
||||
$unattachedRows += $count;
|
||||
}
|
||||
}
|
||||
// A 方案:挂不到订单行的优惠/冲减也补成行(收入/商品/差额留空),金额不丢。
|
||||
foreach ($promoOnlyGroups as $key => $group) {
|
||||
if ($group['order_core_no'] !== '' && isset($groups[$group['shop_key'] . '|' . $group['order_core_no']])) {
|
||||
unset($promoOnlyGroups[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return compact(
|
||||
'groups', 'promoOnlyGroups', 'paymentTotal', 'incomeTotal', 'discountTotal',
|
||||
'offsetTotal', 'refundTotal', 'moneyRemarkRows', 'attachedDiscount', 'attachedOffset', 'unattachedRows', 'billCount',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -28,6 +28,15 @@
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
release_includes_commit() {
|
||||
git merge-base --is-ancestor "$1" "$2" 2>/dev/null && return 0
|
||||
# Squash 改变提交身份;仅接受历史中完整文件树一致的提交。
|
||||
local previous_tree release_trees
|
||||
previous_tree="$(git rev-parse "${1}^{tree}" 2>/dev/null)" || return 1
|
||||
release_trees="$(git log --format=%T "$2" 2>/dev/null)" || return 1
|
||||
printf '%s\n' "$release_trees" | grep -Fx -- "$previous_tree" >/dev/null
|
||||
}
|
||||
|
||||
server='218.93.190.75'
|
||||
key_path=''
|
||||
password_file=''
|
||||
@ -522,7 +531,7 @@ if [ -n "$previous_sha" ]; then
|
||||
if [ "$previous_sha_exists" -eq 0 ]; then
|
||||
fail "线上当前版本 ${previous_sha} 在本地和 origin 都找不到(可能是未推送的分支),发布会把它覆盖。请让发布人先推送并合并进 main;确需撤回时显式添加 --allow-rollback。"
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$previous_sha" "$deploy_sha"; then
|
||||
if ! release_includes_commit "$previous_sha" "$deploy_sha"; then
|
||||
fail "线上当前版本($(git log -1 --format='%h %an %ad %s' --date=format:'%m-%d %H:%M' "$previous_sha"))不在本次发布的版本里,继续发布会把它及之后别人上线的改动撤掉。请先 git fetch 并把最新 main(或线上热修复分支)合入当前分支后重试;确需撤回时显式添加 --allow-rollback。"
|
||||
fi
|
||||
fi
|
||||
|
||||
@ -50,6 +50,21 @@ $buildkitConfig = Join-Path $repoRoot 'deploy/buildkitd.toml'
|
||||
$regctlVersion = 'v0.11.5'
|
||||
$regctlSha256 = '9b83975d464e4ee5a9f899a3ead26072ed67e9a8ed0f582897bfad8141b45938'
|
||||
|
||||
function Test-CaiwuReleaseIncludesCommit {
|
||||
param([string]$PreviousSha, [string]$ReleaseSha)
|
||||
|
||||
git merge-base --is-ancestor $PreviousSha $ReleaseSha 2>$null
|
||||
if ($LASTEXITCODE -eq 0) { return $true }
|
||||
|
||||
# Squash 会改变提交身份;只接受发布历史中完整文件树一致的提交。
|
||||
# 不按提交说明、部分文件或 patch 相似度放行。
|
||||
$previousTree = (git rev-parse "${PreviousSha}^{tree}" 2>$null) -join ''
|
||||
if ($LASTEXITCODE -ne 0) { return $false }
|
||||
$releaseTrees = @(git log --format=%T $ReleaseSha 2>$null)
|
||||
if ($LASTEXITCODE -ne 0) { return $false }
|
||||
return $releaseTrees -contains $previousTree
|
||||
}
|
||||
|
||||
function Invoke-Native {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@ -440,8 +455,7 @@ try {
|
||||
if (-not $previousShaExists) {
|
||||
throw "线上当前版本 $previousSha 在本地和 origin 都找不到(可能是未推送的分支),发布会把它覆盖。请让发布人先推送并合并进 main;确需撤回时显式添加 -AllowRollback。服务器未发生任何变更。"
|
||||
}
|
||||
git merge-base --is-ancestor $previousSha $deploySha 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if (-not (Test-CaiwuReleaseIncludesCommit -PreviousSha $previousSha -ReleaseSha $deploySha)) {
|
||||
$previousInfo = (git log -1 --format='%h %an %ad %s' --date=format:'%m-%d %H:%M' $previousSha) -join ''
|
||||
throw "线上当前版本($previousInfo)不在本次发布的版本里,继续发布会把它及之后别人上线的改动撤掉。请先 git fetch 并把最新 main(或线上热修复分支)合入当前分支后重试;确需撤回时显式添加 -AllowRollback。服务器未发生任何变更。"
|
||||
}
|
||||
|
||||
71
scripts/test-release-inclusion.ps1
Normal file
71
scripts/test-release-inclusion.ps1
Normal file
@ -0,0 +1,71 @@
|
||||
param([string]$BashPath='bash')
|
||||
$ErrorActionPreference='Stop'
|
||||
$repoRoot=Split-Path $PSScriptRoot -Parent
|
||||
$psSource=[IO.File]::ReadAllText((Join-Path $repoRoot 'scripts/deploy-from-workstation.ps1'))
|
||||
$parseErrors=$null; $tokens=$null
|
||||
$ast=[Management.Automation.Language.Parser]::ParseInput($psSource,[ref]$tokens,[ref]$parseErrors)
|
||||
if($parseErrors.Count){throw 'Deployment PowerShell syntax invalid'}
|
||||
$function=$ast.Find({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Test-CaiwuReleaseIncludesCommit'},$true)
|
||||
if(-not $function){throw 'PowerShell inclusion guard missing'}
|
||||
. ([ScriptBlock]::Create($function.Extent.Text))
|
||||
$bashSource=[IO.File]::ReadAllText((Join-Path $repoRoot 'scripts/deploy-from-mac.sh')).Replace("`r`n","`n")
|
||||
$bashFunction=[regex]::Match($bashSource,'(?ms)^release_includes_commit\(\) \{\n.*?^\}').Value
|
||||
if(-not $bashFunction){throw 'Bash inclusion guard missing'}
|
||||
|
||||
$fixture=Join-Path ([IO.Path]::GetTempPath()) ('caiwu-release-inclusion-'+[guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $fixture | Out-Null
|
||||
$expectedFixture=[IO.Path]::GetFullPath($fixture)
|
||||
function Invoke-FixtureGit {
|
||||
param([Parameter(ValueFromRemainingArguments=$true)][string[]]$GitArgs)
|
||||
$output=@(git @GitArgs 2>&1)
|
||||
if($LASTEXITCODE -ne 0){throw "Fixture Git failed: $($GitArgs[0])"}
|
||||
return $output
|
||||
}
|
||||
function New-FixtureCommit {
|
||||
param([string]$Value,[string]$Message)
|
||||
[IO.File]::WriteAllText((Join-Path $fixture 'value.txt'),$Value)
|
||||
Invoke-FixtureGit add value.txt | Out-Null
|
||||
Invoke-FixtureGit commit -m $Message | Out-Null
|
||||
return (Invoke-FixtureGit rev-parse HEAD) -join ''
|
||||
}
|
||||
Push-Location $fixture
|
||||
try {
|
||||
Invoke-FixtureGit init -b main | Out-Null
|
||||
Invoke-FixtureGit config user.name 'Release guard fixture' | Out-Null
|
||||
Invoke-FixtureGit config user.email 'release-guard@example.invalid' | Out-Null
|
||||
$base=New-FixtureCommit 'base' 'base'
|
||||
Invoke-FixtureGit switch -c hotfix | Out-Null
|
||||
$hotfix=New-FixtureCommit 'hotfix content' 'hotfix'
|
||||
Invoke-FixtureGit switch main | Out-Null
|
||||
$squashed=New-FixtureCommit 'hotfix content' 'squashed identity'
|
||||
$release=New-FixtureCommit 'next authorized change' 'new release'
|
||||
Invoke-FixtureGit switch -c unrelated $base | Out-Null
|
||||
$unrelated=New-FixtureCommit 'different hotfix content' 'hotfix'
|
||||
$cases=@(
|
||||
@{name='direct ancestor';previous=$base;release=$release;expected=$true},
|
||||
@{name='same commit';previous=$hotfix;release=$hotfix;expected=$true},
|
||||
@{name='identical squash tree at head';previous=$hotfix;release=$squashed;expected=$true},
|
||||
@{name='identical squash tree in release ancestry';previous=$hotfix;release=$release;expected=$true},
|
||||
@{name='real unmerged change';previous=$unrelated;release=$release;expected=$false},
|
||||
@{name='rollback to before hotfix';previous=$hotfix;release=$base;expected=$false},
|
||||
@{name='missing deployed commit';previous=('0'*40);release=$release;expected=$false},
|
||||
@{name='missing release commit';previous=$hotfix;release=('0'*40);expected=$false}
|
||||
)
|
||||
foreach($case in $cases){
|
||||
$actual=Test-CaiwuReleaseIncludesCommit -PreviousSha $case.previous -ReleaseSha $case.release
|
||||
if($actual -ne $case.expected){throw "PowerShell failed: $($case.name)"}
|
||||
$code=$bashFunction+"`nrelease_includes_commit "+'"$1" "$2"'
|
||||
& $BashPath --noprofile --norc -c $code guard $case.previous $case.release
|
||||
$bashActual=$LASTEXITCODE -eq 0
|
||||
if($bashActual -ne $case.expected){throw "Bash failed: $($case.name)"}
|
||||
}
|
||||
"PASS: $($cases.Count) real Git cases for both PowerShell and Bash guards"
|
||||
} finally {
|
||||
Pop-Location
|
||||
$resolved=(Resolve-Path -LiteralPath $fixture).Path
|
||||
if([IO.Path]::GetFullPath($resolved) -ne $expectedFixture -or (Get-Item -LiteralPath $resolved).Attributes.HasFlag([IO.FileAttributes]::ReparsePoint)){
|
||||
throw 'Refusing cleanup outside the exact generated fixture directory'
|
||||
}
|
||||
Remove-Item -LiteralPath $resolved -Recurse -Force
|
||||
}
|
||||
exit 0
|
||||
177
tests/Unit/EcommerceFinance/StoreReceiptMemoryTest.php
Normal file
177
tests/Unit/EcommerceFinance/StoreReceiptMemoryTest.php
Normal file
@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use App\Console\Commands\SyncStoreReceiptOrderMatches;
|
||||
use Illuminate\Database\Connectors\ConnectionFactory;
|
||||
use Illuminate\Database\MySqlConnection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
class StoreReceiptMemoryTest extends TestCase
|
||||
{
|
||||
private function bill(string $item, string $order, float $income, float $expense = 0, array $extra = []): object
|
||||
{
|
||||
return (object) array_merge([
|
||||
'store_name' => '支付宝-测试账户', 'online_real_store' => '天猫_测试店',
|
||||
'bill_item' => $item, 'online_order_no' => $order,
|
||||
'occurred_at' => '2026-08-01 10:00:00', 'income_amount' => $income, 'expense_amount' => $expense,
|
||||
], $extra);
|
||||
}
|
||||
|
||||
private function aggregate(iterable $rows, array $planting = [], ?AccountLedgerRemarkMap $map = null): array
|
||||
{
|
||||
$map ??= new class extends AccountLedgerRemarkMap {
|
||||
public function remark(?string $storeName, ?string $tabPlatform, ?string $billItem, array $businessRemarks = [], string $goodsName = ''): ?string
|
||||
{
|
||||
return $billItem;
|
||||
}
|
||||
};
|
||||
|
||||
return (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'aggregateBills'))->invoke(
|
||||
new SyncStoreReceiptOrderMatches(), $rows, ['label' => '淘系支付宝'], 'online_order_no', 'occurred_at',
|
||||
'income_amount', 'expense_amount', 'online_real_store', $planting, $map, '淘宝',
|
||||
);
|
||||
}
|
||||
|
||||
public function test_one_pass_keeps_payments_promotions_refunds_and_first_bill_metadata(): void
|
||||
{
|
||||
$bills = [
|
||||
$this->bill('优惠收入', '260630-10001', 0, 3),
|
||||
$this->bill('订单收入', '10001', 100, 0, ['occurred_at' => '2026-08-02 10:00:00']),
|
||||
$this->bill('订单收入', '10001', 25),
|
||||
$this->bill('冲减收入', '10001', 0, 2),
|
||||
$this->bill('退款', '10001', 0, 7),
|
||||
$this->bill('优惠收入', '20002', 4),
|
||||
$this->bill('退款', '20002', 0, 1),
|
||||
$this->bill('冲减收入', '', 0, 8),
|
||||
$this->bill('冲减收入', '', 3),
|
||||
$this->bill('退款', '', 0, 2),
|
||||
$this->bill('订单收入', '30003', 0),
|
||||
$this->bill('不计入', '40004', 999),
|
||||
];
|
||||
// A generator cannot be rewound. Promotions precede their eventual payment.
|
||||
$result = $this->aggregate((function () use ($bills) { yield from $bills; })(), ['10001' => true]);
|
||||
$key = SyncStoreReceiptOrderMatches::normalizeShop('天猫_测试店');
|
||||
$this->assertCount(2, $result['groups']);
|
||||
$paid = $result['groups'][$key . '|10001'];
|
||||
$this->assertSame(2, $paid['payment_count']);
|
||||
$this->assertSame(125.0, $paid['income_amount']);
|
||||
$this->assertSame(-3.0, $paid['discount_amount']);
|
||||
$this->assertSame(-2.0, $paid['offset_amount']);
|
||||
$this->assertSame(-7.0, $paid['refund_amount']);
|
||||
$this->assertTrue($paid['planting_seen']);
|
||||
$this->assertSame('260630-10001', $paid['online_order_no']);
|
||||
$this->assertSame('2026-08-01', $paid['occurred_date']);
|
||||
$this->assertSame('天猫_测试店', $paid['store_name']);
|
||||
$this->assertSame(0.0, $result['groups'][$key . '|30003']['income_amount']);
|
||||
$this->assertSame(3, $result['paymentTotal']);
|
||||
$this->assertSame(12, $result['billCount']);
|
||||
$this->assertSame(8, $result['moneyRemarkRows']);
|
||||
$this->assertSame(125.0, $result['incomeTotal']);
|
||||
$this->assertSame(1.0, $result['discountTotal']);
|
||||
$this->assertSame(-7.0, $result['offsetTotal']);
|
||||
$this->assertSame(-10.0, $result['refundTotal']);
|
||||
$this->assertSame(-3.0, $result['attachedDiscount']);
|
||||
$this->assertSame(-2.0, $result['attachedOffset']);
|
||||
$this->assertSame(3, $result['unattachedRows']);
|
||||
$this->assertSame([$key . '|20002', $key . '||冲减收入', $key . '||退款'], array_keys($result['promoOnlyGroups']));
|
||||
$this->assertSame(4.0, $result['promoOnlyGroups'][$key . '|20002']['discount_amount']);
|
||||
$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']);
|
||||
}
|
||||
|
||||
public function test_empty_order_promotions_remain_separate_even_with_an_empty_order_payment(): void
|
||||
{
|
||||
$result = $this->aggregate([
|
||||
$this->bill('订单收入', '', 12),
|
||||
$this->bill('优惠收入', '', 2, 0, ['occurred_at' => '2026-08-03 10:00:00']),
|
||||
$this->bill('优惠收入', '50005', 3),
|
||||
$this->bill('优惠收入', '50005', 4, 0, ['online_real_store' => '另一家店']),
|
||||
]);
|
||||
$this->assertCount(1, $result['groups']);
|
||||
$this->assertCount(3, $result['promoOnlyGroups']);
|
||||
$first = reset($result['promoOnlyGroups']);
|
||||
$this->assertSame('2026-08-03', $first['occurred_date']);
|
||||
$this->assertSame(2.0, $first['discount_amount']);
|
||||
}
|
||||
|
||||
public function test_business_remark_and_goods_name_are_forwarded_once_per_bill(): void
|
||||
{
|
||||
$map = $this->createMock(AccountLedgerRemarkMap::class);
|
||||
$map->expects($this->once())->method('remark')->with(
|
||||
'支付宝-测试账户', '淘宝', '动态项目', ['活动优惠'], '商品关键词',
|
||||
)->willReturn('优惠收入');
|
||||
$result = $this->aggregate([$this->bill('动态项目', '', 1.5, 0, [
|
||||
'custom_remark' => '活动优惠', 'second_remark' => '活动优惠', 'goods_name' => '商品关键词',
|
||||
])], [], $map);
|
||||
$this->assertSame(1.5, $result['discountTotal']);
|
||||
$this->assertSame([], $result['groups']);
|
||||
$this->assertCount(1, $result['promoOnlyGroups']);
|
||||
$this->assertSame(0, $this->aggregate([])['billCount']);
|
||||
}
|
||||
|
||||
public function test_stream_uses_bound_filter_and_sql_limit_in_sqlite(): void
|
||||
{
|
||||
$db = DB::connection('business_mirror');
|
||||
$db->getSchemaBuilder()->create('receipt_memory_test', function (\Illuminate\Database\Schema\Blueprint $table): void {
|
||||
$table->integer('id');
|
||||
$table->string('name');
|
||||
});
|
||||
$db->table('receipt_memory_test')->insert([['id' => 1, 'name' => 'a'], ['id' => 2, 'name' => 'b'], ['id' => 3, 'name' => 'c']]);
|
||||
$query = $db->table('receipt_memory_test')->where('id', '>', 1)->orderBy('id')->limit(1);
|
||||
$rows = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'streamBillRows'))->invoke(new SyncStoreReceiptOrderMatches(), $query);
|
||||
$this->assertSame([2], array_map(static fn (object $row) => $row->id, iterator_to_array($rows)));
|
||||
}
|
||||
|
||||
public function test_other_source_columns_and_missing_optional_columns_keep_their_amounts(): void
|
||||
{
|
||||
$map = $this->createMock(AccountLedgerRemarkMap::class);
|
||||
$map->expects($this->once())->method('remark')->with('京东-测试店', '京东', '货款(收)', [], '')->willReturn('订单收入');
|
||||
$result = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'aggregateBills'))->invoke(
|
||||
new SyncStoreReceiptOrderMatches(), [(object) ['store_name' => '京东-测试店', 'order_no' => '70007',
|
||||
'bill_date' => '2026-08-09', 'income' => 12.5, 'bill_item' => '货款(收)']],
|
||||
['label' => '京东'], 'order_no', 'bill_date', 'income', '', '', [], $map, '京东',
|
||||
);
|
||||
$group = reset($result['groups']);
|
||||
$this->assertSame(12.5, $result['incomeTotal']);
|
||||
$this->assertSame('2026-08-09', $group['occurred_date']);
|
||||
$this->assertSame('京东-测试店', $group['store_name']);
|
||||
$this->assertSame([], $result['promoOnlyGroups']);
|
||||
}
|
||||
|
||||
public function test_mysql_stream_uses_separate_connection_and_closes_it_after_consumer_error(): void
|
||||
{
|
||||
$normal = $this->createMock(MySqlConnection::class);
|
||||
$normal->method('getConfig')->willReturn(['driver' => 'mysql', 'options' => [\PDO::ATTR_TIMEOUT => 3]]);
|
||||
$query = new \Illuminate\Database\Query\Builder($normal,
|
||||
new \Illuminate\Database\Query\Grammars\MySqlGrammar(), new \Illuminate\Database\Query\Processors\MySqlProcessor());
|
||||
$query->from('receipt_memory_test')->where('id', '>', 1)->limit(1);
|
||||
$stream = $this->createMock(MySqlConnection::class);
|
||||
$stream->expects($this->once())->method('cursor')->with($query->toSql(), [1])->willReturnCallback(function () {
|
||||
yield (object) ['id' => 2];
|
||||
});
|
||||
$stream->expects($this->once())->method('disconnect');
|
||||
$normal->expects($this->never())->method('setReadPdo');
|
||||
$normal->expects($this->never())->method('disconnect');
|
||||
$factory = $this->createMock(ConnectionFactory::class);
|
||||
$factory->expects($this->once())->method('make')->with($this->callback(function (array $config): bool {
|
||||
return $config['options'][\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY] === false && $config['options'][\PDO::ATTR_TIMEOUT] === 3;
|
||||
}), 'receipt_bill_stream')->willReturn($stream);
|
||||
$this->app->instance('db.factory', $factory);
|
||||
$rows = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'streamBillRows'))->invoke(new SyncStoreReceiptOrderMatches(), $query);
|
||||
try {
|
||||
foreach ($rows as $row) {
|
||||
$this->assertSame(2, $row->id);
|
||||
throw new \RuntimeException('consumer aborted');
|
||||
}
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->assertSame('consumer aborted', $exception->getMessage());
|
||||
} finally {
|
||||
unset($rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user