降低业绩匹配任务峰值内存

This commit is contained in:
WuYongJie 2026-10-19 19:08:39 +08:00
parent a6e0b2ac7d
commit bbb967e110
5 changed files with 454 additions and 178 deletions

View File

@ -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',
);
}
/**

View File

@ -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

View File

@ -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。服务器未发生任何变更。"
}

View 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

View 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);
}
}
}