From bbb967e11036378f419692008d2f2ad973518f5d Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Mon, 19 Oct 2026 19:08:39 +0800 Subject: [PATCH] =?UTF-8?q?=E9=99=8D=E4=BD=8E=E4=B8=9A=E7=BB=A9=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E4=BB=BB=E5=8A=A1=E5=B3=B0=E5=80=BC=E5=86=85=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/SyncStoreReceiptOrderMatches.php | 355 +++++++++--------- scripts/deploy-from-mac.sh | 11 +- scripts/deploy-from-workstation.ps1 | 18 +- scripts/test-release-inclusion.ps1 | 71 ++++ .../StoreReceiptMemoryTest.php | 177 +++++++++ 5 files changed, 454 insertions(+), 178 deletions(-) create mode 100644 scripts/test-release-inclusion.ps1 create mode 100644 tests/Unit/EcommerceFinance/StoreReceiptMemoryTest.php diff --git a/app/Console/Commands/SyncStoreReceiptOrderMatches.php b/app/Console/Commands/SyncStoreReceiptOrderMatches.php index 9c67124e..c5301ddc 100644 --- a/app/Console/Commands/SyncStoreReceiptOrderMatches.php +++ b/app/Console/Commands/SyncStoreReceiptOrderMatches.php @@ -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> $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 $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 $bills - * @param array> $groups 已有收款行(已按 payment_count 过滤) - * @return array> + * @return \Generator */ - 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 $bills + * @param array $source + * @param array $plantingCores + * @return array + */ + 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> $groups 账单侧按「店铺 + 订单号」聚合 */ + $groups = []; + $paymentTotal = 0; + $incomeTotal = 0.0; + $discountTotal = 0.0; + $offsetTotal = 0.0; + $refundTotal = 0.0; + /** @var array $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', + ); } /** diff --git a/scripts/deploy-from-mac.sh b/scripts/deploy-from-mac.sh index 9d9c37bb..ec5d0180 100755 --- a/scripts/deploy-from-mac.sh +++ b/scripts/deploy-from-mac.sh @@ -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 diff --git a/scripts/deploy-from-workstation.ps1 b/scripts/deploy-from-workstation.ps1 index 83682387..7bb70aeb 100644 --- a/scripts/deploy-from-workstation.ps1 +++ b/scripts/deploy-from-workstation.ps1 @@ -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。服务器未发生任何变更。" } diff --git a/scripts/test-release-inclusion.ps1 b/scripts/test-release-inclusion.ps1 new file mode 100644 index 00000000..637345c9 --- /dev/null +++ b/scripts/test-release-inclusion.ps1 @@ -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 diff --git a/tests/Unit/EcommerceFinance/StoreReceiptMemoryTest.php b/tests/Unit/EcommerceFinance/StoreReceiptMemoryTest.php new file mode 100644 index 00000000..467c7c49 --- /dev/null +++ b/tests/Unit/EcommerceFinance/StoreReceiptMemoryTest.php @@ -0,0 +1,177 @@ + '支付宝-测试账户', '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); + } + } +}