Compare commits

...

15 Commits

Author SHA1 Message Date
5633ed2430 安全整合存量报价计费规则与独立成本价观测 (#410) 2026-10-20 19:08:47 +08:00
d68548646d 修复多人发布并发覆盖及旧客户端绕过保护 (#409) 2026-10-20 18:13:01 +08:00
chenxi
0ec6f703fe 商品成本:移除两个筛选复选框,默认全部数据展示
- 去掉「只看未匹配商品单价」「包含-不看仓」两个复选框
- 页面与导出默认展示全部行:已匹配/未匹配单价、含「-不看」仓全部纳入
- 汇总卡片按全部口径统计,移除「已隐藏的-不看仓行数」
2026-10-20 17:46:34 +08:00
a317ab9dea 供应商对账:比对汇总改按我方商品编码匹配 (#408)
对照我方货款对账的商品编码匹配厂家条码,页面、CSV导出和钉钉通知统一切换。
2026-10-20 17:45:55 +08:00
db08b2c264 供应商对账:新增财务比对汇总导出 (#407)
财务端比对明细新增CSV导出,包含筛选汇总和完整比对结果。
2026-10-20 17:29:46 +08:00
chenxi
32cd0d0a15 商品成本:常州简尚家居有限公司的仓库简称对应映射表「配件单仓」行
业务确认:仓储方=常州简尚家居有限公司 / 仓库=配件单仓 → 简称=配件单仓-不看。
仓库简称匹配增加这一档:主仓行 → 同名仓库 → 配件单仓行(仅在没有前两者时使用)。
2026-10-20 17:15:49 +08:00
8162afa969 供应商对账:财务比对筛选与分页优化 (#406)
财务端增加供应商精确搜索、比对明细吸顶表头和每页15条分页。
2026-10-20 17:03:27 +08:00
chenxi
bcb29aac1c 商品成本:仓库简称只认映射表里「仓储方 + 主仓」那条
发货仓库是聚水潭仓储方级别的名称,映射表里同一仓储方下还有次品仓/销退仓/配件单仓等行,
之前无主仓行时会兜底取到这些行的简称(如常州简尚家居有限公司→退货报废-不看),
把主仓发货误判成不看仓。改为:仓储方匹配仓库名含「主仓」的行的简称;
没有主仓行时只接受「仓库名=仓储方名」的同名仓库,不再兜底次品/销退行。
2026-10-20 15:19:32 +08:00
d1b751c79c 业绩明细:补齐拼多多退款支付立减备注并延长联动缓存 2026-10-20 15:14:55 +08:00
chenxi
fce31f0c09 商品成本:种菜金额有数据的账单行整条排除
账单侧按业绩明细取数时,除「收入金额有数据且不为 0」外,再排除 planting_amount > 0 的账单行
(种菜订单整条不参与商品成本匹配),页面口径说明同步补充。
2026-10-20 14:12:19 +08:00
acad1f4577 修复业绩明细日期前缀串单并增加防碰撞匹配 2026-10-20 14:01:26 +08:00
ad8da1264d 供应商对账:按采购单号日期条码核对数量金额 (#405)
厂家账单改为按采购单号+日期+条码匹配,逐项比较双方数量、单价和金额,页面与钉钉通知明确展示厂家多算或少算。
2026-10-20 13:44:00 +08:00
6470346392 修复账单覆盖校验日期边界 2026-10-20 13:26:20 +08:00
f08c600961 合并京东钱包每日账单同步修复 2026-10-20 13:19:37 +08:00
chenxi
5fe92bd05c 商品成本:订单侧不取套餐/服务占位编码 TCXJ、FUWU
订单原始数据里的 TCXJ(套餐)、FUWU(服务)不是实物商品,商品成本同步时直接剔除
(SQL 层过滤 + 组合装拆出的组件里同样剔除),页面口径说明同步补充。
2026-10-20 11:53:03 +08:00
44 changed files with 2792 additions and 234 deletions

View File

@ -0,0 +1,280 @@
<?php
namespace App\Console\Commands;
use App\Models\Business\CourierQuoteVersion;
use App\Models\Common\Company;
use App\Support\CourierQuoteStructure;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* 把整理好的结构化计费规则(标准计费规则_导入用.json)写入现有报价版本,供运费核算使用。
* 只写 billing_structure / billing_rows 两列,原始报价单元格、执行日期、状态都不动。
*/
class ImportCourierQuoteBillingRules extends Command
{
protected $signature = 'courier-quotes:import-billing-rules {file : 标准计费规则 JSON}
{--company= : 报价所属公司ID}
{--dry-run : 只校验并输出结果,不写入}
{--force : 覆盖已有且内容不同的计费规则}';
protected $description = '把存量报价的结构化计费规则批量写入报价版本(按版本号匹配,整批事务)';
private const MODES = ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then', CourierQuoteStructure::SEGMENTS];
public function handle(): int
{
$companyId = (int) $this->option('company');
if ($companyId <= 0 || ! Company::query()->where('id', $companyId)->exists()) {
$this->error('请指定有效的 --company。');
return self::FAILURE;
}
$payload = json_decode((string) @file_get_contents((string) $this->argument('file')), true);
$items = is_array($payload) ? ($payload['versions'] ?? null) : null;
if (! is_array($items) || $items === []) {
$this->error('文件不是有效的计费规则 JSON(缺少 versions)。');
return self::FAILURE;
}
$plan = [];
$report = [];
$seen = [];
foreach ($items as $item) {
if (! is_array($item)) {
$report[] = ['', '', '错误', '版本条目必须是对象'];
continue;
}
$code = (string) ($item['version_id'] ?? '');
if (isset($seen[$code])) {
$report[] = [$code, '', '错误', '同一文件中的版本号不能重复'];
continue;
}
$seen[$code] = true;
[$status, $note, $data] = $this->prepare($companyId, $item);
$report[] = [$code, (string) ($item['company_name'] ?? ''), $status, $note];
if ($data !== null) {
$plan[] = $data;
}
}
$this->table(['版本', '快递公司', '结果', '说明'], $report);
$counts = array_count_values(array_column($report, 2));
$summary = collect($counts)->map(fn ($n, $k) => "{$k} {$n}")->implode(',');
if (isset($counts['错误'])) {
$this->error('存在错误,整批未写入:' . $summary);
return self::FAILURE;
}
if ($this->option('dry-run')) {
$this->info('校验通过,未写入(dry-run):' . $summary);
return self::SUCCESS;
}
try {
DB::transaction(function () use ($plan): void {
foreach ($plan as [$version, $structure, $rows]) {
$current = DB::table($version->getTable())->where('id', $version->id)->lockForUpdate()->first();
if (! $current || $current->company_name !== $version->company_name
|| json_decode($current->billing_structure ?? 'null', true) != $version->billing_structure
|| json_decode($current->billing_rows ?? 'null', true) != $version->billing_rows) {
throw new RuntimeException('报价在校验后被其他操作修改,整批未写入,请重新校验。');
}
$version->forceFill(['billing_structure' => $structure, 'billing_rows' => $rows])->save();
}
});
} catch (RuntimeException $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info('导入完成:' . $summary);
return self::SUCCESS;
}
/**
* @param array<string,mixed> $item
* @return array{0:string,1:string,2:?array}
*/
private function prepare(int $companyId, array $item): array
{
$code = (string) ($item['version_id'] ?? '');
$version = CourierQuoteVersion::query()->allCompanies()->where('company_id', $companyId)->where('version_code', $code)->first();
if ($version === null) {
return ['错误', '系统中找不到该版本号', null];
}
if (trim((string) $version->company_name) !== trim((string) ($item['company_name'] ?? ''))) {
return ['错误', '快递名称不一致:系统为「' . $version->company_name . '」', null];
}
if (($item['conversion'] ?? '') === 'manual') {
return ['跳过', '特殊计费(' . ($item['manual_reason'] ?? '按体积/条码') . '),需人工核算', null];
}
if (($error = $this->inputError($item)) !== null) {
return ['错误', $error, null];
}
$source = (array) ($item['structure'] ?? []);
$segments = ($item['conversion'] ?? '') === 'needs_segment_mode';
$mode = $segments ? CourierQuoteStructure::SEGMENTS : (string) ($source['mode'] ?? '');
if (! in_array($mode, self::MODES, true)) {
return ['错误', '不支持的计费方式:' . $mode, null];
}
$params = (array) ($item['params'] ?? []);
$perTicket = 0.0;
foreach ((array) ($params['surcharges'] ?? []) as $surcharge) {
if (($surcharge['condition'] ?? '') === '每票' && is_numeric($surcharge['amount'] ?? null)) {
$perTicket += (float) $surcharge['amount'];
}
}
$structure = [
'mode' => $mode,
'flat_bands' => $segments ? [] : $this->numbers($source['flat_bands'] ?? []),
'then_mode' => $segments ? '' : (string) ($source['then_mode'] ?? ''),
'first_weight' => ! $segments && is_numeric($source['first_weight'] ?? null) ? (float) $source['first_weight'] : null,
'rate_tiers' => $segments ? [] : $this->numbers($source['rate_tiers'] ?? []),
'rounding' => in_array($source['rounding'] ?? 'none', array_keys(CourierQuoteStructure::ROUNDING), true) ? (string) ($source['rounding'] ?? 'none') : 'none',
'volumetric_divisor' => is_numeric($params['volumetric_divisor'] ?? null) ? (int) $params['volumetric_divisor'] : null,
'weight_limit' => is_numeric($params['weight_limit_kg'] ?? null) ? (float) $params['weight_limit_kg'] : null,
'min_weight' => is_numeric($source['min_weight'] ?? $params['min_weight_kg'] ?? null) ? (float) ($source['min_weight'] ?? $params['min_weight_kg']) : null,
'per_ticket_surcharge' => $perTicket > 0 ? $perTicket : null,
'notes' => array_values(array_map('strval', (array) ($params['notes'] ?? []))),
'imported_from' => 'stock-quote-billing-import',
];
$rows = [];
foreach ((array) ($segments ? ($item['segments_rows'] ?? []) : ($item['rows'] ?? [])) as $row) {
$region = trim((string) ($row['region'] ?? ''));
if ($region === '') {
return ['错误', '存在没有地区名的行', null];
}
if ($segments) {
$list = [];
foreach ((array) ($row['segments'] ?? []) as $s) {
$list[] = array_filter([
'lo' => (float) ($s['lo'] ?? 0),
'hi' => is_numeric($s['hi'] ?? null) ? (float) $s['hi'] : null,
'type' => (string) ($s['type'] ?? ''),
'price' => $this->number($s['price'] ?? null),
'base' => $this->number($s['base'] ?? null),
'rate' => $this->number($s['rate'] ?? null),
'fw' => $this->number($s['fw'] ?? null),
], static fn ($v, $k) => $v !== null || $k === 'hi', ARRAY_FILTER_USE_BOTH);
}
$rows[] = ['region' => $region, 'segments' => $list];
} else {
$rows[] = [
'region' => $region,
'flat' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['flat'] ?? []))),
'base' => $this->number($row['base'] ?? null),
'rates' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['rates'] ?? []))),
];
}
}
if ($rows === []) {
return ['错误', '没有任何地区价格', null];
}
$existing = $version->billing_structure;
$note = CourierQuoteStructure::describe($structure) . ',' . count($rows) . ' 个地区';
if (is_array($existing) && $existing !== []) {
if ($existing == $structure && $version->billing_rows == $rows) {
return ['跳过', '已导入且内容相同', null];
}
if (! $this->option('force')) {
return ['错误', '已有不同的计费规则,如需覆盖请加 --force', null];
}
return ['覆盖', $note, [$version, $structure, $rows]];
}
return ['写入', $note, [$version, $structure, $rows]];
}
private function number(mixed $value): ?float
{
return is_numeric($value) ? round((float) $value, 4) : null;
}
/** 金额导入拒绝负数、歧义分段和不支持的附加条件,不静默改写计费含义。 */
private function inputError(array $item): ?string
{
$structure = $item['structure'] ?? null;
$params = $item['params'] ?? [];
if (! is_array($structure) || ! is_array($params)) {
return 'structure 和 params 必须是对象';
}
if (! array_key_exists($structure['rounding'] ?? 'none', CourierQuoteStructure::ROUNDING)) {
return '不支持的重量取整方式';
}
$valid = static fn ($v): bool => is_numeric($v) && is_finite((float) $v) && (float) $v >= 0;
foreach ([$params['volumetric_divisor'] ?? null, $params['weight_limit_kg'] ?? null,
$structure['min_weight'] ?? $params['min_weight_kg'] ?? null, $structure['first_weight'] ?? null] as $v) {
if ($v !== null && ! $valid($v)) return '重量、体积系数必须是非负有限数字';
}
if (isset($params['volumetric_divisor']) && (float) $params['volumetric_divisor'] <= 0) return '体积系数必须大于0';
$minimum = (float) ($structure['min_weight'] ?? $params['min_weight_kg'] ?? 0);
if (($params['weight_limit_kg'] ?? 0) > 0 && $minimum > $params['weight_limit_kg']) return '起算重量不能大于限重';
foreach (['flat_bands', 'rate_tiers'] as $field) {
$previous = 0;
if (! is_array($structure[$field] ?? [])) return '重量分段必须是数组';
foreach ($structure[$field] ?? [] as $edge) {
if (! $valid($edge) || (float) $edge <= $previous) return '重量节点必须为严格递增的正数';
$previous = (float) $edge;
}
}
$mode = ($item['conversion'] ?? '') === 'needs_segment_mode' ? CourierQuoteStructure::SEGMENTS : ($structure['mode'] ?? '');
if (in_array($mode, ['flat_bands', 'flat_then'], true) && empty($structure['flat_bands'])) return '缺少一口价重量节点';
if ($mode === 'flat_then' && ! in_array($structure['then_mode'] ?? '', ['waybill_rate', 'first_additional'], true)) return '不支持的超重计费方式';
if (CourierQuoteStructure::usesFirstWeight($mode, $structure['then_mode'] ?? '') && ! (($structure['first_weight'] ?? 0) > 0)) return '首重必须大于0';
if ($mode === 'flat_then' && ! empty($structure['rate_tiers']) && min($structure['rate_tiers']) <= max($structure['flat_bands'])) return '超重费率节点必须大于一口价最后节点';
if (! is_array($params['surcharges'] ?? [])) return '附加费必须是数组';
foreach ($params['surcharges'] ?? [] as $fee) {
if (! is_array($fee) || ($fee['condition'] ?? '') !== '每票' || ! $valid($fee['amount'] ?? null)) return '附加费条件不支持自动核算,需人工核对';
}
$rows = $item[$mode === CourierQuoteStructure::SEGMENTS ? 'segments_rows' : 'rows'] ?? null;
if (! is_array($rows) || $rows === []) return '没有任何地区价格';
$regions = [];
foreach ($rows as $row) {
if (! is_array($row) || ! is_string($row['region'] ?? null) || trim($row['region']) === '') return '地区行格式不正确';
$region = mb_strtolower(preg_replace('/\s+/u', '', $row['region']));
if (isset($regions[$region])) return '存在重复地区行';
$regions[$region] = true;
if ($mode === CourierQuoteStructure::SEGMENTS) {
if (! is_array($row['segments'] ?? null) || $row['segments'] === []) return '缺少地区计费分段';
$previous = 0.0;
foreach ($row['segments'] as $segment) {
if (! is_array($segment) || ! $valid($segment['lo'] ?? null)
|| (float) $segment['lo'] < $previous || ! in_array($segment['type'] ?? '', ['flat', 'waybill', 'first_add'], true)) return '分段类型不支持或存在重叠';
$hi = $segment['hi'] ?? null;
if ($hi !== null && (! $valid($hi) || (float) $hi <= (float) $segment['lo'])) return '分段上限必须大于下限';
$previous = $hi === null ? INF : (float) $hi;
foreach (['price', 'base', 'rate', 'fw'] as $key) {
if (isset($segment[$key]) && ! $valid($segment[$key])) return '分段金额和重量必须是非负有限数字';
}
if ($segment['type'] === 'first_add' && ! $valid($segment['fw'] ?? null)) return '分段首重缺失';
}
} else {
if (! is_array($row['flat'] ?? []) || ! is_array($row['rates'] ?? [])) return '价格必须是数组';
if (count($row['flat'] ?? []) !== count($structure['flat_bands'] ?? [])
|| (CourierQuoteStructure::usesRates($mode) && count($row['rates'] ?? []) !== count($structure['rate_tiers'] ?? []) + 1)) return '价格列数与重量节点不匹配';
foreach (array_merge($row['flat'] ?? [], $row['rates'] ?? [], [$row['base'] ?? null]) as $v) {
if ($v !== null && ! $valid($v)) return '价格必须是非负有限数字或null(不发)';
}
}
}
return null;
}
/** @return list<float> */
private function numbers(mixed $values): array
{
return array_values(array_map('floatval', array_filter((array) $values, 'is_numeric')));
}
}

View File

@ -6,12 +6,14 @@ use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
use Modules\EcommerceFinance\Services\JushuitanCostPriceHistorySynchronizer;
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer;
use Throwable;
class SyncJushuitanCostPriceHistory extends Command
{
protected $signature = 'business-mirror:sync-jushuitan-cost-price-history
{--full : 强制启动或继续首次全量同步}
{--source=open_api : open_api 为正式操作历史;snapshot 为独立的镜像价格观测}
{--page-size= : 聚水潭接口每页读取条数,最大 50}
{--max-pages= : 本轮最多读取页数,0 表示直到完成}';
@ -25,6 +27,25 @@ class SyncJushuitanCostPriceHistory extends Command
return self::SUCCESS;
}
if ($this->option('source') === 'snapshot') {
try {
$result = app(JushuitanCostPriceSnapshotDiffer::class)->sync(
$this->option('page-size') === null ? 500 : (int) $this->option('page-size'),
$this->option('max-pages') === null ? 20 : (int) $this->option('max-pages'),
);
$this->info(sprintf('镜像价格观测:%s,本轮读取 %d 条,发现 %d 次价格变化;不代表完整操作历史。', $result['status'], $result['rows'], $result['changes']));
return self::SUCCESS;
} catch (Throwable $exception) {
Log::error('镜像价格观测失败。', ['exception' => $exception::class]);
$this->error('镜像价格观测失败,已保留基线、记录和续传游标。');
return self::FAILURE;
}
}
if ($this->option('source') !== 'open_api') {
$this->error('source 仅支持 open_api 或 snapshot。');
return self::FAILURE;
}
try {
$state = $mirror->state();
$full = $this->shouldRunFull($state);

View File

@ -23,9 +23,9 @@ use Throwable;
*
* 口径(已与业务确认):
* - 账单侧:按「总账备注」筛选(与店铺收款账户账单页同一口径),
* 再按「店铺 + 清洗后订单号」汇总收入金额与收款笔数
* 再按「店铺 + 完整线上单号」汇总收入金额与收款笔数
* - 订单侧:销售全量按「店铺 + 线上订单号」先聚合求和(一单多品/多子订单在此合并),金额取 goods_count > 0 的有效行
* - 单号清洗:去除日期前缀(如 260630-)、空格、横杠后按核心号匹配
* - 单号匹配:优先完整线上单号精确匹配;仅当核心号唯一时允许兼容回退,多个完整单号共用核心号时禁止合并
* - 店铺:账单用「线上真实店铺」优先,订单侧用销售全量店铺名,统一把下划线归一为横杠
* - 同一店铺同一订单号多笔收款 → 标注「同单多笔收款」,商品金额只挂一次
*/
@ -448,15 +448,17 @@ class SyncStoreReceiptOrderMatches extends Command
}
}
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(商品金额求和) */
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 完整线上单号」聚合的商品金额(商品金额求和) */
$orderAmount = [];
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 核心单号」(含 0 元行) */
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 完整线上单号」(含 0 元行) */
$orderSeen = [];
/** @var array<string,array<string,true>> $coresByCore 核心单号 → 出现过的店铺集合 */
$coresByCore = [];
/** @var array<string,array<string,array<string,true>>> $identitiesByCore 核心单号 → 店铺 → 完整线上单号 */
$identitiesByCore = [];
/** @var array<string,true> $coreSeen 订单侧出现过的核心单号(含店铺名为空的行) */
$coreSeen = [];
/** @var array<string,float> $orderVirtualPaid 虚拟条码(X0000x)明细行的实付金额合计(店铺 + 核心单号) */
/** @var array<string,float> $orderVirtualPaid 虚拟条码(X0000x)明细行的实付金额合计(店铺 + 完整线上单号) */
$orderVirtualPaid = [];
// 订单侧改用「订单原始数据」(企微客户订单 + 聚水潭订单),按线上订单号取商品金额合计。
// 两个来源数据量大(企微 500 万单 / 690 万行),先用临时表收敛候选单号,
@ -575,19 +577,21 @@ class SyncStoreReceiptOrderMatches extends Command
}
$this->info('订单侧命中(店铺 × 订单)行数:' . count($orderRows) . ',实付行数:' . count($paidRows));
/** @var array<string,float> $orderPaid 订单侧实付金额(店铺 + 核心单号) */
/** @var array<string,float> $orderPaid 订单侧实付金额(店铺 + 完整线上单号) */
$orderPaid = [];
/** @var array<string,float> $orderPaidAlt 订单侧「卖家实收」金额(双口径兜底用) */
$orderPaidAlt = [];
/** @var array<string,float> $orderPaidPay 订单侧「应付」金额(实收缺失时的退化口径) */
$orderPaidPay = [];
foreach ($paidRows as $row) {
$core = self::cleanOrderNo((string) $row['so_id']);
$rawNo = (string) $row['so_id'];
$core = self::cleanOrderNo($rawNo);
$identity = self::orderIdentity($rawNo);
if ($core === '') {
continue;
}
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
$key = $shopKey . '|' . $core;
$key = $shopKey . '|' . $identity;
$orderPaid[$key] = ($orderPaid[$key] ?? 0.0) + (float) $row['paid'];
$orderPaidAlt[$key] = ($orderPaidAlt[$key] ?? 0.0) + (float) ($row['paid_alt'] ?? 0.0);
$orderPaidPay[$key] = ($orderPaidPay[$key] ?? 0.0) + (float) ($row['paid_pay'] ?? 0.0);
@ -596,6 +600,7 @@ class SyncStoreReceiptOrderMatches extends Command
// 商品金额数组仍只由商品明细构建:无明细时商品金额保持为空、按口径标「订单无商品金额」。
$orderSeen[$key] = true;
$coreSeen[$core] = true;
$identitiesByCore[$core][$shopKey][$identity] = true;
if ($shopKey !== '') {
$coresByCore[$core][$shopKey] = true;
}
@ -603,14 +608,17 @@ class SyncStoreReceiptOrderMatches extends Command
unset($paidRows, $row);
foreach ($orderRows as $row) {
$core = self::cleanOrderNo((string) $row['so_id']);
$rawNo = (string) $row['so_id'];
$core = self::cleanOrderNo($rawNo);
$identity = self::orderIdentity($rawNo);
if ($core === '') {
continue;
}
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
$key = $shopKey . '|' . $core;
$key = $shopKey . '|' . $identity;
$orderSeen[$key] = true;
$coreSeen[$core] = true;
$identitiesByCore[$core][$shopKey][$identity] = true;
if ($shopKey !== '') {
$coresByCore[$core][$shopKey] = true;
}
@ -665,21 +673,25 @@ class SyncStoreReceiptOrderMatches extends Command
// 两个口径各跑一遍同一条取数链(同店同号 → 店铺名不一致 → 同号多条 → 空店铺兜底):
// 商品金额用于「商品金额」列,订单实付用于「订单实付金额」列与比对基准。
[$productStatus, $productAmount, $productRemark] = $this->matchOrderSide(
(string) $group['online_order_no'],
$core,
(string) $group['shop_key'],
$orderAmount,
$orderSeen,
$coreSeen,
$coresByCore,
$identitiesByCore,
self::REMARK_NO_AMOUNT,
);
[$paidStatus, $paidAmount, $paidRemark] = $this->matchOrderSide(
(string) $group['online_order_no'],
$core,
(string) $group['shop_key'],
$orderPaidNet,
$orderSeen,
$coreSeen,
$coresByCore,
$identitiesByCore,
self::REMARK_NO_PAID,
);
@ -706,12 +718,14 @@ class SyncStoreReceiptOrderMatches extends Command
// 「先备注规则、后口径兜底」的顺序判定),避免把有明确原因的行悄悄抹平。
{
[$altStatus, $altAmount] = $this->matchOrderSide(
(string) $group['online_order_no'],
$core,
(string) $group['shop_key'],
$orderPaidAlt,
$orderSeen,
$coreSeen,
$coresByCore,
$identitiesByCore,
self::REMARK_NO_PAID,
);
$altMatched = $altStatus === self::STATUS_MATCHED && $altAmount !== null;
@ -722,12 +736,14 @@ class SyncStoreReceiptOrderMatches extends Command
// 或主单没写实收而子单写了值的情况都覆盖到)。
if (! ($altMatched && abs($income + $promoAmount - $altValue) <= 0.02)) {
[$payStatus, $payValue] = $this->matchOrderSide(
(string) $group['online_order_no'],
$core,
(string) $group['shop_key'],
$orderPaidPay,
$orderSeen,
$coreSeen,
$coresByCore,
$identitiesByCore,
self::REMARK_NO_PAID,
);
if ($payStatus === self::STATUS_MATCHED && $payValue !== null) {
@ -764,7 +780,12 @@ class SyncStoreReceiptOrderMatches extends Command
$buffer[] = [
'report_month' => $reportMonth,
'platform' => $group['platform'],
'row_key' => md5($reportMonth . '|' . $group['platform'] . '|' . $group['shop_key'] . '|' . $core),
'row_key' => self::groupRowKey(
$reportMonth,
(string) $group['platform'],
(string) $group['shop_key'],
(string) $group['order_identity']
),
'occurred_date' => $group['occurred_date'],
'store_name' => $group['store_name'],
'online_order_no' => $group['online_order_no'] !== '' ? $group['online_order_no'] : null,
@ -1021,7 +1042,12 @@ class SyncStoreReceiptOrderMatches extends Command
continue;
}
$rowKey = md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|' . $group['order_core_no']);
$rowKey = self::groupRowKey(
$reportMonth,
$platform,
(string) $group['shop_key'],
(string) $group['order_identity']
);
if (! isset($existing[$rowKey])) {
continue;
}
@ -1204,9 +1230,10 @@ class SyncStoreReceiptOrderMatches extends Command
$rawNo = trim((string) ($bill->{$orderColumn} ?? ''));
$core = self::cleanOrderNo($rawNo);
$identity = self::orderIdentity($rawNo);
$realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : '';
$shopKey = self::normalizeShop($realStore !== '' ? $realStore : (string) $bill->store_name);
$key = $shopKey . '|' . $core;
$key = $shopKey . '|' . $identity;
if (! isset($groups[$key])) {
$groups[$key] = [
@ -1216,6 +1243,7 @@ class SyncStoreReceiptOrderMatches extends Command
'shop_key' => $shopKey,
'online_order_no' => $rawNo,
'order_core_no' => $core,
'order_identity' => $identity,
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
'payment_count' => 0,
'income_amount' => 0.0,
@ -1235,13 +1263,14 @@ class SyncStoreReceiptOrderMatches extends Command
// 同一遍读取同时累计补行,避免把整月原始账单留到第二遍。
// 首笔元数据、无单号按账单项目拆分、收入行随后出现的语义与原有补行一致。
if ($bucket !== 'income') {
$promoKey = $core === '' ? $shopKey . '||' . $billItem : $key;
$promoKey = $identity === '' ? $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,
'order_identity' => $identity,
'bill_item' => $billItem,
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
'discount_amount' => 0.0,
@ -1308,7 +1337,7 @@ class SyncStoreReceiptOrderMatches extends Command
}
// A 方案:挂不到订单行的优惠/冲减也补成行(收入/商品/差额留空),金额不丢。
foreach ($promoOnlyGroups as $key => $group) {
if ($group['order_core_no'] !== '' && isset($groups[$group['shop_key'] . '|' . $group['order_core_no']])) {
if ($group['order_identity'] !== '' && isset($groups[$group['shop_key'] . '|' . $group['order_identity']])) {
unset($promoOnlyGroups[$key]);
}
}
@ -1327,9 +1356,10 @@ class SyncStoreReceiptOrderMatches extends Command
*/
private static function promoOnlyRow(string $reportMonth, string $platform, array $group, mixed $now): array
{
$identity = (string) ($group['order_identity'] ?? '');
$core = (string) $group['order_core_no'];
$rowKey = $core !== ''
? md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|' . $core)
$rowKey = $identity !== ''
? self::groupRowKey($reportMonth, $platform, (string) $group['shop_key'], $identity)
: md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|promo|' . $group['bill_item']);
return [
@ -1403,6 +1433,31 @@ class SyncStoreReceiptOrderMatches extends Command
return self::orderCoreNo($value);
}
/**
* 完整线上单号身份:保留日期前缀,只统一空白、横杠形态和大小写。
*
* 拼多多的「260627-...」「260713-...」是两个不同订单,不能按去掉
* 日期前缀后的核心号合并;完整单号用于账单分组与订单侧精确匹配,
* 核心号只在唯一候选时作为兼容回退。
*/
private static function orderIdentity(string $value): string
{
$value = str_replace("\u{3000}", ' ', trim($value));
if ($value === '') {
return '';
}
$value = preg_replace('/[\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', '-', $value) ?? $value;
$value = preg_replace('/\s+/u', '', $value) ?? '';
return strtoupper($value);
}
private static function groupRowKey(string $reportMonth, string $platform, string $shopKey, string $identity): string
{
return md5($reportMonth . '|' . $platform . '|' . $shopKey . '|' . $identity);
}
/**
* 种菜订单号集合:种菜明细表的订单号 ∪ 聚水潭订单标签明细里「是否种菜订单=是」的订单号。
*
@ -1558,11 +1613,13 @@ class SyncStoreReceiptOrderMatches extends Command
}
/**
* 按「店铺 + 订单号」在订单侧取金额,返回 [匹配状态, 订单侧金额, 原因标注]。
* 按「店铺 + 完整线上单号」在订单侧取金额,返回 [匹配状态, 订单侧金额, 原因标注]。
*
* 两个比对基准共用这一条取数链:
* 同店同号 → 店铺名不一致(订单号唯一)→ 同号多条(多店铺合计)→ 空店铺兜底 → 订单表无此单。
* 同店完整单号 → 同店核心号唯一时回退 → 跨店唯一时回退 → 空店铺唯一时回退。
* 核心号一旦对应多个完整线上单号就视为歧义,不再合并金额,避免不同订单串单。
*
* @param array<string,array<string,array<string,true>>> $identitiesByCore 核心单号 → 店铺 → 完整线上单号
* @param array<string,float> $amounts 订单侧金额集合(商品金额或订单实付)
* @param array<string,bool> $orderSeen 订单侧出现过的「店铺|单号」
* @param array<string,bool> $coreSeen 订单侧出现过的单号
@ -1570,12 +1627,14 @@ class SyncStoreReceiptOrderMatches extends Command
* @return array{0:string,1:?float,2:string}
*/
private function matchOrderSide(
string $onlineOrderNo,
string $core,
string $shopKey,
array $amounts,
array $orderSeen,
array $coreSeen,
array $coresByCore,
array $identitiesByCore,
string $noAmountRemark,
): array {
if ($core === '') {
@ -1586,8 +1645,9 @@ class SyncStoreReceiptOrderMatches extends Command
return [self::STATUS_UNMATCHED, null, self::REMARK_BAD_FORMAT];
}
$exactKey = $shopKey . '|' . $core;
if (isset($orderSeen[$exactKey])) {
$identity = self::orderIdentity($onlineOrderNo);
$exactKey = $shopKey . '|' . $identity;
if ($identity !== '' && isset($orderSeen[$exactKey])) {
if (isset($amounts[$exactKey])) {
$remark = count($coresByCore[$core] ?? []) > 1 ? self::REMARK_DUP_ORDER : '';
@ -1598,41 +1658,43 @@ class SyncStoreReceiptOrderMatches extends Command
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
}
if (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) === 1) {
// 单号一致但店铺名称对不上:仍然带上金额,标注「店铺不一致」便于复核。
$otherKey = array_key_first($coresByCore[$core]) . '|' . $core;
if (isset($amounts[$otherKey])) {
return [self::STATUS_MATCHED, round((float) $amounts[$otherKey], 2), self::REMARK_SHOP_DIFF];
if (isset($coreSeen[$core])) {
$sameShopIdentities = array_keys($identitiesByCore[$core][$shopKey] ?? []);
if (count($sameShopIdentities) > 1) {
return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER];
}
if (count($sameShopIdentities) === 1) {
$fallbackKey = $shopKey . '|' . $sameShopIdentities[0];
if (isset($amounts[$fallbackKey])) {
$remark = count($coresByCore[$core] ?? []) > 1 ? self::REMARK_DUP_ORDER : '';
return [self::STATUS_MATCHED, round((float) $amounts[$fallbackKey], 2), $remark];
}
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
}
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
}
$shops = array_keys($identitiesByCore[$core] ?? []);
if (count($shops) === 1) {
$otherShop = (string) $shops[0];
$otherIdentities = array_keys($identitiesByCore[$core][$otherShop] ?? []);
if (count($otherIdentities) > 1) {
return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER];
}
if (count($otherIdentities) === 1) {
$otherKey = $otherShop . '|' . $otherIdentities[0];
if (isset($amounts[$otherKey])) {
$remark = $otherShop === '' ? '' : self::REMARK_SHOP_DIFF;
if (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) > 1) {
$sum = 0.0;
$hasAmount = false;
foreach (array_keys($coresByCore[$core]) as $otherShop) {
$key2 = $otherShop . '|' . $core;
if (isset($amounts[$key2])) {
$sum += (float) $amounts[$key2];
$hasAmount = true;
return [self::STATUS_MATCHED, round((float) $amounts[$otherKey], 2), $remark];
}
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
}
}
if ($hasAmount) {
return [self::STATUS_MATCHED, round($sum, 2), self::REMARK_DUP_ORDER];
}
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
}
if (isset($coreSeen[$core])) {
// 订单侧只有店铺名为空的记录:按订单号兜底匹配。
$blankKey = '|' . $core;
if (isset($amounts[$blankKey])) {
return [self::STATUS_MATCHED, round((float) $amounts[$blankKey], 2), ''];
}
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
// 同一核心号跨多个店铺或同一店铺多个完整单号:禁止把金额相加。
return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER];
}
return [self::STATUS_UNMATCHED, null, self::REMARK_NO_ORDER];

View File

@ -34,6 +34,9 @@ class SyncStoreReceiptProductCost extends Command
/** 虚拟条码(信誉订单/虚拟订单)不是真实商品,不计入商品成本。 */
private const VIRTUAL_SKU_PREFIX = 'X0000';
/** 套餐 / 服务占位编码:不是实物商品,商品成本不取数。 */
private const EXCLUDED_SKUS = ['TCXJ', 'FUWU'];
private const REMARK_NO_WAREHOUSE = '未匹配仓库简称';
private const REMARK_NO_PRICE = '未匹配商品单价';
@ -117,10 +120,15 @@ class SyncStoreReceiptProductCost extends Command
// 账单侧取数:业绩明细里本月(可按平台限定)的账单行,一行 = 一个店铺的一个订单。
// 只取「收入金额有数据且不为 0」的账单行:收入为 0 的行是补出来的优惠/冲减/无收款账单行,
// 没有对应的收款商品,不参与商品成本。
// 「种菜金额」有数据(>0)的账单行整条排除,不参与匹配。
$billQuery = $mirror->table(self::SOURCE_TABLE)
->where('report_month', $reportMonth)
->whereNotNull('income_amount')
->where('income_amount', '!=', 0);
->where('income_amount', '!=', 0)
->where(static function ($query): void {
$query->whereNull('planting_amount')
->orWhere('planting_amount', '<=', 0);
});
if ($platform !== '') {
$billQuery->where('platform', $platform);
}
@ -171,6 +179,7 @@ class SyncStoreReceiptProductCost extends Command
/** @var array<string,list<array{sku:string,qty:float,amount:float,warehouse:string,is_main:bool,row_key:string}>> $lines */
$lines = [];
$excludedSkuSql = "'" . implode("','", self::EXCLUDED_SKUS) . "'";
foreach (self::SOURCES as $source) {
try {
$rows = $mirror->select(
@ -184,7 +193,8 @@ class SyncStoreReceiptProductCost extends Command
STRAIGHT_JOIN {$source['orders']} o ON o.so_id = t.no
STRAIGHT_JOIN {$source['items']} i ON i.o_id = o.o_id
LEFT JOIN ops_jst_warehouse_info wh ON wh.wms_co_id = o.wms_co_id
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'"
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'
AND UPPER(TRIM(COALESCE(i.sku_id, ''))) NOT IN ({$excludedSkuSql})"
);
} catch (Throwable $exception) {
$this->error('订单明细查询失败(' . $source['items'] . '):' . $exception->getMessage());
@ -265,9 +275,7 @@ class SyncStoreReceiptProductCost extends Command
foreach ($billByCore as $core => $bill) {
foreach ($this->costLines($lines[$core] ?? []) as $line) {
$short = $shortNames['by_warehouse'][$line['warehouse']]
?? $shortNames['by_provider'][$line['warehouse']]
?? '';
$short = $shortNames['by_provider'][$line['warehouse']] ?? '';
$price = $short !== '' ? ($prices[$short . "\0" . $line['sku']] ?? null) : null;
$priceSource = $price === null ? null : self::PRICE_SOURCE_WEIGHTED;
// 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。
@ -459,10 +467,14 @@ class SyncStoreReceiptProductCost extends Command
$expanded = [];
foreach ($components as [$code, $multiplier]) {
// 组合装里如果拆出套餐/服务占位编码,同样不取数。
if (in_array(mb_strtoupper(trim($code)), self::EXCLUDED_SKUS, true)) {
continue;
}
$expanded[] = [$code, $quantity * $multiplier, true];
}
return $expanded;
return $expanded === [] ? [] : $expanded;
}
/**
@ -476,7 +488,6 @@ class SyncStoreReceiptProductCost extends Command
private function warehouseShortNames(): array
{
$byProvider = [];
$byWarehouse = [];
foreach (DB::table(self::WAREHOUSE_MAPPING_TABLE)
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']) as $mapping) {
$provider = trim((string) $mapping->warehouse_provider);
@ -485,19 +496,30 @@ class SyncStoreReceiptProductCost extends Command
if ($short === '') {
continue;
}
if ($warehouse !== '' && ! isset($byWarehouse[$warehouse])) {
$byWarehouse[$warehouse] = $short;
}
if ($provider === '') {
continue;
}
// 只认「仓储方 + 仓库含主仓」的那条:发货仓库是仓储方级别的名称,
// 次品仓 / 销退仓 / 客退仓这些行的简称(多为「-不看」)不能套到主仓发货上。
$isMain = str_contains($warehouse, '主仓');
if ($isMain || ! isset($byProvider[$provider])) {
if ($isMain) {
$byProvider[$provider] = $short;
continue;
}
// 映射表没有主仓行时,仅接受「仓库名 = 仓储方名」这种同名仓库(如重庆渝北仓),
// 其余(次品仓、配件单仓等)不再兜底,避免把主仓订单误判成不看仓。
if ($warehouse === $provider && ! isset($byProvider[$provider])) {
$byProvider[$provider] = $short;
continue;
}
// 业务确认:常州简尚家居有限公司的发货仓对应映射表里的「配件单仓」行
// (仓储方=常州简尚家居有限公司 / 仓库=配件单仓 → 配件单仓-不看)。
if (str_contains($warehouse, '配件单仓') && ! isset($byProvider[$provider])) {
$byProvider[$provider] = $short;
}
}
return ['by_provider' => $byProvider, 'by_warehouse' => $byWarehouse];
return ['by_provider' => $byProvider];
}
/**

View File

@ -75,8 +75,8 @@ class VerifyErpDailyBillCoverage extends Command
$month = $from->copy()->startOfMonth();
while ($month->lte($to)) {
$monthEnd = $month->copy()->endOfMonth();
$requiredStart = $month->lt($from) ? $from->copy() : $month->copy();
$requiredThrough = $monthEnd->gt($to) ? $to->copy() : $monthEnd->copy();
$requiredStart = ($month->lt($from) ? $from->copy() : $month->copy())->startOfDay();
$requiredThrough = ($monthEnd->gt($to) ? $to->copy() : $monthEnd->copy())->startOfDay();
$requiredEndExclusive = $requiredThrough->copy()->addDay();
$period = $db->table($target['periods'])
->where('period_start', $month->toDateString())

View File

@ -372,6 +372,10 @@ class Pages extends Controller
$jdSofiaGuobu = '(' . $jdSofiaPay . ' - COALESCE(income_amount, 0)) > 0.005';
$rules['差额=订单实付包含商家承担优惠和以旧换新国补'] = $jdSofiaGuard . ' AND ' . $jdSofiaGuobu;
$rules['差额=订单实付包含商家承担优惠'] = $jdSofiaGuard . ' AND NOT (' . $jdSofiaGuobu . ')';
// 拼多多全额退款:先于通用「未减优惠/冲减」判定,避免抵减项把退款单误标成其他原因。
$pddFullRefundHit = "platform = '拼多多'"
. ' AND ABS(COALESCE(income_amount, 0) + COALESCE(refund_amount, 0)) <= 0.02';
$rules['订单实付含已退款部分'] = $pddFullRefundHit;
// C 方案:这三条是"通用等式档"——口径(卖家实收 / 应付已付)能对上账单的行让位给口径兜底
// (归零 + 口径标识),只有口径也对不上时才显示这三档,避免把口径差标成一堆"未减优惠/冲减"。
$notBasisMarked = self::receiptReasonNotBasisMarkedSql();
@ -410,7 +414,18 @@ class Pages extends Controller
. ' OR ' . $refundGapHit($refundEvidence['cancelled_sum'])
. ' OR ' . $refundGapNetHit($refundEvidence['refund_sum'])
. ' OR ' . $refundFullHit($refundEvidence['refund_sum'])
. ') ELSE 0 END = 1) OR ' . $taoRefundHit;
. ') ELSE 0 END = 1) OR ' . $taoRefundHit . ' OR ' . $pddFullRefundHit;
// 拼多多部分退款:账单收入高于聚水潭买家实付,且扣除优惠/冲减后仍有正向小额差额,
// 与「多多支付立减」等平台支付类补贴一致;只标注原因,不改差额和收入。
$pddPaymentDiscount = '(' . $effective . ' - COALESCE(discount_amount, 0) - COALESCE(offset_amount, 0))';
$rules['差额=平台支付立减'] = "platform = '拼多多'"
. " AND match_status = '已匹配'"
. ' AND ' . $effective . ' > 0.005'
. ' AND COALESCE(refund_amount, 0) < -0.005'
. ' AND ' . $pddPaymentDiscount . ' > 0.005'
. ' AND ' . $pddPaymentDiscount . ' <= 20.005'
. ' AND ' . $notBasisMarked;
// 1688 先采后付订单的订单实付包含平台红包补贴,但支付宝实际到账是扣除补贴后的现金;
// 仅对已匹配、负差额、无优惠/冲减/退款且当前没有口径标识的行标注,不改差额金额。
@ -601,6 +616,11 @@ class Pages extends Controller
if (abs($product) < 0.005) {
return ($row->planting_amount ?? null) !== null ? $label . '取数为0(种菜)' : $label . '取数为0';
}
// 拼多多全额退款优先于其它抵减等式:退款后实际净收款为 0,不应再落成优惠/冲减异常。
if (($row->platform ?? '') === '拼多多'
&& abs($income + (float) ($row->refund_amount ?? 0)) <= 0.02) {
return '订单实付含已退款部分';
}
// 得物负差额:差额与联合营销费未让利尾数吻合(营销费支出 + 差额恰为整数)→「订单实付未减联合营销费」。
// 放在通用「未减冲减/优惠」等式之前:得物这类行的差额常恰好等于冲减(营销费)金额,需优先给出具体项备注。
if (($row->platform ?? '') === '得物' && $difference < -0.005) {
@ -707,6 +727,17 @@ class Pages extends Controller
}
}
// 拼多多部分退款:差额扣除优惠/冲减后,剩余部分与平台支付立减一致。
$pddPaymentDiscount = $difference - $discount - $offset;
if (($row->platform ?? '') === '拼多多'
&& ! $basisMarked
&& $difference > 0.005
&& (float) ($row->refund_amount ?? 0) < -0.005
&& $pddPaymentDiscount > 0.005
&& $pddPaymentDiscount <= 20.005) {
return '差额=平台支付立减';
}
// 原因优先:上面的规则都没命中时,如果这行的口径(卖家实收 / 应付已付)能对上账单,
// 就按口径兜底——差额按 0 展示、口径标识在「匹配原因」列,不再当异常。
$rawRemark = trim((string) ($row->remark ?? ''));
@ -1661,8 +1692,8 @@ class Pages extends Controller
// 联动统计要跑几次全表聚合,短时间内重复请求(页面加载 + 下拉刷新)直接吃缓存
$cacheKey = 'performance-details-facets:' . md5(serialize([$month, $filters, $orderNo]));
// 数据按天同步,缓存 10 分钟:大幅降低大平台(拼多多等)的联动等待。
return response()->json(cache()->remember($cacheKey, now()->addSeconds(600), function () use ($month, $filters, $orderNo) {
// 数据按天同步;联动统计会扫全月,缓存 1 小时,避免相同筛选反复等待。
return response()->json(cache()->remember($cacheKey, now()->addHour(), function () use ($month, $filters, $orderNo) {
return [
'month' => substr($month, 0, 7),
'facets' => $this->receiptOrderMatchFacets($month, $filters, $orderNo),
@ -2178,8 +2209,6 @@ class Pages extends Controller
'online_order_no' => trim((string) request('online_order_no', '')),
'product_code' => trim((string) request('product_code', '')),
'warehouse_short_name' => trim((string) request('warehouse_short_name', '')),
'missing_price' => request()->boolean('missing_price') ? '1' : '',
'include_excluded' => request()->boolean('include_excluded') ? '1' : '',
];
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
@ -2199,7 +2228,9 @@ class Pages extends Controller
];
}
$applyFilters = static function ($query, bool $excludedWarehouseOnly = false) use ($filters): void {
// 页面不再提供「只看未匹配单价 / 包含-不看仓」两个开关:默认全部数据展示,
// 已匹配与未匹配单价、所有仓库(含「-不看」仓)都纳入统计与列表。
$applyFilters = static function ($query) use ($filters): void {
$query->where('report_month', $filters['month'] . '-01');
if ($filters['platform'] !== '') {
$query->where('platform', $filters['platform']);
@ -2212,32 +2243,12 @@ class Pages extends Controller
if ($filters['warehouse_short_name'] !== '') {
$query->where('warehouse_short_name', $filters['warehouse_short_name']);
}
if ($filters['missing_price'] === '1') {
$query->whereNull('unit_price');
}
// 仓库映射表里标「不看」的仓(次品 / 销退 / 虚拟 / 信誉 / 京东云仓等)默认不展示:
// 这些仓在商品月加权平均单价里本来就没有价,展示出来只会让「未匹配单价」虚高。
if ($excludedWarehouseOnly) {
$query->where('warehouse_short_name', 'like', '%-不看%');
return;
}
if ($filters['include_excluded'] !== '1') {
$query->where(static function ($inner): void {
$inner->whereNull('warehouse_short_name')
->orWhere('warehouse_short_name', 'not like', '%-不看%');
});
}
};
$countQuery = $base();
$applyFilters($countQuery);
$total = (int) $countQuery->count();
$excludedQuery = $base();
$applyFilters($excludedQuery, true);
$excludedRows = (int) $excludedQuery->count();
$aggQuery = $base();
$applyFilters($aggQuery);
$agg = $aggQuery->selectRaw('
@ -2291,7 +2302,6 @@ class Pages extends Controller
'missing_price_rows' => (int) ($agg->missing_price_rows ?? 0),
'missing_price_quantity' => round((float) ($agg->missing_price_quantity ?? 0), 2),
'missing_warehouse_rows' => (int) ($agg->missing_warehouse_rows ?? 0),
'excluded_rows' => $excludedRows,
'fallback_price_rows' => (int) ($agg->fallback_price_rows ?? 0),
'catalog_price_rows' => (int) ($agg->catalog_price_rows ?? 0),
'unit_mismatch_rows' => (int) ($agg->unit_mismatch_rows ?? 0),
@ -2573,15 +2583,7 @@ class Pages extends Controller
if ($warehouse !== '') {
$query->where('warehouse_short_name', $warehouse);
}
if ($request->boolean('missing_price')) {
$query->whereNull('unit_price');
}
if (! $request->boolean('include_excluded')) {
$query->where(static function ($inner): void {
$inner->whereNull('warehouse_short_name')
->orWhere('warehouse_short_name', 'not like', '%-不看%');
});
}
// 导出与页面一致:全部数据(含未匹配单价与「-不看」仓)。
$filename = '商品成本_' . ($month === null ? 'all' : substr($month, 0, 7)) . '.csv';

View File

@ -6,6 +6,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\SupplierComparisonCsvExporter;
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
@ -29,6 +30,7 @@ class SupplierReconciliation extends Controller
private readonly SupplierReconciliationPageData $pageData,
private readonly SupplierReturnCsvExporter $returnCsvExporter,
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
private readonly SupplierComparisonCsvExporter $comparisonCsvExporter,
) {
$this->middleware('auth');
$this->middleware('company.identify');
@ -105,6 +107,23 @@ class SupplierReconciliation extends Controller
return $this->returnCsvExporter->download($rows, $supplier);
}
public function exportComparison(Request $request): StreamedResponse
{
$request->validate([
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
]);
[$supplier, $companyId] = $this->returnTarget($request);
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->query());
return $this->comparisonCsvExporter->download(
$detail,
$supplier,
trim((string) $request->query('month', '')),
trim((string) $request->query('reconciler', '')),
);
}
public function saveDingTalkRobot(Request $request): RedirectResponse
{
$request->validate([
@ -131,9 +150,10 @@ class SupplierReconciliation extends Controller
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
'scope' => ['required', 'in:all,row,selected'],
'purchase_no' => ['nullable', 'string', 'max:191'],
'date' => ['nullable', 'string', 'max:32'],
'barcode' => ['nullable', 'string', 'max:191'],
'difference_type' => ['nullable', 'string', 'max:32'],
'unit_price' => ['nullable', 'numeric'],
'selected_rows' => ['nullable', 'array', 'max:600'],
'selected_rows.*' => ['string', 'max:2048'],
]);
@ -145,14 +165,16 @@ class SupplierReconciliation extends Controller
));
if ($request->input('scope') === 'row') {
$purchaseNo = trim((string) $request->input('purchase_no'));
$date = trim((string) $request->input('date'));
$barcode = trim((string) $request->input('barcode'));
$type = trim((string) $request->input('difference_type'));
$unitPrice = (float) $request->input('unit_price');
$differences = array_values(array_filter(
$differences,
static fn (array $row): bool => (string) ($row['barcode'] ?? '') === $barcode
static fn (array $row): bool => (string) ($row['purchaseNo'] ?? '') === $purchaseNo
&& (string) ($row['date'] ?? '') === $date
&& (string) ($row['barcode'] ?? '') === $barcode
&& (string) ($row['type'] ?? '') === $type
&& abs((float) ($row['unitPrice'] ?? 0) - $unitPrice) < 0.005,
));
} elseif ($request->input('scope') === 'selected') {
$selected = [];
@ -214,9 +236,10 @@ class SupplierReconciliation extends Controller
private function differenceRowKey(array $row): string
{
return implode("\0", [
trim((string) ($row['purchaseNo'] ?? $row['purchase_no'] ?? '')),
trim((string) ($row['date'] ?? '')),
trim((string) ($row['barcode'] ?? '')),
trim((string) ($row['type'] ?? '')),
number_format((float) ($row['unitPrice'] ?? 0), 4, '.', ''),
]);
}

View File

@ -14,6 +14,7 @@ class CourierQuoteVersion extends Model
'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name',
'carrier_name', 'courier_expcode', 'billing_rules', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json',
'status', 'signature_status', 'created_by', 'approved_by', 'void_reason', 'voided_at', 'voided_by',
'billing_structure', 'billing_rows',
];
public const STATUS_APPROVED = 'approved';
@ -26,6 +27,8 @@ class CourierQuoteVersion extends Model
'effective_date' => 'date',
'end_date' => 'date',
'voided_at' => 'datetime',
'billing_structure' => 'array',
'billing_rows' => 'array',
];
/** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */
@ -56,6 +59,14 @@ class CourierQuoteVersion extends Model
: $this->effective_date->toDateString() . ' 起';
}
/** 是否已有可用于运费核算的结构化计费规则。 */
public function hasBillingRules(): bool
{
$mode = $this->billing_structure['mode'] ?? null;
return is_string($mode) && $mode !== 'other' && is_array($this->billing_rows) && $this->billing_rows !== [];
}
public function isVoided(): bool
{
return $this->status === self::STATUS_VOIDED;

View File

@ -0,0 +1,157 @@
<?php
namespace App\Support;
use App\Models\Business\CourierQuoteVersion;
use DateTimeInterface;
use Illuminate\Support\Collection;
/**
* 按「快递编码 + 发货日期」找到当天生效的报价版本,用结构化计费规则算一票的运费。
*
* 计费重量 = max(实重, 体积重, 起算重量) 再按取整方式处理;超过限重不计算。
*
* 生效版本的判断与报价管理页一致(CourierRequoteService::resolveAt:未作废、已审核或已签署归档、
* 执行期间覆盖发货日期,短期变价优先)。没有结构化规则的版本(按体积、按条码等特殊计费)返回原因,交人工核算。
*/
class CourierQuoteFreightCalculator
{
/** @var array<string,Collection<int,CourierQuoteVersion>> */
private array $versions = [];
/**
* @param string $courierExpcode 快递编码;没有编码的存量报价可传快递名称(如「沐野竹木顺丰」)
* @return array{
* version_code:?string,company_name:?string,region:?string,gross_weight:float,volume_weight:?float,
* billable_weight:?float,base_amount:?float,surcharge:float,amount:?float,formula:?string,reason:?string
* }
*/
public function calculate(
string $courierExpcode,
DateTimeInterface $shipDate,
string $province,
float $grossWeight,
?float $length = null,
?float $width = null,
?float $height = null,
?string $city = null,
?string $quoteType = null,
): array {
$result = [
'version_code' => null, 'company_name' => null, 'region' => null, 'gross_weight' => $grossWeight,
'volume_weight' => null, 'billable_weight' => null, 'base_amount' => null, 'surcharge' => 0.0,
'amount' => null, 'formula' => null, 'reason' => null,
];
if (! is_finite($grossWeight) || $grossWeight < 0
|| collect([$length, $width, $height])->contains(static fn ($v) => $v !== null && (! is_finite($v) || $v < 0))) {
return ['reason' => '重量和尺寸必须是有效非负数字'] + $result;
}
$version = $this->versionAt(trim($courierExpcode), $shipDate, $quoteType);
if ($version === null) {
return ['reason' => '发货日期没有生效的报价'] + $result;
}
$result['version_code'] = $version->version_code;
$result['company_name'] = $version->company_name;
if (! $version->hasBillingRules()) {
return ['reason' => '该报价为特殊计费或尚未设置计费规则,需人工核算'] + $result;
}
$structure = (array) $version->billing_structure;
$row = $this->matchRow((array) $version->billing_rows, $province, $city);
if ($row === null) {
return ['reason' => '报价未覆盖目的地「' . trim($province . ' ' . $city) . '」或该地区不发'] + $result;
}
$result['region'] = (string) $row['region'];
$divisor = (float) ($structure['volumetric_divisor'] ?? 0);
$volume = $divisor > 0 && $length > 0 && $width > 0 && $height > 0 ? round($length * $width * $height / $divisor, 4) : null;
$settlement = max($grossWeight, (float) $volume);
$result['volume_weight'] = $volume;
if ($settlement <= 0) {
return ['reason' => '缺少重量,无法计算运费'] + $result;
}
$limit = (float) ($structure['weight_limit'] ?? 0);
if ($limit > 0 && $settlement > $limit + 1e-9) {
return ['reason' => '超出限重 ' . CourierQuoteStructure::kg($limit) . 'kg'] + $result;
}
// 起算重量:不足按起算重量计(如菏泽顺帆顺丰不足 5kg 按 5kg)
$minimum = (float) ($structure['min_weight'] ?? 0);
$billable = CourierQuoteStructure::roundWeight(max($settlement, $minimum), (string) ($structure['rounding'] ?? 'none'));
$result['billable_weight'] = $billable;
$freight = CourierQuoteStructure::freight($structure, $row, $billable);
if ($freight['amount'] === null) {
return ['reason' => $freight['reason']] + $result;
}
$surcharge = (float) ($structure['per_ticket_surcharge'] ?? 0);
$result['base_amount'] = round($freight['amount'], 2);
$result['surcharge'] = $surcharge;
$result['amount'] = round($freight['amount'] + $surcharge, 2);
$result['formula'] = $freight['formula'] . ($surcharge > 0 ? '+每票' . CourierQuoteStructure::kg($surcharge) : '');
return $result;
}
/** 省份名统一:去掉省/市/自治区后缀,黑龙→黑龙江、内蒙→内蒙古。 */
public static function regionKey(string $name): string
{
$text = (string) preg_replace('/\s+/u', '', $name);
$text = (string) preg_replace('/(维吾尔自治区|壮族自治区|回族自治区|自治区|特别行政区|省|市)$/u', '', $text);
return match ($text) {
'黑龙' => '黑龙江',
'内蒙' => '内蒙古',
default => $text,
};
}
private function versionAt(string $courierExpcode, DateTimeInterface $date, ?string $quoteType): ?CourierQuoteVersion
{
if ($courierExpcode === '') {
return null;
}
// 先按快递编码;部分存量版本没有编码,按快递名称(与报价管理页的分组口径一致)
$cacheKey = company_id() . ':' . $courierExpcode;
$versions = $this->versions[$cacheKey] ??= CourierQuoteVersion::query()
->with(['signature', 'change'])
->where(static fn ($query) => $query->where('courier_expcode', $courierExpcode)
->orWhere(static fn ($q) => $q->where(static fn ($e) => $e->whereNull('courier_expcode')->orWhere('courier_expcode', ''))
->where('company_name', $courierExpcode)))
->get();
return CourierRequoteService::resolveAt(
$quoteType === null ? $versions : $versions->where('quote_type', $quoteType),
$date,
);
}
/**
* 先按城市匹配单列的城市行(如「深圳(广东)」),再按省份匹配。
*
* @param list<array<string,mixed>> $rows
* @return array<string,mixed>|null
*/
private function matchRow(array $rows, string $province, ?string $city): ?array
{
$cityKey = self::regionKey((string) $city);
if ($cityKey !== '') {
foreach ($rows as $row) {
if (is_array($row) && preg_match('/^(.+?)[((](.+?)[))]$/u', trim((string) ($row['region'] ?? '')), $parts)
&& self::regionKey($parts[1]) === $cityKey && self::regionKey($parts[2]) === self::regionKey($province)) {
return $row;
}
}
}
$key = self::regionKey($province);
foreach ($rows as $row) {
if (is_array($row) && self::regionKey((string) ($row['region'] ?? '')) === $key) {
return $row;
}
}
return null;
}
}

View File

@ -40,6 +40,13 @@ final class CourierQuoteStructure
'round' => '四舍五入(如 4.4kg 按 4kg,4.5kg 按 5kg)',
];
/**
* 分段计费:不放进 MODES(入驻表单不提供),只用于存量报价导入。
* 每个省份行为 {region, segments:[{lo, hi, type: flat|waybill|first_add, price|base, rate, fw}]},
* 重量落在 (lo, hi](第一段含 0)就按该段计算;hi 为 null 表示不封顶。
*/
public const SEGMENTS = 'segments';
public const MAX_BANDS = 12;
public const MAX_TIERS = 6;
@ -272,10 +279,120 @@ final class CourierQuoteStructure
? "首重{$fw}kg价+(重量-{$fw})*续重"
: '面单+重量*费率') . $tierNote,
'other' => '特殊计费,见附件报价表',
self::SEGMENTS => '分段计费:每个重量段单独设一口价、面单+费率或首重+续重(可表示累进计费)',
default => '面单+重量*费率' . $tierNote,
};
}
/**
* 按结构化报价计算一票的基础运费(不含计泡、限重、附加费,这些由调用方先处理)。
*
* 与 describe() 口径一致:费率分段按「总重量所在区间」取费率,区间上限含本数;
* 一口价/费率为空表示该重量段不发。$weight 应为已取整的计费重量。
*
* @param array<string,mixed> $structure
* @param array<string,mixed> $row
* @return array{amount:?float,formula:?string,reason:?string}
*/
public static function freight(array $structure, array $row, float $weight): array
{
$mode = (string) ($structure['mode'] ?? 'waybill_rate');
$none = static fn (string $reason): array => ['amount' => null, 'formula' => null, 'reason' => $reason];
$ok = static fn (float $amount, string $formula): array => ['amount' => round($amount, 4), 'formula' => $formula, 'reason' => null];
if (! is_finite($weight) || $weight <= 0) {
return $none('缺少有效重量,无法计算运费');
}
$w = self::kg($weight);
$within = static fn (float $value, float $edge): bool => $value <= $edge + 1e-9;
if ($mode === self::SEGMENTS) {
foreach ((array) ($row['segments'] ?? []) as $segment) {
$lo = (float) ($segment['lo'] ?? 0);
$hi = isset($segment['hi']) ? (float) $segment['hi'] : INF;
if (! (($weight > $lo + 1e-9 || $lo <= 0.0) && $within($weight, $hi))) {
continue;
}
$type = (string) ($segment['type'] ?? '');
if (! in_array($type, ['flat', 'waybill', 'first_add'], true)) {
return $none('不支持的分段计费方式,需人工核算');
}
if ($type === 'flat') {
return isset($segment['price']) ? $ok((float) $segment['price'], '一口价' . self::kg((float) $segment['price'])) : $none('该重量段不发');
}
if (! isset($segment['base'], $segment['rate'])) {
return $none('该重量段不发');
}
$base = (float) $segment['base'];
$rate = (float) $segment['rate'];
if ($type === 'first_add') {
$fw = (float) ($segment['fw'] ?? 0);
return $within($weight, $fw)
? $ok($base, '首重' . self::kg($base))
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
}
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
}
return $none('超出报价重量范围');
}
if (! in_array($mode, ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then'], true)) {
return $none('特殊计费,需人工核算');
}
$bands = array_map('floatval', array_values((array) ($structure['flat_bands'] ?? [])));
$flat = array_values((array) ($row['flat'] ?? []));
if (in_array($mode, ['flat_bands', 'flat_then'], true) && $bands !== [] && ($mode === 'flat_bands' || $within($weight, end($bands)))) {
foreach ($bands as $index => $edge) {
if ($within($weight, $edge)) {
$price = $flat[$index] ?? null;
return is_numeric($price) ? $ok((float) $price, '≤' . self::kg($edge) . 'kg 一口价' . self::kg((float) $price)) : $none('该重量段不发');
}
}
return $none('超出报价重量范围');
}
$tiers = array_map('floatval', array_values((array) ($structure['rate_tiers'] ?? [])));
$index = count($tiers);
foreach ($tiers as $i => $edge) {
if ($within($weight, $edge)) {
$index = $i;
break;
}
}
$rate = $row['rates'][$index] ?? null;
$base = $row['base'] ?? null;
if (! is_numeric($rate) || ! is_numeric($base)) {
return $none('该重量段不发');
}
$rate = (float) $rate;
$base = (float) $base;
$then = $mode === 'flat_then' ? (string) ($structure['then_mode'] ?? 'waybill_rate') : $mode;
if ($then === 'first_additional') {
$fw = (float) ($structure['first_weight'] ?? 0);
return $within($weight, $fw)
? $ok($base, '首重' . self::kg($base))
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
}
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
}
/** 按取整方式得到计费重量:ceil 向上取整,round 四舍五入(4.5 进为 5)。 */
public static function roundWeight(float $weight, string $rounding): float
{
return match ($rounding) {
'ceil' => (float) ceil(round($weight, 6)),
'round' => (float) floor($weight + 0.5),
default => $weight,
};
}
public static function usesRates(string $mode): bool
{
return in_array($mode, ['waybill_rate', 'first_additional', 'flat_then'], true);

View File

@ -142,6 +142,23 @@ final class QuoteManagementSnapshot
return $companyName !== '' ? $companyName : $name;
}
/** 「查看」面板的计费规则一行:计费方式 + 取整/计泡/限重/每票加收。 */
private function billingSummary(CourierQuoteVersion $version): string
{
$structure = (array) $version->billing_structure;
$kg = static fn ($value): string => CourierQuoteStructure::kg((float) $value);
$parts = array_filter([
CourierQuoteStructure::describe($structure),
($structure['rounding'] ?? 'none') !== 'none' ? (CourierQuoteStructure::ROUNDING[$structure['rounding']] ?? null) : null,
filled($structure['min_weight'] ?? null) ? '不足' . $kg($structure['min_weight']) . 'kg按' . $kg($structure['min_weight']) . 'kg计' : null,
filled($structure['volumetric_divisor'] ?? null) ? '计泡 ' . $structure['volumetric_divisor'] : null,
filled($structure['weight_limit'] ?? null) ? '限重 ' . $kg($structure['weight_limit']) . 'kg' : null,
filled($structure['per_ticket_surcharge'] ?? null) ? '每票加收 ' . $kg($structure['per_ticket_surcharge']) . ' 元' : null,
]);
return implode(';', $parts) . '(' . count((array) $version->billing_rows) . ' 个地区)';
}
private function normalizeVersion(CourierQuoteVersion $version): array
{
$values = $this->decodeMatrix($version->values_json);
@ -155,6 +172,7 @@ final class QuoteManagementSnapshot
'company_name' => $version->company_name,
'courier_expcode' => $version->courier_expcode,
'billing_rules' => $version->billing_rules,
'billing_summary' => $version->hasBillingRules() ? $this->billingSummary($version) : null,
'carrier' => $version->carrier_name,
'quote_type' => $version->quote_type,
'effective_date' => $version->effective_date?->toDateString(),

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 报价版本增加结构化计费规则,供运费核算使用;原始单元格(values_json)保留不动。
*
* billing_structure:{mode, flat_bands, then_mode, first_weight, rate_tiers, rounding, volumetric_divisor,
* weight_limit, min_weight(起算重量), per_ticket_surcharge, notes}
* billing_rows: [{region, flat, base, rates}] 或分段计费 [{region, segments}]
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_quote_versions')) {
return;
}
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
if (! Schema::hasColumn('business_courier_quote_versions', 'billing_structure')) {
$table->longText('billing_structure')->nullable();
}
if (! Schema::hasColumn('business_courier_quote_versions', 'billing_rows')) {
$table->longText('billing_rows')->nullable();
}
});
}
public function down(): void
{
if (! Schema::hasTable('business_courier_quote_versions')) {
return;
}
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
foreach (['billing_structure', 'billing_rows'] as $column) {
if (Schema::hasColumn('business_courier_quote_versions', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (app()->environment('testing') || config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
// 生产可能保留旧 SKU 级快照;使用独立源行基线,不修改或删除旧表。
if (! $schema->hasTable('jushuitan_cost_price_observation_baselines')) {
$schema->create('jushuitan_cost_price_observation_baselines', function (Blueprint $table): void {
$table->char('source_key', 64)->primary();
$table->string('sku_id', 255);
$table->decimal('cost_price', 20, 4);
$table->dateTime('captured_at', 6);
});
}
if (! $schema->hasTable('jushuitan_cost_price_observations')) {
$schema->create('jushuitan_cost_price_observations', function (Blueprint $table): void {
$table->char('id', 64)->primary();
$table->char('source_key', 64);
$table->string('sku_id', 255);
$table->decimal('before_price', 20, 4);
$table->decimal('after_price', 20, 4);
$table->dateTime('previous_observed_at', 6);
$table->dateTime('observed_at', 6);
$table->dateTime('source_modified_at', 6)->nullable();
$table->index(['source_key', 'observed_at', 'id'], 'jst_cost_observations_lookup');
});
}
}
public function down(): void
{
if (app()->environment('testing') || config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
$schema->dropIfExists('jushuitan_cost_price_observations');
$schema->dropIfExists('jushuitan_cost_price_observation_baselines');
}
};

View File

@ -7,6 +7,11 @@ REMOTE_DIR="${3:-/opt/caiwuxitong}"
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
RUN_MIGRATIONS="${5:-0}"
KEEP_IMAGES="${6:-2}"
EXPECTED_PREVIOUS_SHA="${7:?Checked production revision is required; update the deployment scripts}"
DEPLOY_ACTOR="${8:-unknown}"
RELEASE_APP_URL="${9:-}"
RELEASE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$RELEASE_DIR/release-guard.sh"
IMAGE_NAME="caiwu_akaunting"
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
REGISTRY_API="http://127.0.0.1:5000/v2/caiwuxitong/app"
@ -65,6 +70,12 @@ if ! flock -n 9; then
exit 1
fi
RELEASE_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
RELEASE_RESULT=blocked
trap 'release_status=$?; caiwu_record_release_result "$release_status"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。
exec 8>/run/caiwu-upstream-sync.lock
echo "Waiting for active upstream synchronization before replacing the application container"
@ -74,7 +85,8 @@ if ! flock -w 1800 8; then
fi
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
caiwu_verify_release_base
RELEASE_RESULT=running
restore_previous_image() {
case "$PREVIOUS_IMAGE" in
@ -96,6 +108,8 @@ if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
exit 1
fi
caiwu_install_release_files
cd "$REMOTE_DIR"
export IMAGE_TAG RUN_MIGRATIONS
@ -196,6 +210,8 @@ CRON_TZ=Asia/Shanghai
40 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:follow-upstream-columns --quiet >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
# 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。
3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1

View File

@ -7,6 +7,11 @@ REMOTE_DIR="${3:-/opt/caiwuxitong}"
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
RUN_MIGRATIONS="${5:-0}"
KEEP_IMAGES="${6:-2}"
EXPECTED_PREVIOUS_SHA="${7:?Checked production revision is required; update the deployment scripts}"
DEPLOY_ACTOR="${8:-unknown}"
RELEASE_APP_URL="${9:-}"
RELEASE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$RELEASE_DIR/release-guard.sh"
IMAGE_NAME="caiwu_akaunting"
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
@ -14,7 +19,9 @@ cleanup() {
rm -f "$IMAGE_ARCHIVE"
}
trap cleanup EXIT INT TERM
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if [ "$(id -u)" -ne 0 ]; then
echo "activate-release.sh must run as root" >&2
@ -69,6 +76,10 @@ if ! flock -n 9; then
exit 1
fi
RELEASE_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
RELEASE_RESULT=blocked
trap 'release_status=$?; caiwu_record_release_result "$release_status"; cleanup' EXIT
# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。
exec 8>/run/caiwu-upstream-sync.lock
echo "Waiting for active upstream synchronization before replacing the application container"
@ -78,7 +89,8 @@ if ! flock -w 1800 8; then
fi
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
caiwu_verify_release_base
RELEASE_RESULT=running
restore_previous_image() {
case "$PREVIOUS_IMAGE" in
@ -100,6 +112,8 @@ if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
exit 1
fi
caiwu_install_release_files
cd "$REMOTE_DIR"
export IMAGE_TAG RUN_MIGRATIONS
@ -200,6 +214,8 @@ CRON_TZ=Asia/Shanghai
40 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:follow-upstream-columns --quiet >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
# 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。
3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1

View File

@ -0,0 +1,43 @@
#!/bin/sh
# Caller holds the production deployment lock. Does not restart any service.
set -eu
REMOTE_DIR="${1:-/opt/caiwuxitong}"
SOURCE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
LEGACY_STUB="$SOURCE_DIR/retired-activation.sh"
[ "$(id -u)" -eq 0 ] || { echo 'Release protection must be installed as root' >&2; exit 1; }
[ -f "$LEGACY_STUB" ] || { echo 'Missing retired activation stub' >&2; exit 1; }
is_immutable() { lsattr -d "$1" 2>/dev/null | awk '{print $1}' | grep -q i; }
needs_install=0
for legacy in activate-release.sh activate-incremental-release.sh; do
if cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"; then
continue
fi
if is_immutable "$REMOTE_DIR/$legacy"; then
echo "Unexpected immutable legacy entry point: $legacy; verify it before changing protection" >&2
exit 1
fi
needs_install=1
done
[ "$needs_install" -eq 1 ] || exit 0
# Test support on an owned temporary file before touching either entry point.
probe="$(mktemp "$REMOTE_DIR/.release-protection.XXXXXX")"
cleanup_probe() { chattr -i "$probe" 2>/dev/null || true; rm -f "$probe"; }
trap cleanup_probe EXIT
chattr +i "$probe" || { echo 'Filesystem cannot protect legacy entry points; no application was changed' >&2; exit 1; }
chattr -i "$probe"
backup="$REMOTE_DIR/runtime/private/release-protection/$(date -u +%Y%m%dT%H%M%SZ)-$$"
umask 077
mkdir -p "$backup"
for legacy in activate-release.sh activate-incremental-release.sh; do
if cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"; then
continue
fi
[ ! -f "$REMOTE_DIR/$legacy" ] || cp -p "$REMOTE_DIR/$legacy" "$backup/$legacy"
install -m 700 "$LEGACY_STUB" "$REMOTE_DIR/.$legacy.next"
mv "$REMOTE_DIR/.$legacy.next" "$REMOTE_DIR/$legacy"
chattr +i "$REMOTE_DIR/$legacy"
cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"
done
echo 'Legacy activation paths protected; current application and databases were not restarted'

72
deploy/release-guard.sh Normal file
View File

@ -0,0 +1,72 @@
#!/bin/sh
# Sourced by both activation paths after the deployment and synchronization locks.
caiwu_verify_release_base() {
case "$EXPECTED_PREVIOUS_SHA" in
none) ;;
*[!0-9a-f]*|'') echo 'Missing or invalid checked production revision; update the deployment scripts' >&2; return 1 ;;
*) [ "${#EXPECTED_PREVIOUS_SHA}" -eq 40 ] || return 1 ;;
esac
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
[ -n "$PREVIOUS_SHA" ] || PREVIOUS_SHA=none
if [ "$PREVIOUS_SHA" != none ] && ! printf '%s\n' "$PREVIOUS_SHA" | grep -Eq '^[0-9a-f]{40}$'; then
echo 'Invalid production revision marker; refusing activation' >&2
return 1
fi
if [ "$PREVIOUS_SHA" != "$EXPECTED_PREVIOUS_SHA" ]; then
echo "Production changed while this release was being prepared: checked=$EXPECTED_PREVIOUS_SHA current=$PREVIOUS_SHA requested=$DEPLOY_SHA. Fetch latest main and retry; the running application was not changed." >&2
return 1
fi
# A failed/interrupted older activation must not leave the marker ahead of the app.
running_sha="$(docker inspect caiwu_app --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null || true)"
if [ "$PREVIOUS_SHA" != none ] && [ "$running_sha" != "$PREVIOUS_SHA" ]; then
echo "Running image and production marker disagree; refusing activation: marker=$PREVIOUS_SHA image=$running_sha" >&2
return 1
fi
if [ "$PREVIOUS_SHA" = none ] && [ -n "$running_sha" ]; then
echo 'An application is already running without a production marker; verify its revision before deploying' >&2
return 1
fi
}
caiwu_install_release_files() {
# Each uploader has its own directory. Shared runtime files change only inside
# the activation lock and after the production revision has been rechecked.
[ "$RELEASE_DIR" != "$REMOTE_DIR" ] || return 0
release_files='compose.jiangling.yml activate-release.sh activate-incremental-release.sh release-guard.sh retired-activation.sh install-release-protection.sh config-db-init.sh run-finance-batch.sh clickhouse-resource-policy.xml update-runtime-url.sh configure-dingtalk-callback.sh host-nginx-8442-dingtalk.conf setup-incremental-registry.sh registry-config.yml'
for release_file in $release_files; do
[ -f "$RELEASE_DIR/$release_file" ] || { echo "Incomplete staged release: $release_file" >&2; return 1; }
done
sh "$RELEASE_DIR/install-release-protection.sh" "$REMOTE_DIR" || return 1
for release_file in $release_files; do
case "$release_file" in activate-release.sh|activate-incremental-release.sh) continue ;; esac
case "$release_file" in *.sh) release_mode=700 ;; *) release_mode=644 ;; esac
[ "$release_file" != config-db-init.sh ] || release_mode=644
install -m "$release_mode" "$RELEASE_DIR/$release_file" "$REMOTE_DIR/.$release_file.next"
mv "$REMOTE_DIR/.$release_file.next" "$REMOTE_DIR/$release_file"
done
if [ -n "$RELEASE_APP_URL" ]; then
"$REMOTE_DIR/update-runtime-url.sh" "$RELEASE_APP_URL" "$REMOTE_DIR"
fi
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.release-controls-sha.next"
mv "$REMOTE_DIR/.release-controls-sha.next" "$REMOTE_DIR/.release-controls-sha"
}
caiwu_record_release_result() {
release_exit="$1"
[ "$RELEASE_RESULT" != running ] || RELEASE_RESULT=failed
[ "$release_exit" -ne 0 ] || RELEASE_RESULT=success
# Client identity is declared by the workstation; the SSH source is recorded
# separately. Neither field is inferred from the commit author.
release_actor="$(printf '%s' "$DEPLOY_ACTOR" | tr -cd 'A-Za-z0-9_.@:-' | cut -c1-160)"
release_peer="$(printf '%s' "${SSH_CONNECTION:-local}" | tr -cd 'A-Za-z0-9 .:-' | cut -c1-160)"
(
umask 077
mkdir -p "$REMOTE_DIR/runtime/private"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$RELEASE_STARTED_AT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$release_actor" "$release_peer" \
"${PREVIOUS_SHA:-unknown}" "$DEPLOY_SHA" "$RELEASE_RESULT" "$release_exit" "$EXPECTED_PREVIOUS_SHA" \
>> "$REMOTE_DIR/runtime/private/deployments.tsv"
) || echo 'Could not write the private deployment receipt' >&2
}

View File

@ -0,0 +1,4 @@
#!/bin/sh
# This legacy shared path is intentionally retired and immutable on production.
echo 'Legacy activation is disabled. Fetch latest main and use the workstation deployment script with an isolated release directory and checked production revision. The running application was not changed.' >&2
exit 1

View File

@ -35,6 +35,14 @@
抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。
### 成本价镜像观测
`business-mirror:sync-jushuitan-cost-price-history --source=snapshot` 仅比较既有 `ops_jst_sku` 的当前成本价,首次读取和新增商品只建立基线。基线写入 `jushuitan_cost_price_observation_baselines`,后续观测变化写入独立的 `jushuitan_cost_price_observations`;状态使用 `business_mirror_table_states` 中的 `jushuitan.product-cost-price-observations`,不共用正式操作历史的同步状态。生产保留的旧 SKU 级 `jushuitan_cost_price_snapshots` 不作覆盖、删除或追溯导入。
观测任务沿 `_mirror_synced_at + _mirror_key` 索引顺序分批续传,默认每批 500 条、每轮最多 20 批;固定窗口并保留两分钟重叠,基线、事件、游标同事务提交。不进行每轮全表计数。生产任务每十分钟尝试运行,沿用 `/run/caiwu-upstream-sync.lock`,繁忙时跳过。首次基线较大时可由同步节点持有同一互斥锁分批完成。
普通商品资料独立展示每个源商品最近 20 条镜像价格观测,时间明确表示“发现时间”。`modified` 只是商品更新时间,不能充当真实改价时间;采集间隔内的中间价格、建立基线前的历史无法从当前镜像还原。正式操作历史仍使用默认 `--source=open_api`;月度加权成本及其他财务计算继续只读原正式历史,不使用观测结果。
## 经营分析副本库(operations_replica)
店铺维度报表不使用订单件数代替销量。中央刷新节点按上游主键 `Id`

View File

@ -129,11 +129,22 @@ bash scripts/deploy-from-mac.sh
8. 验证唯一私有七牛桶的写入、读取和删除,并把旧本机业务上传幂等复制到七牛;不删除本机原件;
9. 检查服务器本机后端页面;
10. 任一存储验证或页面验证失败时恢复上一个镜像;
11. 使用服务器互斥锁阻止两个人同时切换版本;
11. 每次上传使用独立暂存目录;服务器取得发布锁和同步锁后,再核对线上 SHA 与发布机已检查的 SHA,确认未变化才切换配置和应用;
12. 删除本地临时镜像包并关闭 SSH 隧道;
13. 开发机、服务器 Docker 和服务器 Registry 均仅保留当前和上一个本项目版本;
14. 清理 7 天前的本项目构建缓存,并将总量压到最多 3GB。
### 5.0 多人发布保护
- 远端 `main` 禁止直接推送和强推,任务分支通过 PR 合并;不额外要求人工审批或新增 CI 门槛。
- 发布机先刷新远端 `main`,正式发布必须与最新主线一致,并检查本次版本包含线上提交。
- 上传期间若别人已发布,服务器在锁内拒绝本次激活,运行中的应用保持原版本。先 `git fetch`、快进到最新 `main` 并重新发布,不用回滚开关绕过并发校验。
- Windows 和 macOS 发布机都必须使用最新脚本。共享根目录的两个旧激活入口已停用并设置 Linux immutable 属性,旧发布机无法用上传覆盖它们;新脚本从每次独立暂存目录执行激活,缺少已检查线上版本参数也会被拒绝。
- 运行配置按每次发布独立暂存,核对通过后才在锁内安装;上传文件不会提前覆盖其他发布使用的配置。
- 服务器私有 `runtime/private/deployments.tsv` 记录起止 UTC 时间、发布机声明的身份、SSH 来源、前后 SHA、结果、退出码和已检查的 SHA。声明身份及提交作者不能单独证明具体操作者;记录不包含凭据或业务数据。
- 安装旧入口保护前自动备份到服务器私有 `runtime/private/release-protection/`。普通发布不解除 immutable 属性;维护这些停用入口时先核对备份和目的,再由维护人员使用 `chattr -i` 处理指定文件。应用镜像回滚不需要启用旧入口。
- 仅维护发布控制脚本时,可在发布锁内备份并安装已合并主线的控制脚本,记录独立的 `.release-controls-sha`,不重建应用容器、不修改表示应用镜像版本的 `.deployed-sha`。
### 5.1 安全软件拦截 regctl 时的降级
第 6 步默认用项目专用 `regctl`(未签名)把 BuildKit 原生 OCI 分层包推入服务器本机 Registry。少数开发机的 360 安全卫士、金山毒霸等安全软件会拦截未签名程序,表现为 `regctl-v0.11.5.exe` 启动即报“拒绝访问”。

View File

@ -0,0 +1,42 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer;
class JushuitanCostPriceObservationRepository
{
public function __construct(private readonly GenericBusinessMirrorRepository $mirror) {}
/** 仅当前页每个源商品最近 20 条观测;不混入正式财务取价历史。 */
public function attach(array $rows): void
{
foreach ($rows as $row) {
$row->jushuitan_cost_price_observations = [];
}
$db = $this->mirror->connection();
if ($rows === [] || ! Schema::connection($db->getName())->hasTable(JushuitanCostPriceSnapshotDiffer::OBSERVATIONS)) {
return;
}
$keys = collect($rows)->pluck('_mirror_key')->filter()->unique()->values()->all();
if ($keys === []) {
return;
}
$ranked = $db->table(JushuitanCostPriceSnapshotDiffer::OBSERVATIONS)
->whereIn('source_key', $keys)
->select('*')->selectRaw('ROW_NUMBER() OVER (PARTITION BY source_key ORDER BY observed_at DESC, id DESC) AS observation_rank');
$events = $db->query()->fromSub($ranked, 'ranked')->where('observation_rank', '<=', 20)
->orderBy('observed_at', 'desc')->get()->groupBy('source_key');
foreach ($rows as $row) {
$row->jushuitan_cost_price_observations = ($events->get($row->_mirror_key ?? '') ?? collect())->map(static fn ($event) => [
'id' => $event->id, 'source' => '镜像价格观测(非完整改价历史)',
'record_type_label' => '观测到成本价变化', 'effect_start_time' => $event->observed_at,
'before_price' => $event->before_price, 'after_price' => $event->after_price,
'diff_amount' => (float) $event->after_price - (float) $event->before_price,
'operator' => '-', 'operation' => '镜像快照比较',
'remark' => '上次观测:' . $event->previous_observed_at . ';本次为发现时间,不是真实改价时间。',
])->all();
}
}
}

View File

@ -141,6 +141,7 @@ class ProductCatalogMirrorRepository
$this->attachWorkPlatformPrices($rows, $workPlatformPriceSyncStatus['initialized']);
$jushuitanCostPriceHistorySyncStatus = $this->jushuitanCostPriceHistorySyncStatus();
$this->attachJushuitanCostPriceHistory($rows, $jushuitanCostPriceHistorySyncStatus['initialized']);
app(JushuitanCostPriceObservationRepository::class)->attach($rows);
return [
'data' => $rows,
@ -571,6 +572,7 @@ class ProductCatalogMirrorRepository
$query = $this->connection->table($productTable)
->where('sku_type', 'normal')
->select([
'_mirror_key',
'autoid',
'i_id',
'sku_id',

View File

@ -0,0 +1,137 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
use Throwable;
/** 当前镜像的观测记录;不写正式操作日志,也不推测真实改价时间或历史价格。 */
class JushuitanCostPriceSnapshotDiffer
{
public const DATASET_KEY = 'jushuitan.product-cost-price-observations';
public const SNAPSHOTS = 'jushuitan_cost_price_observation_baselines';
public const OBSERVATIONS = 'jushuitan_cost_price_observations';
public const STATES = 'business_mirror_table_states';
public function __construct(
private readonly GenericBusinessMirrorRepository $mirror,
private readonly BusinessMirrorDatasetRegistry $datasets,
) {}
public function sync(int $batchSize = 500, int $maxBatches = 20): array
{
$lock = Cache::lock('jushuitan-cost-price-observations', 1800);
if (! $lock->get()) {
return ['status' => 'locked', 'rows' => 0, 'changes' => 0];
}
try {
return $this->run(max(1, min(1000, $batchSize)), max(1, min(2000, $maxBatches)));
} finally {
$lock->release();
}
}
private function run(int $batchSize, int $maxBatches): array
{
$db = $this->mirror->connection();
$schema = Schema::connection($db->getName());
$dataset = $this->datasets->get(ProductCatalogMirrorRepository::DATASET_KEY);
if (! $this->mirror->ready($dataset)
|| ! $schema->hasTable(self::SNAPSHOTS) || ! $schema->hasTable(self::OBSERVATIONS)) {
return ['status' => 'source_not_ready', 'rows' => 0, 'changes' => 0];
}
$source = $this->mirror->state($dataset);
if (($source['status'] ?? '') !== 'succeeded') {
return ['status' => 'source_not_ready', 'rows' => 0, 'changes' => 0];
}
$state = $db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->first();
$resume = $state && in_array($state->status, ['running', 'failed'], true);
$cursor = $resume ? json_decode($state->cursor ?? 'null', true) : null;
// 固定窗口、两分钟重叠,兼容源镜像批次时间精度;重复读取不重复记变价。
$from = $resume ? $state->window_started_at
: ($state?->window_ends_at ? Carbon::parse($state->window_ends_at)->subMinutes(2)->format('Y-m-d H:i:s.u') : null);
$until = $resume ? $state->window_ends_at : now()->subSeconds(2)->format('Y-m-d H:i:s.u');
$db->table(self::STATES)->updateOrInsert(['dataset_key' => self::DATASET_KEY], [
'source_key' => 'operations_replica', 'source_object' => 'mirror.' . $dataset->targetTable(),
'target_table' => self::OBSERVATIONS, 'status' => 'running',
'run_mode' => $state?->initial_completed_at ? 'incremental' : 'baseline',
'generation' => $resume ? $state->generation : (string) Str::uuid(),
'window_started_at' => $from, 'window_ends_at' => $until,
'cursor' => $cursor ? json_encode($cursor) : null,
'last_attempted_at' => now(), 'last_error_code' => null, 'updated_at' => now(),
]);
$scanned = $changes = 0;
try {
for ($batch = 0; $batch < $maxBatches; $batch++) {
$query = $db->table($dataset->targetTable())
->select(['_mirror_key', '_mirror_synced_at', 'sku_id', 'cost_price', 'modified'])
->where('sku_type', 'normal')->whereNotNull('cost_price')
->where('_mirror_synced_at', '<=', $until);
if ($from !== null) {
$query->where('_mirror_synced_at', '>=', $from);
}
if ($cursor) {
$query->where(static fn ($q) => $q->where('_mirror_synced_at', '>', $cursor['at'])
->orWhere(static fn ($q) => $q->where('_mirror_synced_at', $cursor['at'])->where('_mirror_key', '>', $cursor['key'])));
}
$rows = $query->orderBy('_mirror_synced_at')->orderBy('_mirror_key')->limit($batchSize)->get();
if ($rows->isEmpty()) {
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
'status' => 'succeeded', 'cursor' => null, 'high_watermark' => json_encode(['at' => $until]),
'initial_completed_at' => $state?->initial_completed_at ?? now(),
'last_succeeded_at' => now(), 'updated_at' => now(),
]);
return ['status' => 'succeeded', 'rows' => $scanned, 'changes' => $changes];
}
$previous = $db->table(self::SNAPSHOTS)->whereIn('source_key', $rows->pluck('_mirror_key'))->get()->keyBy('source_key');
$snapshots = $events = [];
$observedAt = now()->format('Y-m-d H:i:s.u');
foreach ($rows as $row) {
$sku = trim((string) $row->sku_id);
if ($sku === '' || ! is_numeric($row->cost_price)) {
continue;
}
$price = number_format((float) $row->cost_price, 4, '.', '');
$old = $previous->get($row->_mirror_key);
if ($old && $old->sku_id === $sku && number_format((float) $old->cost_price, 4, '.', '') !== $price) {
$events[] = [
'id' => hash('sha256', $row->_mirror_key . '|' . $old->captured_at . '|' . $observedAt . '|' . $price),
'source_key' => $row->_mirror_key, 'sku_id' => $sku,
'before_price' => $old->cost_price, 'after_price' => $price,
'previous_observed_at' => $old->captured_at, 'observed_at' => $observedAt,
'source_modified_at' => $row->modified,
];
}
$snapshots[] = ['source_key' => $row->_mirror_key, 'sku_id' => $sku, 'cost_price' => $price, 'captured_at' => $observedAt];
}
$last = $rows->last();
$cursor = ['at' => $last->_mirror_synced_at, 'key' => $last->_mirror_key];
// 价格基线、观测事件和游标同一事务;中断续传不会漏写或重复记账。
$db->transaction(function () use ($db, $snapshots, $events, $cursor): void {
if ($snapshots) {
$db->table(self::SNAPSHOTS)->upsert($snapshots, ['source_key'], ['sku_id', 'cost_price', 'captured_at']);
}
if ($events) {
$db->table(self::OBSERVATIONS)->insert($events);
}
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
'cursor' => json_encode($cursor), 'updated_at' => now(),
]);
});
$scanned += $rows->count();
$changes += count($events);
}
return ['status' => 'running', 'rows' => $scanned, 'changes' => $changes];
} catch (Throwable $exception) {
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
'status' => 'failed', 'last_error_code' => 'snapshot_comparison_failed', 'updated_at' => now(),
]);
throw $exception;
}
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace Modules\EcommerceFinance\Services;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
/** 财务端货款对账比对汇总 CSV。 */
class SupplierComparisonCsvExporter
{
private const HEADERS = [
'采购单号', '日期', '我方商品编码', '厂家条码', '比对结果',
'我方数量', '我方单价', '我方金额',
'厂家数量', '厂家单价', '厂家金额',
'数量差异', '金额差异', '结算口径', '结算金额',
];
/**
* @param array<string,mixed> $detail
*/
public function download(
array $detail,
string $supplier,
string $month,
string $reconciler,
): StreamedResponse {
$safeSupplier = preg_replace('/[\\\\\\/:*?"<>|]+/u', '_', $supplier) ?: '厂家';
$period = $month !== '' ? preg_replace('/[^0-9-]+/u', '', $month) : '全部月份';
$filename = $safeSupplier . '_货款对账比对_' . $period . '_' . now()->format('Ymd_His') . '.csv';
$summary = (array) ($detail['summary'] ?? []);
$rows = (array) ($detail['rows'] ?? []);
return response()->streamDownload(function () use ($detail, $rows, $summary, $supplier, $month, $reconciler): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new RuntimeException('无法创建导出文件。');
}
fwrite($csv, "\xEF\xBB\xBF");
$this->write($csv, ['供应商', $supplier, '月份', $month !== '' ? $month : '全部月份', '对账人', $reconciler !== '' ? $reconciler : '全部']);
$this->write($csv, [
'我方数据行数', (int) ($detail['mineRows'] ?? 0),
'厂家数据行数', (int) ($detail['theirsRows'] ?? 0),
'比对结果条数', count($rows),
]);
$this->write($csv, [
'双方一致', (int) ($summary['match'] ?? 0),
'厂家多', (int) ($summary['supplierExtra'] ?? 0),
'数量/金额不一致', (int) ($summary['mismatch'] ?? 0),
'厂家少', (int) ($summary['supplierMissing'] ?? 0),
]);
$this->write($csv, [
'我方金额合计', $this->money($summary['mineAmount'] ?? 0),
'厂家金额合计', $this->money($summary['theirsAmount'] ?? 0),
'厂家多算金额', $this->money($summary['supplierOverAmount'] ?? 0),
'按口径结算合计', $this->money($summary['settleAmount'] ?? 0),
'差异金额', $this->money($summary['differenceAmount'] ?? 0),
]);
$this->write($csv, []);
$this->write($csv, self::HEADERS);
foreach ($rows as $row) {
$this->write($csv, [
$row['purchaseNo'] ?? '',
$row['date'] ?? '',
$row['barcode'] ?? '',
$row['supplierBarcode'] ?? '',
$row['type'] ?? '',
$this->number($row['mineQty'] ?? 0),
$this->money($row['mineUnitPrice'] ?? 0),
$this->money($row['mineAmount'] ?? 0),
$this->number($row['theirsQty'] ?? 0),
$this->money($row['theirsUnitPrice'] ?? 0),
$this->money($row['theirsAmount'] ?? 0),
$this->quantityDifference($row['qtyDifference'] ?? 0),
$this->amountDifference($row['amountDifference'] ?? 0),
$row['note'] ?? '',
$this->money($row['settleAmount'] ?? 0),
]);
}
fclose($csv);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @param resource $csv @param list<mixed> $values */
private function write($csv, array $values): void
{
fputcsv($csv, array_map(fn (mixed $value): string => $this->csvCell($value), $values));
}
private function quantityDifference(mixed $value): string
{
$difference = (float) $value;
if (abs($difference) < 0.005) {
return '一致';
}
return '厂家' . ($difference > 0 ? '多 ' : '少 ') . $this->number(abs($difference));
}
private function amountDifference(mixed $value): string
{
$difference = (float) $value;
if (abs($difference) < 0.005) {
return '一致';
}
return '厂家' . ($difference > 0 ? '多 ¥' : '少 ¥') . $this->money(abs($difference));
}
private function number(mixed $value): string
{
return rtrim(rtrim(number_format((float) $value, 2, '.', ''), '0'), '.');
}
private function money(mixed $value): string
{
return number_format((float) $value, 2, '.', '');
}
private function csvCell(mixed $value): string
{
$text = trim((string) $value);
if (is_numeric(str_replace(',', '', $text))) {
return $text;
}
return preg_match('/^[=+\-@\t\r]/u', $text) === 1 ? "\t" . $text : $text;
}
}

View File

@ -165,7 +165,7 @@ class SupplierDingTalkNotifier
'- 对账月份:' . ($month !== '' ? $month : '全部月份'),
'- 对账人:' . ($reconciler !== '' ? $reconciler : '全部'),
'- 差异共 **' . $summary['count'] . '** 条(厂家多 ' . $summary['extra']
. ' / 金额数量不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
. ' / 数量金额不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
'- 差异金额:**¥' . number_format($summary['difference_amount'], 2) . '**',
];
@ -179,22 +179,29 @@ class SupplierDingTalkNotifier
$theirsQty = (float) ($row['theirsQty'] ?? 0);
$mineAmount = (float) ($row['mineAmount'] ?? 0);
$theirsAmount = (float) ($row['theirsAmount'] ?? 0);
$mineUnitPrice = (float) ($row['mineUnitPrice'] ?? $row['unitPrice'] ?? 0);
$theirsUnitPrice = (float) ($row['theirsUnitPrice'] ?? $row['unitPrice'] ?? 0);
$purchaseNo = trim((string) ($row['purchaseNo'] ?? ''));
$date = trim((string) ($row['date'] ?? ''));
$barcode = trim((string) ($row['barcode'] ?? ''));
$supplierBarcode = trim((string) ($row['supplierBarcode'] ?? ''));
$lines[] = sprintf(
'%d. **%s** `%s`%s 单价 ¥%s|我方 %s / ¥%s|厂家 %s / ¥%s|%s',
'%d. **%s**|采购单号:%s|日期:%s|商品编码:%s%s',
$rowOffset + $index + 1,
(string) ($row['type'] ?? '差异'),
$purchaseNo !== '' ? $purchaseNo : '—',
$date !== '' ? $date : '—',
$barcode,
$supplierBarcode !== '' ? '(厂家条码 ' . $supplierBarcode . ')' : '',
number_format((float) ($row['unitPrice'] ?? 0), 2),
$this->number($mineQty),
number_format($mineAmount, 2),
$this->number($theirsQty),
number_format($theirsAmount, 2),
(string) ($row['note'] ?? ''),
);
$lines[] = ' 我方:数量 ' . $this->number($mineQty)
. '|单价 ¥' . number_format($mineUnitPrice, 2)
. '|金额 ¥' . number_format($mineAmount, 2);
$lines[] = ' 厂家:数量 ' . $this->number($theirsQty)
. '|单价 ¥' . number_format($theirsUnitPrice, 2)
. '|金额 ¥' . number_format($theirsAmount, 2);
$lines[] = ' 处理:' . (string) ($row['note'] ?? '');
}
$lines[] = '';

View File

@ -4,9 +4,9 @@ namespace Modules\EcommerceFinance\Services;
/**
* 厂家对账单 vs 我方结算数据:
* - 行键 = 条码(去掉厂家编号/版本后缀,如 CJ019312N1097V01 → CJ019312)+ 单价
* (同一把钥匙的多行先合并:数量、金额相加);
* - 四类结果:一致 / 厂家多(不结算、退回)/ 金额不一致(按金额低的一方结算)/ 厂家少(按厂家数据结算,即不付款)。
* - 行键 = 采购单号 + 日期 + 我方商品编码(对应厂家条码去掉厂家编号/版本后缀);
* - 同一行键的多行先合并数量与金额,再比较双方数量和金额;
* - 厂家未填采购单号时,仅在同日期+商品编码能唯一对应我方一条数据时兜底匹配。
*/
class SupplierStatementMatcher
{
@ -14,7 +14,7 @@ class SupplierStatementMatcher
public const TYPE_SUPPLIER_EXTRA = '厂家多';
public const TYPE_MISMATCH = '金额不一致';
public const TYPE_MISMATCH = '数量/金额不一致';
public const TYPE_SUPPLIER_MISSING = '厂家少';
@ -22,74 +22,129 @@ class SupplierStatementMatcher
private const SUPPLIER_SUFFIX = '/[A-Z]{1,3}\d{3,6}V\d{1,3}$/i';
/**
* @param list<array<string,mixed>> $mine 我方结算数据(货款对账:skuCode/unitPrice/qty/amount)
* @param list<array<string,mixed>> $theirs 厂家对账单(barcode/unitPrice/qty/amount)
* @param list<array<string,mixed>> $mine 我方数据(purchaseNo/date/productCode/unitPrice/qty/amount;productCode 为空才回退货号)
* @param list<array<string,mixed>> $theirs 厂家数据(purchaseNo/createdAt/barcode/unitPrice/qty/amount)
* @return array{rows:list<array<string,mixed>>,summary:array<string,mixed>}
*/
public function compare(array $mine, array $theirs): array
{
$mineGrouped = $this->group($mine, 'skuCode');
$theirsGrouped = $this->group($theirs, 'barcode');
$mineGrouped = $this->group($mine, ['productCode', 'skuCode'], 'purchaseNo', 'date');
$theirsGrouped = $this->group($theirs, ['barcode'], 'purchaseNo', 'createdAt');
// 已有采购单号时严格按三元组匹配;缺失采购单号时,仅在日期+商品编码唯一时兜底。
$fallbackIndex = [];
foreach ($mineGrouped as $key => $row) {
$fallbackIndex[$row['date'] . "\0" . $row['barcodeKey']][] = $key;
}
$theirsGrouped = $this->resolveMissingPurchaseNumbers($theirsGrouped, $fallbackIndex);
$rows = [];
$summary = ['match' => 0, 'supplierExtra' => 0, 'mismatch' => 0, 'supplierMissing' => 0,
'mineAmount' => 0.0, 'settleAmount' => 0.0];
$summary = [
'match' => 0,
'supplierExtra' => 0,
'mismatch' => 0,
'supplierMissing' => 0,
'mineAmount' => 0.0,
'theirsAmount' => 0.0,
'supplierOverQty' => 0.0,
'supplierOverAmount' => 0.0,
'settleAmount' => 0.0,
];
foreach (array_unique(array_merge(array_keys($mineGrouped), array_keys($theirsGrouped))) as $key) {
$mineRow = $mineGrouped[$key] ?? null;
$theirsRow = $theirsGrouped[$key] ?? null;
$barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode']);
$supplierBarcode = (string) ($theirsRow['barcode'] ?? '');
$unitPrice = (float) ($mineRow['unitPrice'] ?? $theirsRow['unitPrice']);
$purchaseNo = (string) ($mineRow['purchaseNo'] ?? $theirsRow['purchaseNo'] ?? '');
$date = (string) ($mineRow['date'] ?? $theirsRow['date'] ?? '');
$barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode'] ?? '');
$supplierBarcode = (string) ($theirsRow['supplierBarcode'] ?? '');
$mineUnitPrice = (float) ($mineRow['unitPrice'] ?? 0);
$theirsUnitPrice = (float) ($theirsRow['unitPrice'] ?? 0);
$unitPrice = $mineUnitPrice > 0 ? $mineUnitPrice : $theirsUnitPrice;
$mineQty = (float) ($mineRow['qty'] ?? 0);
$mineAmount = (float) ($mineRow['amount'] ?? 0);
$theirsQty = (float) ($theirsRow['qty'] ?? 0);
$theirsAmount = (float) ($theirsRow['amount'] ?? 0);
$qtyDifference = $theirsQty - $mineQty;
$amountDifference = $theirsAmount - $mineAmount;
$quantityDifference = $this->describeDifference($qtyDifference, $amountDifference);
$fallbackNote = '';
if (($theirsRow['matchedByFallback'] ?? false) === true) {
$fallbackNote = '厂家未填采购单号,已按日期+商品编码唯一匹配;';
} elseif (($theirsRow['fallbackAmbiguous'] ?? false) === true) {
$fallbackNote = '厂家未填采购单号,且同日期+商品编码有多条我方数据,无法唯一匹配;';
}
if ($mineRow === null) {
$type = self::TYPE_SUPPLIER_EXTRA;
$settle = 0.0;
$note = '我方没有这条数据:不结算,退回厂家修改账单';
$note = $fallbackNote . '厂家整条多报:' . $quantityDifference . ';我方没有该采购单号+日期+商品编码,不结算';
$summary['supplierExtra']++;
} elseif ($theirsRow === null) {
$type = self::TYPE_SUPPLIER_MISSING;
$settle = 0.0;
$note = '厂家少报了这条数据:按厂家数据结算(不付款)';
$note = '厂家漏报:我方数量 ' . $this->number($mineQty)
. ',单价 ¥' . number_format($mineUnitPrice, 2)
. ',金额 ¥' . number_format($mineAmount, 2)
. ';按厂家数据结算(不付款)';
$summary['supplierMissing']++;
} elseif (abs($mineAmount - $theirsAmount) < 0.005 && abs($mineQty - $theirsQty) < 0.005) {
$type = self::TYPE_MATCH;
$settle = $mineAmount;
$note = '正常结算';
$note = $fallbackNote . '采购单号、日期、商品编码、数量和金额一致,正常结算';
$summary['match']++;
} else {
$type = self::TYPE_MISMATCH;
$settle = min($mineAmount, $theirsAmount);
$note = '金额/数量不一致:按金额低的一方结算';
$note = $fallbackNote . $quantityDifference . ';按金额低的一方结算,请厂家修改';
$summary['mismatch']++;
}
$summary['mineAmount'] += $mineAmount;
$summary['theirsAmount'] += $theirsAmount;
$summary['supplierOverQty'] += max($qtyDifference, 0);
$summary['supplierOverAmount'] += max($amountDifference, 0);
$summary['settleAmount'] += $settle;
$rows[] = [
'purchaseNo' => $purchaseNo,
'date' => $date,
'barcode' => $barcode,
'supplierBarcode' => $supplierBarcode === $barcode ? '' : $supplierBarcode,
'supplierBarcode' => $supplierBarcode !== '' && $supplierBarcode !== $barcode ? $supplierBarcode : '',
'unitPrice' => $unitPrice,
'mineUnitPrice' => $mineUnitPrice,
'theirsUnitPrice' => $theirsUnitPrice,
'mineQty' => $mineQty,
'mineAmount' => $mineAmount,
'theirsQty' => $theirsQty,
'theirsAmount' => $theirsAmount,
'qtyDifference' => round($qtyDifference, 4),
'amountDifference' => round($amountDifference, 2),
'supplierOverQty' => round(max($qtyDifference, 0), 4),
'supplierOverAmount' => round(max($amountDifference, 0), 2),
'type' => $type,
'settleAmount' => $settle,
'note' => $note,
];
}
usort($rows, static fn (array $a, array $b): int => [$a['type'] === self::TYPE_MATCH ? 1 : 0, $a['barcode']]
<=> [$b['type'] === self::TYPE_MATCH ? 1 : 0, $b['barcode']]);
usort($rows, static fn (array $a, array $b): int => [
$a['type'] === self::TYPE_MATCH ? 1 : 0,
$a['date'],
$a['purchaseNo'],
$a['barcode'],
] <=> [
$b['type'] === self::TYPE_MATCH ? 1 : 0,
$b['date'],
$b['purchaseNo'],
$b['barcode'],
]);
$summary['mineAmount'] = round($summary['mineAmount'], 2);
$summary['theirsAmount'] = round($summary['theirsAmount'], 2);
$summary['supplierOverQty'] = round($summary['supplierOverQty'], 4);
$summary['supplierOverAmount'] = round($summary['supplierOverAmount'], 2);
$summary['settleAmount'] = round($summary['settleAmount'], 2);
$summary['differenceAmount'] = round($summary['mineAmount'] - $summary['settleAmount'], 2);
@ -97,36 +152,176 @@ class SupplierStatementMatcher
}
/**
* 按「条码 + 单价」归组,数量与金额相加。
* 按「采购单号 + 日期 + 商品编码」归组,数量与金额相加。
*
* @param list<array<string,mixed>> $rows
* @return array<string,array{barcode:string,unitPrice:float,qty:float,amount:float}>
* @param list<string> $barcodeFields
* @return array<string,array<string,mixed>>
*/
private function group(array $rows, string $barcodeField): array
private function group(array $rows, array $barcodeFields, string $purchaseField, string $dateField): array
{
$grouped = [];
foreach ($rows as $row) {
$barcode = trim((string) ($row[$barcodeField] ?? ''));
$barcode = $this->barcodeValue($row, $barcodeFields);
if ($barcode === '') {
continue;
}
$barcodeKey = $this->normalizeBarcode($barcode);
$purchaseNo = $this->normalizePurchaseNo($row[$purchaseField] ?? '');
$date = $this->normalizeDate($row[$dateField] ?? '');
$unitPrice = (float) ($row['unitPrice'] ?? 0);
$key = $this->normalizeBarcode($barcode) . '|' . number_format($unitPrice, 4, '.', '');
$key = $this->matchKey($purchaseNo, $date, $barcodeKey);
$grouped[$key] ??= ['barcode' => $barcode, 'unitPrice' => $unitPrice, 'qty' => 0.0, 'amount' => 0.0];
$grouped[$key] ??= [
'purchaseNo' => $purchaseNo,
'date' => $date,
'barcode' => $barcode,
'barcodeKey' => $barcodeKey,
'supplierBarcode' => in_array('barcode', $barcodeFields, true) ? $barcode : '',
'unitPrice' => $unitPrice,
'unitPrices' => [],
'qty' => 0.0,
'amount' => 0.0,
'matchedByFallback' => false,
'fallbackAmbiguous' => false,
];
if ($unitPrice > 0) {
$grouped[$key]['unitPrices'][number_format($unitPrice, 4, '.', '')] = true;
}
$grouped[$key]['qty'] += (float) ($row['qty'] ?? 0);
$grouped[$key]['amount'] += (float) ($row['amount'] ?? 0);
}
foreach ($grouped as &$group) {
$group = $this->finalizeGroup($group);
}
unset($group);
return $grouped;
}
/** @param array<string,mixed> $row @param list<string> $fields */
private function barcodeValue(array $row, array $fields): string
{
foreach ($fields as $field) {
$value = trim((string) ($row[$field] ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
/**
* @param array<string,array<string,mixed>> $groups
* @param array<string,list<string>> $fallbackIndex
* @return array<string,array<string,mixed>>
*/
private function resolveMissingPurchaseNumbers(array $groups, array $fallbackIndex): array
{
$resolved = [];
foreach ($groups as $key => $group) {
$targetKey = $key;
if ($group['purchaseNo'] === '') {
$candidates = $fallbackIndex[$group['date'] . "\0" . $group['barcodeKey']] ?? [];
if (count($candidates) === 1) {
$targetKey = $candidates[0];
$group['matchedByFallback'] = true;
} elseif (count($candidates) > 1) {
$group['fallbackAmbiguous'] = true;
}
}
if (isset($resolved[$targetKey])) {
$resolved[$targetKey] = $this->mergeGroup($resolved[$targetKey], $group);
} else {
$resolved[$targetKey] = $group;
}
}
return $resolved;
}
/** @param array<string,mixed> $left @param array<string,mixed> $right */
private function mergeGroup(array $left, array $right): array
{
$left['qty'] += (float) $right['qty'];
$left['amount'] += (float) $right['amount'];
$left['unitPrices'] += (array) $right['unitPrices'];
$left['matchedByFallback'] = (bool) $left['matchedByFallback'] || (bool) $right['matchedByFallback'];
$left['fallbackAmbiguous'] = (bool) $left['fallbackAmbiguous'] || (bool) $right['fallbackAmbiguous'];
if ($left['supplierBarcode'] === '' && $right['supplierBarcode'] !== '') {
$left['supplierBarcode'] = $right['supplierBarcode'];
}
return $this->finalizeGroup($left);
}
/** @param array<string,mixed> $group @return array<string,mixed> */
private function finalizeGroup(array $group): array
{
$prices = array_keys((array) ($group['unitPrices'] ?? []));
if (count($prices) === 1) {
$group['unitPrice'] = (float) $prices[0];
} elseif ((float) $group['qty'] != 0.0) {
$group['unitPrice'] = round((float) $group['amount'] / (float) $group['qty'], 4);
}
return $group;
}
private function matchKey(string $purchaseNo, string $date, string $barcode): string
{
return $purchaseNo . "\0" . $date . "\0" . $barcode;
}
private function normalizePurchaseNo(mixed $value): string
{
$purchaseNo = ltrim(trim((string) $value), "'");
return preg_match('/^(\d+)\.0+$/', $purchaseNo, $matches) === 1 ? $matches[1] : $purchaseNo;
}
private function normalizeDate(mixed $value): string
{
$raw = trim((string) $value);
if (preg_match('/^(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})/', $raw, $matches) === 1) {
$year = (int) $matches[1];
$month = (int) $matches[2];
$day = (int) $matches[3];
if (checkdate($month, $day, $year)) {
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}
return $raw;
}
private function describeDifference(float $qtyDifference, float $amountDifference): string
{
$parts = [];
if (abs($qtyDifference) >= 0.005) {
$parts[] = '厂家数量' . ($qtyDifference > 0 ? '多 ' : '少 ') . $this->number(abs($qtyDifference));
}
if (abs($amountDifference) >= 0.005) {
$parts[] = '厂家金额' . ($amountDifference > 0 ? '多 ¥' : '少 ¥') . number_format(abs($amountDifference), 2);
}
return $parts === [] ? '数量、金额一致' : implode(',', $parts);
}
private function number(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
/** 去掉条码末尾的「厂家编号 + 版本」后缀,双方口径统一后再匹配。 */
private function normalizeBarcode(string $barcode): string
{
$normalized = preg_replace(self::SUPPLIER_SUFFIX, '', $barcode);
return $normalized === null || $normalized === '' ? $barcode : $normalized;
return strtoupper($normalized === null || $normalized === '' ? $barcode : $normalized);
}
}

View File

@ -12,7 +12,7 @@ use Modules\EcommerceFinance\Services\SupplierStatementStore;
/**
* 「供应商对账」页面数据:
* - 财务端数据:按供应商汇总 + 行级差异(条码 + 单价 匹配,四类结果带结算口径)
* - 财务端数据:按供应商汇总 + 行级差异(采购单号 + 日期 + 条码匹配,四类结果带结算口径)
* - 厂家端数据:厂家上传的对账单(实时)与只读的退货明细
*/
class SupplierReconciliationPageData
@ -23,6 +23,9 @@ class SupplierReconciliationPageData
/** 退货明细行较高:每页 10 行,并在页面内固定表头滚动查看。 */
private const RETURN_PER_PAGE = 10;
/** 财务端比对明细:每页 15 条。 */
private const COMPARISON_PER_PAGE = 15;
public function __construct(
private readonly SupplierPayableOverrideStore $store,
private readonly SupplierStatementStore $statements,
@ -109,17 +112,31 @@ class SupplierReconciliationPageData
}
// 选中厂家时页面切换成「比对明细」视图(列表不再渲染,避免 100+ 家时往下翻)。
$selected = trim((string) request()->query('supplier', ''));
$supplierQuery = trim((string) request()->query('supplier', ''));
$selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : '';
$supplierNotFound = $supplierQuery !== '' && $selected === '';
$detail = null;
if ($selected !== '' && in_array($selected, $supplierNames, true)) {
if ($selected !== '') {
$detail = $this->detail($mine, $selected, $companyId);
} else {
$selected = '';
$totalRows = count($detail['rows']);
$pages = max((int) ceil($totalRows / self::COMPARISON_PER_PAGE), 1);
$currentPage = min(max((int) request()->query('compare_page', 1), 1), $pages);
$detail['total_rows'] = $totalRows;
$detail['page'] = $currentPage;
$detail['pages'] = $pages;
$detail['per_page'] = self::COMPARISON_PER_PAGE;
$detail['rows'] = array_slice(
$detail['rows'],
($currentPage - 1) * self::COMPARISON_PER_PAGE,
self::COMPARISON_PER_PAGE,
);
}
return [
'view' => 'business.pages.supplier-reconciliation.finance-data',
'supplierNames' => $supplierNames,
'supplierQuery' => $supplierQuery,
'supplierNotFound' => $supplierNotFound,
'uploads' => $uploads,
'months' => $months,
'month' => $month,

View File

@ -865,6 +865,7 @@
<div><dt>原始范围</dt><dd data-quote-detail="range"></dd></div>
<div><dt>执行日期</dt><dd data-quote-detail="date"></dd></div>
<div><dt>公式单元格</dt><dd data-quote-detail="formulas"></dd></div>
<div><dt>系统计费规则</dt><dd data-quote-detail="billing"></dd></div>
</dl>
<div class="quote-signed" data-quote-signed hidden>
<span class="material-icons-outlined" aria-hidden="true">task_alt</span>
@ -1180,6 +1181,7 @@
setDetail('range', text(quote.source_range, '-'));
setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注'));
setDetail('formulas', String(quote.formula_count));
setDetail('billing', text(quote.billing_summary, '未设置(运费需人工核算)'));
var originals = root.querySelector('[data-stock-original-links]');
originals.replaceChildren();
(quote.stock_originals || []).forEach(function (file, index, files) {

View File

@ -92,14 +92,6 @@
@endforeach
</select>
</div>
<label class="product-cost-check">
<input type="checkbox" name="missing_price" value="1" @checked(($filters['missing_price'] ?? '') === '1')>
只看未匹配商品单价
</label>
<label class="product-cost-check">
<input type="checkbox" name="include_excluded" value="1" @checked(($filters['include_excluded'] ?? '') === '1')>
包含「-不看」仓
</label>
<div class="product-cost-actions">
<button class="product-cost-button primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
<a class="product-cost-button" href="{{ route('business.financial-operations.product-costs.export', array_merge(['company_id' => company_id()], request()->except('page'))) }}" aria-label="导出当前筛选结果" title="导出当前筛选结果"><span class="material-icons-outlined" aria-hidden="true">download</span>导出</a>
@ -113,14 +105,18 @@
<div class="product-cost-note">
口径:以「业绩明细」的账单行为基数(发生时间 / 店铺名称 / 线上单号),按「店铺 + 线上订单号」展开订单侧商品行,
只取收入金额有数据且不为 0 的账单行,一行 = 一个发货仓库的一个商品;发货仓库取订单的聚水潭仓库名称,仓库简称查仓库映射表,
只取收入金额有数据且不为 0 的账单行,「种菜金额」有数据(&gt;0)的账单行整条排除、不参与匹配;
一行 = 一个发货仓库的一个商品;发货仓库取订单的聚水潭仓库名称,
仓库简称按仓库映射表用「仓储方」匹配「仓库名含主仓」的那条取简称(没有主仓行时认同名仓库,
常州简尚家居有限公司对应映射表里的「配件单仓」行),
商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价;匹配不到时退回「线上代发价格」
(采购单价优先、其次钉钉代发单价),仍匹配不到时用「普通商品资料」里的商品成本价兜底;商品成本 = 商品数量 × 商品单价。
拆单子单重复列出的同一批商品只算一次,同一单分多仓发货的按子单实际仓库分行;虚拟条码(X0000x)不计入。
拆单子单重复列出的同一批商品只算一次,同一单分多仓发货的按子单实际仓库分行;虚拟条码(X0000x)不计入;
套餐/服务占位编码 TCXJ、FUWU 不取数(组合装里拆出的这两个编码同样剔除)。
定制配件这类明细行,聚水潭把配件件数写进了「商品数量」(例如数量 9524、金额 2000.04,实际单价 0.21),
乘成品的月加权/代发单价会放大几百倍;成本单价高于该行明细单价 3 倍时判为「数量与单价口径不一致」,成本留空、不计入合计。
仓库映射表里标记为「-不看」的仓库(次品 / 销退 / 虚拟 / 信誉订单 / 京东云仓等)默认不展示,
需要核对时可以勾选「包含『-不看』仓」。
页面默认展示全部数据:已匹配/未匹配商品单价的行、仓库映射表里标记为「-不看」的仓
(次品 / 销退 / 虚拟 / 信誉订单 / 京东云仓等)都纳入列表与统计。
</div>
<div class="product-cost-summary">
@ -133,7 +129,6 @@
<div class="item"><span>其中用商品资料成本价兜底</span><b>{{ number_format((int) ($summary['catalog_price_rows'] ?? 0)) }}</b></div>
<div class="item"><span>数量与单价口径不一致(不计成本)</span><b>{{ number_format((int) ($summary['unit_mismatch_rows'] ?? 0)) }}</b></div>
<div class="item {{ ($summary['missing_warehouse_rows'] ?? 0) > 0 ? 'is-warn' : '' }}"><span>未匹配仓库简称行数</span><b>{{ number_format((int) ($summary['missing_warehouse_rows'] ?? 0)) }}</b></div>
<div class="item"><span>已隐藏的「-不看」仓行数</span><b>{{ number_format((int) ($summary['excluded_rows'] ?? 0)) }}</b></div>
</div>
<div class="product-cost-table-wrap">

View File

@ -778,7 +778,7 @@
</div>
@endif
@if (!($productCatalogJushuitanCostPriceHistorySyncStatus['initialized'] ?? false) && !$productCatalogError)
@if ((!($productCatalogJushuitanCostPriceHistorySyncStatus['initialized'] ?? false) || ($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'failed') && !$productCatalogError)
<div
class="product-catalog-notice"
role="status"
@ -786,7 +786,9 @@
>
<span class="material-icons-outlined text-base" aria-hidden="true">schedule</span>
<span>
@if (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initializing')
@if (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'failed')
聚水潭成本价历史最近同步失败,记录可能不完整;镜像价格观测单独展示,不代表完整历史。
@elseif (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initializing')
聚水潭成本价记录正在执行首次同步,完成前不显示记录数量。
@elseif (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initial_failed')
聚水潭成本价记录首次同步失败,当前记录数量不代表聚水潭真实历史。
@ -905,6 +907,14 @@
<span class="product-catalog-history-count">{{ $jushuitanHistoryInitialized ? $jushuitanHistoryCount . ' 条' : '待同步' }}</span>
<script id="{{ $jushuitanHistoryId }}" type="application/json">@json($jushuitanHistory, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)</script>
</div>
@php($observations = $row->jushuitan_cost_price_observations ?? [])
<div class="product-catalog-history-action">
<button type="button" class="product-catalog-icon-button" data-price-history-trigger data-history-target="{{ $jushuitanHistoryId }}-observations" data-history-source="镜像价格观测(发现时间,非真实改价时间)" data-sku-code="{{ $row->sku_id ?? '' }}" data-product-name="{{ $row->name ?? '' }}" data-cost-price="{{ $row->cost_price ?? '' }}" data-work-platform-price="{{ $row->work_platform_price ?? '' }}" aria-label="查看镜像价格观测" title="仅从建立基线后记录价格变化,最多展示最近20条,不代表完整操作历史" @disabled($observations === [])>
<span class="material-icons-outlined text-base" aria-hidden="true">visibility</span>
</button>
<span class="product-catalog-history-count">观测 {{ count($observations) }} 条(最近20条)</span>
<script id="{{ $jushuitanHistoryId }}-observations" type="application/json">@json($observations, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)</script>
</div>
</td>
<td>
<div class="product-catalog-history-action">

View File

@ -36,6 +36,9 @@
.supplier-reconciliation-page .sr-summary-cell { padding: 10px 12px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; display: grid; gap: 4px; }
.supplier-reconciliation-page .sr-summary-cell b { font-size: 1.125rem; font-weight: 600; }
.supplier-reconciliation-page .sr-summary-cell span { color: var(--vip-content-muted, #595959); font-size: .75rem; }
.supplier-reconciliation-page .sr-comparison-scroll { max-height: min(62vh, 560px); overflow: auto; scrollbar-gutter: stable; }
.supplier-reconciliation-page .sr-comparison-scroll thead th { position: sticky; top: 0; z-index: 4; box-shadow: 0 1px 0 var(--vip-border, #D8D2C7); }
.supplier-reconciliation-page .sr-pagination { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--vip-content-muted, #595959); font-size: .8125rem; }
.supplier-reconciliation-page .sr-robot-config { padding: 10px 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; }
.supplier-reconciliation-page .sr-robot-config summary { cursor: pointer; color: #5B5343; font-size: .8125rem; font-weight: 600; }
.supplier-reconciliation-page .sr-robot-form { display: grid; grid-template-columns: minmax(260px, 2fr) minmax(180px, 1fr) auto; gap: 10px; align-items: end; margin-top: 12px; }
@ -82,12 +85,17 @@
</select>
</label>
<label class="sr-field"><span>供应商</span>
<select name="supplier" onchange="this.form.submit()">
<option value="">请选择厂家</option>
<input type="search"
name="supplier"
value="{{ $supplierQuery !== '' ? $supplierQuery : $selectedSupplier }}"
list="finance-supplier-options"
placeholder="输入厂家名称搜索"
autocomplete="off">
<datalist id="finance-supplier-options">
@foreach ($supplierNames as $supplier)
<option value="{{ $supplier }}" @selected($selectedSupplier === $supplier)>{{ $supplier }}</option>
<option value="{{ $supplier }}"></option>
@endforeach
</select>
</datalist>
</label>
<button type="submit" class="sr-button is-primary">查询</button>
</div>
@ -97,10 +105,16 @@
<a href="{{ route('vendors.index') }}" class="sr-button">厂家登录账号管理</a>
@endcanany
</form>
@if ($supplierNotFound)
<div class="biz-alert biz-alert-error" role="alert">
未找到名为「{{ $supplierQuery }}」的供应商,请输入完整名称并从搜索建议中选择。
</div>
@endif
<p class="sr-hint">
比对口径:按「条码 + 单价」逐行核对(同一把钥匙的多行先合并数量与金额)。一致 → 正常结算;
厂家多(我方没有)→ 不结算、退回厂家修改账单;金额/数量不一致 → 取金额低的一方结算;
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家上传的对账单在「厂家端数据」实时可查。<br>
比对口径:按「采购单号 + 日期 + 商品编码」核对,以我方货款对账的“商品编码”匹配厂家条码;同一采购单号、日期和商品编码的多行先合并数量与金额。
数量、金额一致 → 正常结算;厂家多(我方没有)→ 不结算、退回修改;
数量或金额不一致 → 明确列出双方数量、单价、金额及厂家多算部分,取金额低的一方结算;
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家未填采购单号时,仅在同日期+商品编码能唯一匹配我方一条数据时兜底。<br>
厂家登录:在「厂家登录账号管理」新增供应商并勾选可登录,供应商名称必须与对账数据中的厂家名称完全一致;
厂家登录后只能查看自己的对账单上传记录、全部退货明细及导出数据。
</p>
@ -126,6 +140,12 @@
</span>
<span style="flex:1 1 auto"></span>
<a href="{{ $listUrl }}" class="sr-button">← 返回供应商列表</a>
<a href="{{ route('business.supplier-reconciliation.comparison.export', array_filter([
'supplier' => $selectedSupplier,
'month' => $month,
'reconciler' => $reconciler,
], static fn (mixed $value): bool => $value !== '' && $value !== null)) }}"
class="sr-button">导出比对汇总</a>
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
@if ($differenceCount > 0)
<form method="POST"
@ -242,13 +262,15 @@
<div class="sr-summary">
<div class="sr-summary-cell"><span>双方一致</span><b>{{ $detail['summary']['match'] }}</b></div>
<div class="sr-summary-cell"><span>厂家多(退回修改)</span><b>{{ $detail['summary']['supplierExtra'] }}</b></div>
<div class="sr-summary-cell"><span>金额不一致(按低结算)</span><b>{{ $detail['summary']['mismatch'] }}</b></div>
<div class="sr-summary-cell"><span>数量/金额不一致(按低结算)</span><b>{{ $detail['summary']['mismatch'] }}</b></div>
<div class="sr-summary-cell"><span>厂家少(按厂家结算)</span><b>{{ $detail['summary']['supplierMissing'] }}</b></div>
<div class="sr-summary-cell"><span>我方金额合计</span><b>{{ number_format($detail['summary']['mineAmount'], 2) }}</b></div>
<div class="sr-summary-cell"><span>厂家金额合计</span><b>{{ number_format($detail['summary']['theirsAmount'], 2) }}</b></div>
<div class="sr-summary-cell"><span>厂家多算金额</span><b>{{ number_format($detail['summary']['supplierOverAmount'], 2) }}</b></div>
<div class="sr-summary-cell"><span>按口径结算合计</span><b>{{ number_format($detail['summary']['settleAmount'], 2) }}</b></div>
<div class="sr-summary-cell"><span>差异金额</span><b>{{ number_format($detail['summary']['differenceAmount'], 2) }}</b></div>
</div>
<div class="sr-scroll">
<div class="sr-scroll sr-comparison-scroll">
<table>
<thead>
<tr>
@ -256,12 +278,17 @@
<input id="select-all-differences" type="checkbox" aria-label="选择全部差异">
</th>
<th scope="col">比对结果</th>
<th scope="col">条码</th>
<th scope="col">单价</th>
<th scope="col">采购单号</th>
<th scope="col">日期</th>
<th scope="col">我方商品编码 / 厂家条码</th>
<th scope="col">我方数量</th>
<th scope="col">厂家数量</th>
<th scope="col">我方单价</th>
<th scope="col">我方金额</th>
<th scope="col">厂家数量</th>
<th scope="col">厂家单价</th>
<th scope="col">厂家金额</th>
<th scope="col">数量差异(厂家-我方)</th>
<th scope="col">金额差异(厂家-我方)</th>
<th scope="col">结算口径</th>
<th scope="col">结算金额</th>
<th scope="col">推送</th>
@ -272,7 +299,7 @@
@php
$tagClass = match ($row['type']) {
'厂家多' => 'is-extra',
'金额不一致' => 'is-mismatch',
'数量/金额不一致' => 'is-mismatch',
'厂家少' => 'is-missing',
default => 'is-match',
};
@ -284,24 +311,41 @@
form="push-selected-form"
type="checkbox"
name="selected_rows[]"
value="{{ json_encode(['barcode' => $row['barcode'], 'type' => $row['type'], 'unitPrice' => $row['unitPrice']], JSON_UNESCAPED_UNICODE) }}"
value="{{ json_encode(['purchaseNo' => $row['purchaseNo'], 'date' => $row['date'], 'barcode' => $row['barcode'], 'type' => $row['type']], JSON_UNESCAPED_UNICODE) }}"
aria-label="选择差异 {{ $row['barcode'] }}">
@else
<span class="sr-muted">—</span>
@endif
</td>
<td><span class="sr-tag {{ $tagClass }}">{{ $row['type'] }}</span></td>
<td>{{ $row['purchaseNo'] !== '' ? $row['purchaseNo'] : '—' }}</td>
<td>{{ $row['date'] !== '' ? $row['date'] : '—' }}</td>
<td>
{{ $row['barcode'] }}
@if (! empty($row['supplierBarcode']))
<span class="sr-muted" style="font-size:.7rem">(厂家:{{ $row['supplierBarcode'] }})</span>
@endif
</td>
<td class="is-num">{{ number_format($row['unitPrice'], 2) }}</td>
<td class="is-num">{{ $row['mineQty'] !== 0.0 ? number_format($row['mineQty'], 2) : '—' }}</td>
<td class="is-num">{{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 2) : '—' }}</td>
<td class="is-num">{{ $row['mineUnitPrice'] !== 0.0 ? number_format($row['mineUnitPrice'], 2) : '—' }}</td>
<td class="is-num">{{ $row['mineAmount'] !== 0.0 ? number_format($row['mineAmount'], 2) : '—' }}</td>
<td class="is-num">{{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 2) : '—' }}</td>
<td class="is-num">{{ $row['theirsUnitPrice'] !== 0.0 ? number_format($row['theirsUnitPrice'], 2) : '—' }}</td>
<td class="is-num">{{ $row['theirsAmount'] !== 0.0 ? number_format($row['theirsAmount'], 2) : '—' }}</td>
<td class="is-num">
@if (abs($row['qtyDifference']) < 0.005)
一致
@else
厂家{{ $row['qtyDifference'] > 0 ? '多' : '少' }} {{ number_format(abs($row['qtyDifference']), 2) }}
@endif
</td>
<td class="is-num">
@if (abs($row['amountDifference']) < 0.005)
一致
@else
厂家{{ $row['amountDifference'] > 0 ? '多' : '少' }} ¥{{ number_format(abs($row['amountDifference']), 2) }}
@endif
</td>
<td class="sr-muted">{{ $row['note'] }}</td>
<td class="is-num">{{ number_format($row['settleAmount'], 2) }}</td>
<td>
@ -314,9 +358,10 @@
<input type="hidden" name="month" value="{{ $month }}">
<input type="hidden" name="reconciler" value="{{ $reconciler }}">
<input type="hidden" name="scope" value="row">
<input type="hidden" name="purchase_no" value="{{ $row['purchaseNo'] }}">
<input type="hidden" name="date" value="{{ $row['date'] }}">
<input type="hidden" name="barcode" value="{{ $row['barcode'] }}">
<input type="hidden" name="difference_type" value="{{ $row['type'] }}">
<input type="hidden" name="unit_price" value="{{ number_format($row['unitPrice'], 4, '.', '') }}">
<button type="submit"
class="sr-button is-small {{ $robot['configured'] ? 'is-primary' : 'is-disabled' }}"
@disabled(! $robot['configured'])
@ -330,11 +375,48 @@
</td>
</tr>
@empty
<tr><td colspan="11"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
<tr><td colspan="16"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="sr-pagination">
@php
$comparisonPageCount = max((int) ($detail['pages'] ?? 1), 1);
$comparisonPageCurrent = min(max((int) ($detail['page'] ?? 1), 1), $comparisonPageCount);
$comparisonPageStart = max(1, min($comparisonPageCurrent - 4, $comparisonPageCount - 8));
$comparisonPageEnd = min($comparisonPageCount, $comparisonPageStart + 8);
$comparisonPageUrl = fn (int $number): string => route('business.pages.show', array_merge(request()->query(), [
'section' => $section['slug'],
'page' => $page['slug'],
'compare_page' => $number,
]));
@endphp
<span>
共 {{ number_format((int) ($detail['total_rows'] ?? 0)) }} 条
· 每页 {{ number_format((int) ($detail['per_page'] ?? 15)) }} 条
· 第 {{ $comparisonPageCurrent }} / {{ $comparisonPageCount }} 页
</span>
@if ($comparisonPageCurrent > 1)
<a class="sr-button is-small" href="{{ $comparisonPageUrl($comparisonPageCurrent - 1) }}">上一页</a>
@endif
@if ($comparisonPageStart > 1)
<a class="sr-button is-small" href="{{ $comparisonPageUrl(1) }}">1</a>
@if ($comparisonPageStart > 2) <span>…</span> @endif
@endif
@for ($number = $comparisonPageStart; $number <= $comparisonPageEnd; $number++)
<a class="sr-button is-small {{ $number === $comparisonPageCurrent ? 'is-primary' : '' }}"
@if ($number === $comparisonPageCurrent) aria-current="page" @endif
href="{{ $comparisonPageUrl($number) }}">{{ $number }}</a>
@endfor
@if ($comparisonPageEnd < $comparisonPageCount)
@if ($comparisonPageEnd < $comparisonPageCount - 1) <span>…</span> @endif
<a class="sr-button is-small" href="{{ $comparisonPageUrl($comparisonPageCount) }}">{{ $comparisonPageCount }}</a>
@endif
@if ($comparisonPageCurrent < $comparisonPageCount)
<a class="sr-button is-small" href="{{ $comparisonPageUrl($comparisonPageCurrent + 1) }}">下一页</a>
@endif
</div>
</div>
@php $returnAccounting = $financeReturnPanel['accounting_summary']; @endphp

View File

@ -107,7 +107,7 @@
</div>
@endif
<p class="sr-hint">
厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号、单据编号(采购单号)可以留空;
厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号可以留空;采购单号建议填写(用于按采购单号+日期+条码核对,未填时仅在日期+条码唯一匹配时自动匹配);
单价或金额留空时按数量自动补齐(都留空按 0,未定价行可直接导出上传);其余列必填;
不符合模板或数值格式的整份失败并提示具体行号。厂家上传成功后,下面的数据与「财务端数据」的比对结果立即更新。
</p>

View File

@ -89,7 +89,8 @@
</form>
</div>
<p class="sp-hint">
请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号、采购单号可留空,
请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号可留空;
采购单号建议填写,系统会按采购单号+日期+条码与我方数据核对;
单价或金额留空时会按数量自动补齐。
</p>
<div class="sp-summary">

View File

@ -409,6 +409,10 @@ Route::get('workspace/supplier-reconciliation/returns/export', [SupplierReconcil
->middleware('business.page:supplier-reconciliation,supplier-data')
->name('business.supplier-reconciliation.returns.export');
Route::get('workspace/supplier-reconciliation/comparison/export', [SupplierReconciliationActions::class, 'exportComparison'])
->middleware('business.page:supplier-reconciliation,finance-data')
->name('business.supplier-reconciliation.comparison.export');
// 退货扣减确认:逐行确认/异议 + 一键全部确认
Route::post('workspace/supplier-reconciliation/returns/confirm', [SupplierReconciliationActions::class, 'confirmReturn'])
->middleware('business.page:supplier-reconciliation,supplier-data')

View File

@ -416,7 +416,8 @@ if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
fail '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
fi
if [ "$branch" = 'main' ] && git rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
git fetch origin main --quiet || fail '无法更新远端 main,已停止发布。'
if [ "$branch" = 'main' ]; then
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
fail '本地 main 与 origin/main 不一致,请先执行 git pull --ff-only 再发布。'
fi
@ -457,7 +458,6 @@ ssh_control_path="/tmp/caiwu-ssh-$$-$RANDOM.sock"
ssh_options+=(-o ControlMaster=auto -o ControlPersist=600 -o "ControlPath=$ssh_control_path")
tunnel_ssh_options=("${ssh_options[@]}" -o ControlPath=none)
remote_archive="$remote_path/image-$image_tag.tar.gz"
temp_root="$(mktemp -d "${TMPDIR:-/tmp}/caiwu-deploy-XXXXXX")"
local_tar="$temp_root/image.tar"
local_archive="$temp_root/image.tar.gz"
@ -512,6 +512,13 @@ for pair in \
done
previous_sha="$(ssh "${ssh_options[@]}" "$ssh_target" "cat '$remote_path/.deployed-sha' 2>/dev/null || true" | tr -d '[:space:]')"
if [ -n "$previous_sha" ] && ! printf '%s\n' "$previous_sha" | grep -Eq '^[0-9a-f]{40}$'; then
fail '线上版本标记格式无效,已停止发布。'
fi
expected_previous_sha="${previous_sha:-none}"
deploy_actor="$(printf '%s@%s' "$(git config user.name)" "$(hostname)" | tr -c 'A-Za-z0-9_.@:-' '_')"
remote_release_path="$remote_path/releases/$image_tag-$(uuidgen | tr 'A-Z' 'a-z')"
remote_archive="$remote_release_path/image-$image_tag.tar.gz"
if [ -n "$previous_sha" ]; then
if git cat-file -e "${previous_sha}^{commit}" 2>/dev/null; then
@ -556,7 +563,7 @@ if [ "$bootstrap_server" -eq 1 ]; then
bootstrap_remote=''
fi
ssh "${ssh_options[@]}" "$ssh_target" "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$remote_path'" \
ssh "${ssh_options[@]}" "$ssh_target" "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$remote_path' '$remote_release_path'" \
|| fail '服务器 Docker 未就绪;首次发布请添加 --bootstrap-server。'
ensure_qiniu_storage_runtime
@ -570,6 +577,9 @@ scp_to_remote \
"$repo_root/compose.jiangling.yml" \
"$repo_root/deploy/activate-release.sh" \
"$repo_root/deploy/activate-incremental-release.sh" \
"$repo_root/deploy/release-guard.sh" \
"$repo_root/deploy/retired-activation.sh" \
"$repo_root/deploy/install-release-protection.sh" \
"$repo_root/deploy/config-db-init.sh" \
"$repo_root/deploy/run-finance-batch.sh" \
"$repo_root/deploy/clickhouse-resource-policy.xml" \
@ -578,10 +588,7 @@ scp_to_remote \
"$repo_root/deploy/host-nginx-8442-dingtalk.conf" \
"$repo_root/deploy/setup-incremental-registry.sh" \
"$repo_root/deploy/registry-config.yml" \
"${ssh_target}:$remote_path/" || fail '上传运行配置失败。'
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/update-runtime-url.sh' && '$remote_path/update-runtime-url.sh' '$app_url' '$remote_path'" \
|| fail '更新服务器访问地址失败。'
"${ssh_target}:$remote_release_path/" || fail '上传运行配置失败。'
if [ "$bootstrap_server" -eq 1 ]; then
if ! docker image inspect "$database_image" >/dev/null 2>&1; then
@ -598,11 +605,11 @@ if [ "$bootstrap_server" -eq 1 ]; then
log "首次初始化全量包:${archive_size} MiB"
scp_to_remote "$local_archive" "${ssh_target}:$remote_archive" || fail '上传镜像失败。'
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/'*.sh && chmod 644 '$remote_path/config-db-init.sh' && '$remote_path/activate-release.sh' '$remote_archive' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' && '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml'" \
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_release_path/'*.sh && '$remote_release_path/activate-release.sh' '$remote_archive' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' '$expected_previous_sha' '$deploy_actor' '$app_url' && '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml'" \
|| fail '服务器激活、健康检查或增量仓库初始化失败。'
else
ssh "${ssh_options[@]}" -o ConnectTimeout=10 -o ConnectionAttempts=1 "$ssh_target" \
"chmod 700 '$remote_path/'*.sh && chmod 644 '$remote_path/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml')" \
"chmod 700 '$remote_release_path/'*.sh && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remote_release_path/setup-incremental-registry.sh' '$remote_path' '$remote_release_path/registry-config.yml')" \
|| fail '服务器增量镜像仓库初始化失败。'
docker buildx build \
@ -685,7 +692,7 @@ else
incremental_mib="$(awk -v a="$after_bytes" -v b="$before_bytes" 'BEGIN { v = (a - b) / 1048576; if (v < 0) v = 0; printf "%.2f", v }')"
log "增量新增镜像层:${incremental_mib} MiB;传输耗时:${upload_seconds} 秒"
ssh "${ssh_options[@]}" "$ssh_target" "'$remote_path/activate-incremental-release.sh' '$server_registry_image' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images'" \
ssh "${ssh_options[@]}" "$ssh_target" "'$remote_release_path/activate-incremental-release.sh' '$server_registry_image' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' '$expected_previous_sha' '$deploy_actor' '$app_url'" \
|| fail '服务器增量激活或健康检查失败。'
fi

View File

@ -348,6 +348,11 @@ try {
throw '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
}
Invoke-Native { git fetch origin main --quiet } '无法更新远端 main,已停止发布。'
if ($branch -eq 'main' -and (git rev-parse HEAD).Trim() -ne (git rev-parse origin/main).Trim()) {
throw '本地 main 不是最新 origin/main,请先 git pull --ff-only 再发布。'
}
$deploySha = (git rev-parse HEAD).Trim()
$imageTag = $deploySha.Substring(0, 12)
$fullImage = "${imageName}:$imageTag"
@ -372,7 +377,6 @@ try {
}
Invoke-NativeWithRetry { ssh @sshOptions $sshTarget true } '服务器登录失败或已取消,已停止部署。' -Attempts 3
if ($authSession.Directory) { $env:CAIWU_DEPLOY_PASSWORD_UPDATE = $null }
$remoteArchive = "$RemotePath/image-$imageTag.tar.gz"
# deploy-auth.ps1 is dot-sourced and can clear the automatic script path
# variable on PowerShell Core. Restore it before invoking the build helper.
@ -436,6 +440,11 @@ try {
if ($LASTEXITCODE -ne 0) {
throw '无法连接服务器。'
}
if ($previousSha -and $previousSha -notmatch '^[0-9a-f]{40}$') { throw '线上版本标记格式无效,已停止发布。' }
$expectedPreviousSha = if ($previousSha) { $previousSha } else { 'none' }
$deployActor = (((git config user.name) -join '') + '@' + [Environment]::MachineName) -replace '[^A-Za-z0-9_.@:-]', '_'
$remoteReleasePath = "$RemotePath/releases/$imageTag-$([Guid]::NewGuid().ToString('N'))"
$remoteArchive = "$remoteReleasePath/image-$imageTag.tar.gz"
$previousShaExists = $false
if ($previousSha) {
@ -487,7 +496,7 @@ try {
}
Invoke-Native {
ssh @sshOptions $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'"
ssh @sshOptions $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath' '$remoteReleasePath'"
} '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。'
Ensure-QiniuBusinessStorageRuntime
@ -498,6 +507,9 @@ try {
compose.jiangling.yml `
deploy/activate-release.sh `
deploy/activate-incremental-release.sh `
deploy/release-guard.sh `
deploy/retired-activation.sh `
deploy/install-release-protection.sh `
deploy/config-db-init.sh `
deploy/run-finance-batch.sh `
deploy/clickhouse-resource-policy.xml `
@ -506,13 +518,9 @@ try {
deploy/host-nginx-8442-dingtalk.conf `
deploy/setup-incremental-registry.sh `
deploy/registry-config.yml `
"${sshTarget}:$RemotePath/"
"${sshTarget}:$remoteReleasePath/"
} '上传运行配置失败。'
Invoke-Native {
ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/update-runtime-url.sh' && '$RemotePath/update-runtime-url.sh' '$AppUrl' '$RemotePath'"
} '更新服务器访问地址失败。'
if ($BootstrapServer) {
docker image inspect $DatabaseImage 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
@ -532,12 +540,12 @@ try {
Write-Host "首次初始化全量包:$archiveSizeMB MiB"
Invoke-Native { scp @scpOptions @sshOptions $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。'
Invoke-Native {
ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'"
ssh @sshOptions $sshTarget "chmod 700 '$remoteReleasePath/'*.sh && '$remoteReleasePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' '$expectedPreviousSha' '$deployActor' '$AppUrl' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'"
} '服务器激活、健康检查或增量仓库初始化失败。'
}
else {
Invoke-NativeWithRetry {
ssh @sshOptions -o ConnectTimeout=10 -o ConnectionAttempts=1 $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml')"
ssh @sshOptions -o ConnectTimeout=10 -o ConnectionAttempts=1 $sshTarget "chmod 700 '$remoteReleasePath/'*.sh && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remoteReleasePath/setup-incremental-registry.sh' '$RemotePath' '$remoteReleasePath/registry-config.yml')"
} '服务器增量镜像仓库初始化失败。'
# docker save 导出未压缩层,每次导入重新压缩会改变摘要,导致 Registry 无法复用。
@ -653,7 +661,7 @@ try {
Write-Host "增量新增镜像层:$incrementalMiB MiB;传输耗时:$([math]::Round($uploadTimer.Elapsed.TotalSeconds, 1)) 秒"
Invoke-Native {
ssh @sshOptions $sshTarget "'$RemotePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'"
ssh @sshOptions $sshTarget "'$remoteReleasePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' '$expectedPreviousSha' '$deployActor' '$AppUrl'"
} '服务器增量激活或健康检查失败。'
}

View File

@ -0,0 +1,289 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteVersion;
use App\Support\CourierQuoteFreightCalculator;
use App\Support\CourierQuoteStructure;
use App\Support\QuoteManagementSnapshot;
use Illuminate\Support\Carbon;
use Tests\Feature\FeatureTestCase;
class CourierQuoteBillingRulesTest extends FeatureTestCase
{
private array $files = [];
protected function tearDown(): void
{
foreach ($this->files as $file) {
@unlink($file);
}
parent::tearDown();
}
/** 用各家原表备注里自带的算例核对计费口径。 */
public function testFreightMatchesWorkedExamplesFromQuoteRemarks(): void
{
$f = fn (array $structure, array $row, float $kg, string $rounding = 'none') => CourierQuoteStructure::freight(
$structure, $row, CourierQuoteStructure::roundWeight($kg, $rounding)
)['amount'];
// 江西新建中通:15kg 广东 = 5.5 + 10×0.43 + 5×0.63(累进,用分段表示)
$progressive = ['mode' => 'segments'];
$gd = ['region' => '广东', 'segments' => [
['lo' => 0, 'hi' => 10, 'type' => 'waybill', 'base' => 5.5, 'rate' => 0.43],
['lo' => 10, 'hi' => null, 'type' => 'first_add', 'base' => 9.8, 'fw' => 10, 'rate' => 0.63],
]];
$this->assertEqualsWithDelta(12.95, $f($progressive, $gd, 15), 0.0001);
$this->assertEqualsWithDelta(9.8, $f($progressive, $gd, 10), 0.0001);
// 迈当极兔:8.1kg 广东向上取整按 9kg,3 + 9×1.1 = 12.9;3kg 内一口价 4.2
$md = ['mode' => 'flat_then', 'flat_bands' => [3], 'then_mode' => 'waybill_rate', 'rate_tiers' => [10]];
$mdRow = ['region' => '广东', 'flat' => [4.2], 'base' => 3, 'rates' => [1.1, 1.4]];
$this->assertEqualsWithDelta(12.9, $f($md, $mdRow, 8.1, 'ceil'), 0.0001);
$this->assertEqualsWithDelta(4.2, $f($md, $mdRow, 2.6, 'ceil'), 0.0001);
$this->assertEqualsWithDelta(3 + 11 * 1.4, $f($md, $mdRow, 10.2, 'ceil'), 0.0001);
// 顺兴中通快运:10.4kg = (10.4-10)×1.07 + 首重最低收费 24
$sx = ['mode' => 'first_additional', 'first_weight' => 10, 'rate_tiers' => []];
$this->assertEqualsWithDelta(24.428, $f($sx, ['region' => '安徽', 'base' => 24, 'rates' => [1.07]], 10.4), 0.0001);
$this->assertEqualsWithDelta(24.0, $f($sx, ['region' => '安徽', 'base' => 24, 'rates' => [1.07]], 6), 0.0001);
// 彩艺德邦:四舍五入,4.4 按 4kg(首重 5kg 内 13 元),5.5 按 6kg
$cy = ['mode' => 'first_additional', 'first_weight' => 5, 'rate_tiers' => []];
$cyRow = ['region' => '江苏', 'base' => 13, 'rates' => [1.6]];
$this->assertEqualsWithDelta(13.0, $f($cy, $cyRow, 4.4, 'round'), 0.0001);
$this->assertEqualsWithDelta(14.6, $f($cy, $cyRow, 5.5, 'round'), 0.0001);
// 常州圆通:按总重量所在区间取费率,区间上限含本数
$yt = ['mode' => 'waybill_rate', 'rate_tiers' => [35, 50]];
$ytRow = ['region' => '江苏', 'base' => 3.6, 'rates' => [0.48, 0.98, 1.28]];
$this->assertEqualsWithDelta(3.6 + 35 * 0.48, $f($yt, $ytRow, 35), 0.0001);
$this->assertEqualsWithDelta(3.6 + 35.5 * 0.98, $f($yt, $ytRow, 35.5), 0.0001);
$this->assertEqualsWithDelta(3.6 + 60 * 1.28, $f($yt, $ytRow, 60), 0.0001);
// 派雅极兔:某一段原表为「不发」(价格为空);超过最后一段不在报价范围
$py = ['mode' => 'flat_bands', 'flat_bands' => [3, 5]];
$pyRow = ['region' => '江苏', 'flat' => [4, null]];
$this->assertEqualsWithDelta(4.0, $f($py, $pyRow, 2), 0.0001);
$this->assertSame('该重量段不发', CourierQuoteStructure::freight($py, $pyRow, 4)['reason']);
$this->assertSame('超出报价重量范围', CourierQuoteStructure::freight($py, $pyRow, 6)['reason']);
$this->assertSame('特殊计费,需人工核算', CourierQuoteStructure::freight(['mode' => 'other'], [], 3)['reason']);
}
public function testImportCommandWritesRulesAndCalculatorUsesThemByShipDate(): void
{
$old = $this->version('QB-101', '常州圆通其他', 'CZYTOQT', '2026-06-01', '2026-09-19');
$new = $this->version('QB-102', '常州圆通其他', 'CZYTOQT', '2026-09-20', null);
$seg = $this->version('QB-103', '江西新建中通', 'JXXJZTO', '2000-01-01', null);
$manual = $this->version('QB-104', '华韵安能', 'HYAN', '2025-07-01', null);
$file = $this->rulesFile([
$this->waybill('QB-101', '常州圆通其他', 3.5),
$this->waybill('QB-102', '常州圆通其他', 3.6),
[
'version_id' => 'QB-103', 'company_name' => '江西新建中通', 'conversion' => 'needs_segment_mode',
'structure' => ['mode' => 'other', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => null, 'rate_tiers' => [], 'rounding' => 'none'],
'segments_rows' => [['region' => '广东', 'segments' => [
['lo' => 0, 'hi' => 10, 'type' => 'waybill', 'base' => 5.5, 'rate' => 0.43],
['lo' => 10, 'hi' => null, 'type' => 'first_add', 'base' => 9.8, 'fw' => 10, 'rate' => 0.63],
]]],
'params' => ['volumetric_divisor' => 10000, 'weight_limit_kg' => 45, 'surcharges' => [], 'notes' => []],
],
['version_id' => 'QB-104', 'company_name' => '华韵安能', 'conversion' => 'manual', 'manual_reason' => '按体积计费'],
]);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id, '--dry-run' => true])
->assertExitCode(0);
$this->assertNull($old->fresh()->billing_structure);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])
->assertExitCode(0);
$this->assertSame('waybill_rate', $old->fresh()->billing_structure['mode']);
$this->assertEquals([35, 50], $new->fresh()->billing_structure['rate_tiers']);
$this->assertSame('segments', $seg->fresh()->billing_structure['mode']);
$this->assertNull($manual->fresh()->billing_structure);
$calc = new CourierQuoteFreightCalculator();
// 发货日期决定用哪一版:9 月 1 日走旧版面单 3.5,9 月 25 日走新版面单 3.6
$before = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-01'), '江苏省', 10);
$this->assertSame('QB-101', $before['version_code']);
$this->assertEqualsWithDelta(3.5 + 10 * 0.48, $before['amount'], 0.001);
$after = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '江苏', 10);
$this->assertSame('QB-102', $after['version_code']);
$this->assertEqualsWithDelta(3.6 + 10 * 0.48, $after['amount'], 0.001);
// 计泡:60×50×40/6000 = 20kg > 实重 10kg
$volume = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '江苏', 10, 60, 50, 40);
$this->assertEqualsWithDelta(20.0, $volume['volume_weight'], 0.001);
$this->assertEqualsWithDelta(3.6 + 20 * 0.48, $volume['amount'], 0.001);
// 城市单列行优先:深圳走「深圳(广东)」,其他广东城市走广东
$this->assertEqualsWithDelta(3.6 + 2 * 1.0, $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '广东省', 2, city: '深圳')['amount'], 0.001);
$this->assertEqualsWithDelta(3.6 + 2 * 0.75, $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '广东省', 2, city: '广州')['amount'], 0.001);
$this->assertEqualsWithDelta(12.95, $calc->calculate('JXXJZTO', Carbon::parse('2026-10-01'), '广东', 15)['amount'], 0.001);
// 没有快递编码的存量报价按快递名称查
$seg->forceFill(['courier_expcode' => null])->save();
$this->assertEqualsWithDelta(12.95, (new CourierQuoteFreightCalculator())->calculate('江西新建中通', Carbon::parse('2026-10-01'), '广东', 15)['amount'], 0.001);
$seg->forceFill(['courier_expcode' => 'JXXJZTO'])->save();
// 起算重量:靓阁不足 16kg 按 16kg(落在 16-17kg 段 40 元)
$this->version('QB-105', '靓阁快递', 'LGKD', '2024-03-12', null)->forceFill([
'billing_structure' => ['mode' => 'flat_bands', 'flat_bands' => [17.0, 18.0], 'rounding' => 'none', 'min_weight' => 16.0, 'weight_limit' => 45.0],
'billing_rows' => [['region' => '江苏', 'flat' => [40.0, 50.0], 'base' => null, 'rates' => []]],
])->save();
$light = $calc->calculate('LGKD', Carbon::parse('2026-10-01'), '江苏', 3);
$this->assertSame(16.0, $light['billable_weight']);
$this->assertEqualsWithDelta(40.0, $light['amount'], 0.001);
$this->assertEqualsWithDelta(50.0, $calc->calculate('LGKD', Carbon::parse('2026-10-01'), '江苏', 17.5)['amount'], 0.001);
$this->assertSame('超出限重 45kg', $calc->calculate('JXXJZTO', Carbon::parse('2026-10-01'), '广东', 46)['reason']);
$this->assertStringContainsString('未覆盖目的地', $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '西藏', 2)['reason']);
$this->assertStringContainsString('需人工核算', $calc->calculate('HYAN', Carbon::parse('2026-09-25'), '江苏', 2)['reason']);
$this->assertSame('发货日期没有生效的报价', $calc->calculate('CZYTOQT', Carbon::parse('2026-01-01'), '江苏', 2)['reason']);
// 再导一次内容相同 → 跳过;内容不同且不加 --force → 整批报错不写
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])->assertExitCode(0);
$changed = $this->rulesFile([$this->waybill('QB-102', '常州圆通其他', 9.9)]);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $changed, '--company' => $this->company->id])->assertExitCode(1);
$this->assertEqualsWithDelta(3.6, $new->fresh()->billing_rows[0]['base'], 0.0001);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $changed, '--company' => $this->company->id, '--force' => true])->assertExitCode(0);
$this->assertEqualsWithDelta(9.9, $new->fresh()->billing_rows[0]['base'], 0.0001);
}
public function testImportRejectsWholeBatchWhenCompanyNameDoesNotMatch(): void
{
$a = $this->version('QB-201', '沭阳德邦', 'SYDB', '2026-02-01', null);
$this->version('QB-202', '沭阳德邦其他', 'SYDBQT', '2026-02-01', null);
$file = $this->rulesFile([$this->waybill('QB-201', '沭阳德邦', 6.8), $this->waybill('QB-202', '别的快递', 6.8)]);
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])->assertExitCode(1);
$this->assertNull($a->fresh()->billing_structure);
}
public function testQuoteDetailShowsBillingSummary(): void
{
$version = $this->version('QB-301', '彩艺德邦', 'CYDB', '2024-07-01', null);
$version->forceFill([
'billing_structure' => ['mode' => 'first_additional', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => 5.0, 'rate_tiers' => [],
'rounding' => 'round', 'volumetric_divisor' => 10000, 'weight_limit' => 60.0, 'per_ticket_surcharge' => 1.0],
'billing_rows' => [['region' => '江苏', 'flat' => [], 'base' => 13.0, 'rates' => [1.6]]],
])->save();
$quotes = (new QuoteManagementSnapshot('/nonexistent-billing.json'))->load()['quotes'];
$quote = collect($quotes)->firstWhere('id', 'QB-301');
$this->assertStringContainsString('首重5kg内按首重价', $quote['billing_summary']);
$this->assertStringContainsString('每票加收 1 元', $quote['billing_summary']);
$this->assertStringContainsString('1 个地区', $quote['billing_summary']);
$this->assertCalculatorAddsPerTicket();
}
private function assertCalculatorAddsPerTicket(): void
{
$result = (new CourierQuoteFreightCalculator())->calculate('CYDB', Carbon::parse('2026-01-01'), '江苏', 5.5);
$this->assertSame(6.0, $result['billable_weight']);
$this->assertEqualsWithDelta(15.6, $result['amount'], 0.001);
$this->assertSame('13+(6-5)×1.6+每票1', $result['formula']);
}
public function test_import_rejects_duplicate_versions_negative_rates_and_unknown_segments_without_partial_writes(): void
{
$version = $this->version('QB-SAFE', '安全报价', 'SAFE', '2026-01-01', null);
$valid = $this->waybill('QB-SAFE', '安全报价', 2);
$negative = $valid;
$negative['rows'][0]['rates'][0] = -1;
$unknown = $valid;
$unknown['conversion'] = 'needs_segment_mode';
$unknown['segments_rows'] = [['region' => '江苏', 'segments' => [['lo' => 0, 'hi' => null, 'type' => 'unknown', 'base' => 2, 'rate' => 1]]]];
foreach ([[$valid, $valid], [$negative], [$unknown], ['invalid-row']] as $items) {
$this->artisan('courier-quotes:import-billing-rules', ['file' => $this->rulesFile($items), '--company' => $this->company->id])->assertExitCode(1);
$this->assertNull($version->fresh()->billing_structure);
}
}
public function test_city_specific_prices_require_the_matching_province_and_accept_chinese_parentheses(): void
{
$version = $this->version('QB-CITY', '城市报价', 'CITY', '2026-01-01', null);
$version->forceFill([
'billing_structure' => ['mode' => 'waybill_rate', 'rate_tiers' => []],
'billing_rows' => [
['region' => '深圳(广东)', 'base' => 5, 'rates' => [1]],
['region' => '江苏', 'base' => 10, 'rates' => [2]],
],
])->save();
$calculator = new CourierQuoteFreightCalculator();
$this->assertEquals(7, $calculator->calculate('CITY', Carbon::parse('2026-10-01'), '广东省', 2, city: '深圳市')['amount']);
$this->assertEquals(14, $calculator->calculate('CITY', Carbon::parse('2026-10-01'), '江苏省', 2, city: '深圳市')['amount']);
}
private function waybill(string $code, string $name, float $base): array
{
$rows = [];
foreach ([['江苏', 0.48, 0.98, 1.28], ['广东', 0.75, 3, 3.3], ['深圳(广东)', 1, 3, 3.3]] as [$region, $r1, $r2, $r3]) {
$rows[] = ['region' => $region, 'flat' => [], 'base' => $base, 'rates' => [$r1, $r2, $r3], 'source_row' => 2];
}
return [
'version_id' => $code, 'company_name' => $name, 'conversion' => 'direct',
'structure' => ['mode' => 'waybill_rate', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => null, 'rate_tiers' => [35.0, 50.0], 'rounding' => 'none'],
'rows' => $rows,
'params' => ['volumetric_divisor' => 6000, 'weight_limit_kg' => null, 'surcharges' => [], 'notes' => ['面单预充6元/单']],
];
}
private function rulesFile(array $versions): string
{
$path = storage_path('framework/testing/billing-rules-' . uniqid('', true) . '.json');
@mkdir(dirname($path), 0777, true);
file_put_contents($path, json_encode(['versions' => $versions], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$this->files[] = $path;
return $path;
}
private function version(string $code, string $name, string $expcode, string $from, ?string $to): CourierQuoteVersion
{
$change = CourierQuoteChange::create([
'company_id' => $this->company->id,
'number' => 'BG-T-' . $code,
'source_quote_id' => $code,
'source_sheet_name' => $name,
'carrier_name' => $name,
'quote_type' => '标准快递报价',
'change_type' => '面单调整',
'status' => CourierQuoteChange::STATUS_APPROVED,
'effective_date' => $from,
'is_retrospective' => false,
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
'summary' => '测试报价',
'created_by' => $this->user->id,
'submitted_by' => $this->user->id,
'submitted_at' => now(),
'approved_by' => $this->user->id,
'approved_at' => now(),
]);
return CourierQuoteVersion::create([
'company_id' => $this->company->id,
'version_code' => $code,
'source_quote_id' => $code,
'source_change_id' => $change->id,
'sheet_name' => $name,
'company_name' => $name,
'carrier_name' => $name,
'courier_expcode' => $expcode,
'quote_type' => '标准快递报价',
'effective_date' => $from,
'end_date' => $to,
'source_range' => 'A1:C2',
'values_json' => json_encode([['省份', '面单', '费率']], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode([[]], JSON_THROW_ON_ERROR),
'status' => 'approved',
'signature_status' => '历史签署资料已归档',
'created_by' => $this->user->id,
'approved_by' => $this->user->id,
]);
}
}

View File

@ -105,6 +105,10 @@ class ProductCatalogPageTest extends FeatureTestCase
'operation' => '商品与库存管理导入',
]],
'jushuitan_cost_price_history_count' => 1,
'jushuitan_cost_price_observations' => [[
'record_type_label' => '观测到成本价变化', 'before_price' => 499, 'after_price' => 500,
'effect_start_time' => '2026-10-20 12:00:00', 'remark' => '发现时间,不是真实改价时间',
]],
'purchase_order_unit_price' => 65,
'purchase_order_finished_at' => '2026-08-20 11:03:05',
'purchase_order_id' => 827380,
@ -143,7 +147,7 @@ class ProductCatalogPageTest extends FeatureTestCase
'last_succeeded_at' => '2026-09-01 03:02:00',
],
'jushuitan_cost_price_history_sync_status' => [
'code' => 'ready',
'code' => 'failed',
'initialized' => true,
'last_succeeded_at' => '2026-09-15 00:05:00',
],
@ -166,6 +170,8 @@ class ProductCatalogPageTest extends FeatureTestCase
->assertOk()
->assertSee('data-product-catalog-page', false)
->assertSeeText('普通商品资料')
->assertSeeText('聚水潭成本价历史最近同步失败')
->assertSeeText('观测 1 条(最近20条)')
->assertSee('data-sync-state="healthy"', false)
->assertSeeText('同步正常')
->assertSeeText('最近同步 2026-08-15 17:51:03')

View File

@ -105,6 +105,9 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
&& str_contains($text, 'B-1')
&& str_contains($text, 'C-1')
&& str_contains($text, 'D-1')
&& str_contains($text, '采购单号:编号-B-1')
&& str_contains($text, '日期:2026-09-30')
&& str_contains($text, '厂家数量少 1')
&& ! str_contains($text, '`A-1`')
&& ! str_contains($text, '鸣飞家具');
});
@ -126,9 +129,10 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
'month' => '2026-09',
'reconciler' => '张三',
'scope' => 'row',
'purchase_no' => '编号-B-1',
'date' => '2026-09-30',
'barcode' => 'B-1',
'difference_type' => '金额不一致',
'unit_price' => '50',
'difference_type' => '数量/金额不一致',
])
->assertStatus(302)
->assertSessionHas('success', '已推送 1 条差异到 晨丰 钉钉群(1 条消息)。');
@ -155,8 +159,8 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
'reconciler' => '张三',
'scope' => 'selected',
'selected_rows' => [
json_encode(['barcode' => 'B-1', 'type' => '金额不一致', 'unitPrice' => 50]),
json_encode(['barcode' => 'D-1', 'type' => '厂家多', 'unitPrice' => 80]),
json_encode(['purchaseNo' => '编号-B-1', 'date' => '2026-09-30', 'barcode' => 'B-1', 'type' => '数量/金额不一致']),
json_encode(['purchaseNo' => '编号-D-1', 'date' => '2026-09-30', 'barcode' => 'D-1', 'type' => '厂家多']),
],
])
->assertStatus(302)
@ -198,14 +202,20 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
$rows[] = [
'barcode' => sprintf('BULK-%02d', $index),
'supplierBarcode' => '',
'purchaseNo' => sprintf('PO-BULK-%02d', $index),
'date' => '2026-09-30',
'unitPrice' => 10,
'mineUnitPrice' => 10,
'theirsUnitPrice' => 10,
'mineQty' => 2,
'theirsQty' => 1,
'mineAmount' => 20,
'theirsAmount' => 10,
'type' => '金额不一致',
'qtyDifference' => -1,
'amountDifference' => -10,
'type' => '数量/金额不一致',
'settleAmount' => 10,
'note' => '金额/数量不一致:按金额低的一方结算',
'note' => '厂家数量少 1;按金额低的一方结算',
];
}
@ -230,6 +240,8 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
] as $key => $row) {
$store->save('final:supplier:payment-reconciliation', $key, $row + [
'summary_month' => '2026-09',
'purchaseNo' => '编号-' . $row['skuCode'],
'date' => '2026-09-30',
'supplier' => '晨丰',
'reconciler' => '张三',
], $companyId, null);

View File

@ -46,6 +46,8 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertSeeText('厂家端数据')
->assertSeeText('厂家多')
->assertSeeText('取金额低的一方结算')
->assertSeeText('采购单号 + 日期 + 商品编码')
->assertSee('list="finance-supplier-options"', false)
->assertSeeText('对账人')
->assertSeeText('下载对账单模板');
}
@ -55,11 +57,13 @@ class SupplierReconciliationTest extends FeatureTestCase
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$store->save('final:supplier:payment-reconciliation', 'line:000001', [
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-1', 'date' => '2026-09-30',
'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
'qty' => '2', 'unitPrice' => '128', 'amount' => '256', 'reconciler' => '张三',
], $companyId, null);
$store->save('final:supplier:payment-reconciliation', 'line:000002', [
'summary_month' => '2026-09', 'supplier' => '鸣飞家具', 'skuCode' => 'SJ-1',
'summary_month' => '2026-09', 'purchaseNo' => '编号-SJ-1', 'date' => '2026-09-30',
'supplier' => '鸣飞家具', 'skuCode' => 'SJ-1',
'qty' => '1', 'unitPrice' => '96', 'amount' => '96', 'reconciler' => '李四',
], $companyId, null);
@ -81,15 +85,17 @@ class SupplierReconciliationTest extends FeatureTestCase
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$store->save('final:supplier:payment-reconciliation', 'line:000001', [
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-1N1097V01', 'date' => '2026-09-30',
'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
'qty' => '2', 'unitPrice' => '128', 'amount' => '256', 'reconciler' => '张三',
], $companyId, null);
$store->save('final:supplier:payment-reconciliation', 'line:000002', [
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-2',
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-2N1097V01', 'date' => '2026-09-30',
'supplier' => '晨丰', 'skuCode' => 'ZSJ-2',
'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三',
], $companyId, null);
$this->seedStatement('晨丰', [
$this->statementRow('ZSJ-1N1097V01', '128.00', '2', '256.00'),
$this->statementRow('ZSJ-1N1097V01', '128.00', '3', '384.00'),
$this->statementRow('ZSJ-3N1097V01', '80.00', '1', '80.00'),
]);
@ -103,11 +109,161 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertSeeText('比对明细')
->assertSeeText('返回供应商列表')
->assertSeeText('结算金额')
->assertSeeText('采购单号')
->assertSeeText('日期')
->assertSeeText('数量差异(厂家-我方)')
->assertSeeText('厂家多 1.00')
->assertSeeText('厂家多 ¥128.00')
->assertSeeText('编号-ZSJ-1N1097V01')
->assertSeeText('导出比对汇总')
->assertSee('sr-comparison-scroll', false)
->assertSeeText('每页 15 条')
->assertSeeText('ZSJ-3')
->assertSeeText('ZSJ-2')
->assertDontSeeText('各厂家上传情况');
}
public function test_finance_supplier_filter_requires_an_exact_searchable_name(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
foreach (['晨丰', '鸣飞家具'] as $supplier) {
$store->save('final:supplier:payment-reconciliation', 'supplier:' . $supplier, [
'summary_month' => '2026-09',
'purchaseNo' => 'PO-' . $supplier,
'date' => '2026-09-30',
'supplier' => $supplier,
'skuCode' => 'A-1',
'qty' => '1',
'unitPrice' => '10',
'amount' => '10',
'reconciler' => '张三',
], $companyId, null);
}
$pageUrl = route('business.pages.show', [
'company_id' => $companyId,
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
]);
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('晨丰'))
->assertOk()
->assertSeeText('晨丰 · 比对明细')
->assertSee('value="晨丰"', false);
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('不存在的厂家'))
->assertOk()
->assertSeeText('未找到名为「不存在的厂家」的供应商')
->assertDontSeeText('晨丰 · 比对明细')
->assertDontSeeText('鸣飞家具 · 比对明细');
}
public function test_finance_comparison_rows_are_paginated_fifteen_per_page(): void
{
$companyId = (int) $this->company->id;
$store = app(SupplierPayableOverrideStore::class);
$statementRows = [];
for ($i = 1; $i <= 20; $i++) {
$purchaseNo = sprintf('PO-%02d', $i);
$barcode = sprintf('A-%02d', $i);
$store->save('final:supplier:payment-reconciliation', 'compare:' . $i, [
'summary_month' => '2026-09',
'purchaseNo' => $purchaseNo,
'date' => '2026-09-30',
'supplier' => '晨丰',
'skuCode' => $barcode,
'qty' => '1',
'unitPrice' => '10',
'amount' => '10',
'reconciler' => '张三',
], $companyId, null);
$statementRows[] = array_merge(
$this->statementRow($barcode . 'N1097V01', '10.00', '1', '10.00'),
['purchaseNo' => $purchaseNo],
);
}
$this->seedStatement('晨丰', $statementRows);
$pageUrl = fn (int $page): string => route('business.pages.show', [
'company_id' => $companyId,
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'supplier' => '晨丰',
'compare_page' => $page,
]);
$this->loginAs()->get($pageUrl(1))
->assertOk()
->assertSeeText('第 1 / 2 页')
->assertSeeText('每页 15 条')
->assertSeeText('A-01')
->assertSeeText('A-15')
->assertDontSeeText('A-16')
->assertDontSeeText('A-20');
$this->loginAs()->get($pageUrl(2))
->assertOk()
->assertSeeText('第 2 / 2 页')
->assertSeeText('A-16')
->assertSeeText('A-20')
->assertDontSeeText('A-15')
->assertDontSeeText('A-01');
}
public function test_finance_comparison_export_includes_summary_and_all_filtered_rows(): void
{
$companyId = (int) $this->company->id;
$store = app(SupplierPayableOverrideStore::class);
foreach ([
'mine:1' => ['purchaseNo' => 'PO-1', 'skuCode' => 'A-1', 'qty' => '2', 'unitPrice' => '100', 'amount' => '200', 'reconciler' => '张三'],
'mine:2' => ['purchaseNo' => 'PO-2', 'skuCode' => 'B-1', 'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三'],
'mine:other' => ['purchaseNo' => 'PO-OTHER', 'skuCode' => 'C-1', 'qty' => '1', 'unitPrice' => '30', 'amount' => '30', 'reconciler' => '李四'],
] as $key => $row) {
$store->save('final:supplier:payment-reconciliation', $key, $row + [
'summary_month' => $key === 'mine:other' ? '2026-10' : '2026-09',
'date' => '2026-09-30',
'supplier' => '晨丰',
], $companyId, null);
}
$this->seedStatement('晨丰', [
array_merge($this->statementRow('A-1N1097V01', '100.00', '2', '200.00'), ['purchaseNo' => 'PO-1']),
array_merge($this->statementRow('B-1N1097V01', '50.00', '2', '100.00'), ['purchaseNo' => 'PO-2']),
array_merge($this->statementRow('D-1N1097V01', '80.00', '1', '80.00'), ['purchaseNo' => 'PO-D']),
]);
$response = $this->loginAs()->get(route('business.supplier-reconciliation.comparison.export', [
'supplier' => '晨丰',
'month' => '2026-09',
'reconciler' => '张三',
]));
$content = $response->streamedContent();
$response->assertOk()
->assertDownload()
->assertHeader('content-type', 'text/csv; charset=UTF-8');
foreach ([
'供应商,晨丰',
'月份,2026-09',
'对账人,张三',
'双方一致,1',
'厂家多,1',
'数量/金额不一致,1',
'厂家少,0',
'采购单号,日期,我方商品编码,厂家条码,比对结果',
'PO-1',
'PO-2',
'PO-D',
'厂家多 1',
'厂家多 ¥50.00',
] as $expected) {
$this->assertStringContainsString($expected, $content);
}
$this->assertStringNotContainsString('PO-OTHER', $content);
}
public function test_supplier_page_shows_uploads_and_return_rows(): void
{
$this->seedStatement('晨丰', [
@ -365,6 +521,8 @@ class SupplierReconciliationTest extends FeatureTestCase
'mine:logistics:1',
[
'summary_month' => '2026-09',
'purchaseNo' => 'PO-LOGISTICS-1',
'date' => '2026-09-27',
'supplier' => '曹县宇诺',
'reconciler' => '陈亲园',
'skuCode' => 'CZ008407',
@ -835,14 +993,14 @@ class SupplierReconciliationTest extends FeatureTestCase
public function test_matcher_applies_the_agreed_settlement_rules(): void
{
$mine = [
['skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], // 一致
['skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'], // 金额不一致(厂家低)
['skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'], // 厂家少
['purchaseNo' => 'PO-A-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
['purchaseNo' => 'PO-B-1', 'date' => '2026-09-30', 'skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'],
['purchaseNo' => 'PO-C-1', 'date' => '2026-09-30', 'skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'],
];
$theirs = [
['barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
['barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'],
['barcode' => 'D-1', 'unitPrice' => '80', 'qty' => '1', 'amount' => '80'], // 厂家多
['purchaseNo' => 'PO-A-1', 'createdAt' => '2026-09-30', 'barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
['purchaseNo' => 'PO-B-1', 'createdAt' => '2026-09-30', 'barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'],
['purchaseNo' => 'PO-D-1', 'createdAt' => '2026-09-30', 'barcode' => 'D-1', 'unitPrice' => '80', 'qty' => '1', 'amount' => '80'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
@ -852,25 +1010,31 @@ class SupplierReconciliationTest extends FeatureTestCase
$this->assertSame(1, $result['summary']['supplierMissing']);
$this->assertSame(1, $result['summary']['supplierExtra']);
$this->assertSame(330.0, $result['summary']['mineAmount']);
$this->assertSame(330.0, $result['summary']['theirsAmount']);
$this->assertSame(80.0, $result['summary']['supplierOverAmount']);
$this->assertSame(250.0, $result['summary']['settleAmount']);
$this->assertSame(80.0, $result['summary']['differenceAmount']);
$byBarcode = collect($result['rows'])->keyBy('barcode');
$this->assertSame(50.0, $byBarcode['B-1']['settleAmount']);
$this->assertSame(-1.0, $byBarcode['B-1']['qtyDifference']);
$this->assertSame(-50.0, $byBarcode['B-1']['amountDifference']);
$this->assertSame('PO-B-1', $byBarcode['B-1']['purchaseNo']);
$this->assertSame('2026-09-30', $byBarcode['B-1']['date']);
$this->assertSame(0.0, $byBarcode['C-1']['settleAmount']);
$this->assertSame(0.0, $byBarcode['D-1']['settleAmount']);
}
public function test_matcher_normalizes_supplier_barcode_suffix(): void
{
// 真实场景:我方货号不带厂家后缀,厂家条码带「厂家编号 + 版本」(CJ019312 对 CJ019312N1097V01)
// 真实场景:我方商品编码与厂家条码的厂家编号/版本后缀不同,去掉后缀后应一致。
$mine = [
['skuCode' => 'CJ019312', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
['skuCode' => 'DNZ037602', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
['purchaseNo' => 'PO-CJ-1', 'date' => '2026-09-30', 'skuCode' => 'CJ019312', 'productCode' => 'CJ019312N1178V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
['purchaseNo' => 'PO-DNZ-1', 'date' => '2026-09-30', 'skuCode' => 'DNZ037602', 'productCode' => 'DNZ037602N1178V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
];
$theirs = [
['barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
['barcode' => 'DNZ037602N1097V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
['purchaseNo' => 'PO-CJ-1', 'createdAt' => '2026-09-30', 'barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
['purchaseNo' => 'PO-DNZ-1', 'createdAt' => '2026-09-30', 'barcode' => 'DNZ037602N1097V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
@ -883,6 +1047,92 @@ class SupplierReconciliationTest extends FeatureTestCase
$this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode'] ?? null);
}
public function test_matcher_uses_product_code_instead_of_sku_code(): void
{
$mine = [[
'purchaseNo' => 'PO-CODE-1',
'date' => '2026-09-30',
'skuCode' => 'SKU-ONLY',
'productCode' => 'CJ019312N1178V01',
'unitPrice' => '182.5',
'qty' => '2',
'amount' => '365',
]];
$theirs = [[
'purchaseNo' => 'PO-CODE-1',
'createdAt' => '2026-09-30',
'barcode' => 'CJ019312N1097V01',
'unitPrice' => '182.5',
'qty' => '2',
'amount' => '365',
]];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame('CJ019312N1178V01', $result['rows'][0]['barcode']);
$this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode']);
}
public function test_matcher_uses_purchase_number_date_and_barcode_as_the_unique_key(): void
{
$mine = [
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'],
];
$theirs = [
['purchaseNo' => 'PO-1', 'createdAt' => '2026/9/30 09:30:00', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'],
['purchaseNo' => 'PO-3', 'createdAt' => '2026-10-01', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$rows = collect($result['rows'])->keyBy('purchaseNo');
$this->assertSame(SupplierStatementMatcher::TYPE_MISMATCH, $rows['PO-1']['type']);
$this->assertSame('2026-09-30', $rows['PO-1']['date']);
$this->assertSame(1.0, $rows['PO-1']['qtyDifference']);
$this->assertSame(100.0, $rows['PO-1']['amountDifference']);
$this->assertStringContainsString('厂家数量多 1', $rows['PO-1']['note']);
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_MISSING, $rows['PO-2']['type']);
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_EXTRA, $rows['PO-3']['type']);
$this->assertSame(3.0, $result['summary']['supplierOverQty']);
$this->assertSame(300.0, $result['summary']['supplierOverAmount']);
}
public function test_matcher_can_fallback_only_when_missing_purchase_number_matches_one_row(): void
{
$mine = [
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
];
$theirs = [
['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame('PO-1', $result['rows'][0]['purchaseNo']);
$this->assertStringContainsString('按日期+商品编码唯一匹配', $result['rows'][0]['note']);
}
public function test_matcher_does_not_guess_when_missing_purchase_number_is_ambiguous(): void
{
$mine = [
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'],
['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
];
$theirs = [
['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$this->assertSame(0, $result['summary']['match']);
$this->assertSame(1, $result['summary']['supplierExtra']);
$this->assertSame(2, $result['summary']['supplierMissing']);
$this->assertStringContainsString('无法唯一匹配', $result['rows'][0]['note']);
}
public function test_header_mismatch_message_lists_the_differences(): void
{
$file = UploadedFile::fake()->createWithContent(

View File

@ -0,0 +1,113 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceObservationRepository;
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer as Differ;
use Tests\TestCase;
class JushuitanCostPriceSnapshotDifferTest extends TestCase
{
private Differ $differ;
protected function setUp(): void
{
parent::setUp();
config(['database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''],
'upstream_mirrors.connection' => 'business_mirror', 'cache.default' => 'array']);
DB::purge('business_mirror');
Carbon::setTestNow('2026-10-20 12:00:00');
$schema = Schema::connection('business_mirror');
$schema->create(Differ::STATES, function (Blueprint $t): void {
$t->string('dataset_key')->primary();
foreach (['status', 'source_key', 'source_object', 'target_table', 'run_mode', 'generation', 'last_error_code'] as $c) $t->string($c)->nullable();
foreach (['cursor', 'high_watermark'] as $c) $t->json($c)->nullable();
foreach (['window_started_at', 'window_ends_at', 'initial_completed_at', 'last_attempted_at', 'last_succeeded_at'] as $c) $t->dateTime($c, 6)->nullable();
$t->timestamps();
});
$schema->create('ops_jst_sku', function (Blueprint $t): void {
$t->char('_mirror_key', 64)->primary(); $t->string('sku_type'); $t->string('sku_id');
$t->decimal('cost_price', 20, 4)->nullable(); $t->dateTime('modified')->nullable(); $t->dateTime('_mirror_synced_at', 6);
});
$schema->create(Differ::SNAPSHOTS, function (Blueprint $t): void {
$t->char('source_key', 64)->primary(); $t->string('sku_id'); $t->decimal('cost_price', 20, 4); $t->dateTime('captured_at', 6);
});
$schema->create(Differ::OBSERVATIONS, function (Blueprint $t): void {
$t->char('id', 64)->primary(); $t->char('source_key', 64); $t->string('sku_id');
$t->decimal('before_price', 20, 4); $t->decimal('after_price', 20, 4);
$t->dateTime('previous_observed_at', 6); $t->dateTime('observed_at', 6); $t->dateTime('source_modified_at', 6)->nullable();
});
DB::connection('business_mirror')->table(Differ::STATES)->insert([
'dataset_key' => ProductCatalogMirrorRepository::DATASET_KEY, 'status' => 'succeeded',
'initial_completed_at' => '2026-10-19 00:00:00', 'last_succeeded_at' => '2026-10-20 11:59:00',
]);
$this->differ = new Differ(new GenericBusinessMirrorRepository(app('db')), new BusinessMirrorDatasetRegistry());
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_baseline_is_resumable_and_does_not_invent_history_then_observes_at_capture_time(): void
{
$db = DB::connection('business_mirror');
$this->product('a', 'SKU-A', 12);
$this->product('b', 'SKU-B', 20);
$this->assertSame('running', $this->differ->sync(1, 1)['status']);
$db->table(Differ::STATES)->where('dataset_key', Differ::DATASET_KEY)->update(['status' => 'failed']);
$this->assertSame('succeeded', $this->differ->sync(1, 3)['status']);
$this->assertSame(2, $db->table(Differ::SNAPSHOTS)->count());
$this->assertSame(0, $db->table(Differ::OBSERVATIONS)->count());
Carbon::setTestNow('2026-10-20 12:10:00');
$db->table('ops_jst_sku')->where('_mirror_key', 'a')->update(['cost_price' => 15.1234, '_mirror_synced_at' => '2026-10-20 12:09:00']);
$result = $this->differ->sync(10, 2);
$this->assertSame(1, $result['changes']);
$event = $db->table(Differ::OBSERVATIONS)->first();
$this->assertSame('2026-10-20 12:10:00.000000', $event->observed_at);
$this->assertSame('2026-01-01 00:00:00', $event->source_modified_at);
$this->assertEquals(12, $event->before_price);
$this->assertEquals(15.1234, $event->after_price);
$this->assertSame(0, $this->differ->sync(10, 2)['changes']);
$this->assertSame(1, $db->table(Differ::OBSERVATIONS)->count());
$this->assertFalse(Schema::connection('business_mirror')->hasTable('jushuitan_cost_price_history_rows'));
$this->assertFalse(Schema::connection('business_mirror')->hasTable('jushuitan_cost_price_history_sync_states'));
$rows = [(object) ['_mirror_key' => 'a'], (object) ['_mirror_key' => 'b']];
(new JushuitanCostPriceObservationRepository(new GenericBusinessMirrorRepository(app('db'))))->attach($rows);
$this->assertCount(1, $rows[0]->jushuitan_cost_price_observations);
$this->assertCount(0, $rows[1]->jushuitan_cost_price_observations);
$this->assertStringContainsString('不是真实改价时间', $rows[0]->jushuitan_cost_price_observations[0]['remark']);
}
public function test_new_products_null_prices_and_same_sku_on_different_source_rows_do_not_invent_changes(): void
{
$this->product('a', 'SAME', 12);
$this->product('b', 'SAME', 25);
$this->product('c', 'NULL', null);
$this->differ->sync();
Carbon::setTestNow('2026-10-20 12:10:00');
$this->product('d', 'NEW', 99, '2026-10-20 12:09:00');
$this->assertSame(0, $this->differ->sync()['changes']);
$this->assertSame(3, DB::connection('business_mirror')->table(Differ::SNAPSHOTS)->count());
DB::connection('business_mirror')->table(Differ::STATES)->where('dataset_key', ProductCatalogMirrorRepository::DATASET_KEY)->update(['status' => 'running']);
$this->assertSame('source_not_ready', $this->differ->sync()['status']);
}
private function product(string $key, string $sku, ?float $price, string $at = '2026-10-20 11:50:00'): void
{
DB::connection('business_mirror')->table('ops_jst_sku')->insert([
'_mirror_key' => $key, 'sku_id' => $sku, 'sku_type' => 'normal', 'cost_price' => $price,
'modified' => '2026-01-01 00:00:00', '_mirror_synced_at' => $at,
]);
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use App\Http\Controllers\Business\Pages;
use ReflectionMethod;
use Tests\TestCase;
class PerformanceDifferenceRemarkTest extends TestCase
{
private function remark(array $attributes): ?string
{
$row = (object) array_merge([
'platform' => '拼多多',
'match_status' => '已匹配',
'income_amount' => 0,
'discount_amount' => 0,
'offset_amount' => 0,
'refund_amount' => 0,
'product_amount' => 0,
'paid_amount' => 0,
'difference' => 0,
'planting_amount' => null,
'remark' => null,
], $attributes);
return (new ReflectionMethod(Pages::class, 'receiptDifferenceRemark'))->invoke(null, $row);
}
public function test_pinduoduo_full_refund_wins_before_other_difference_rules(): void
{
$this->assertSame('订单实付含已退款部分', $this->remark([
'income_amount' => 7.00,
'discount_amount' => 17.00,
'refund_amount' => -7.00,
'product_amount' => 42.00,
'paid_amount' => 6.99,
'difference' => 17.01,
]));
}
public function test_pinduoduo_partial_refund_marks_platform_payment_discount(): void
{
$this->assertSame('差额=平台支付立减', $this->remark([
'income_amount' => 896.00,
'refund_amount' => -552.36,
'product_amount' => 936.00,
'paid_amount' => 894.21,
'difference' => 1.79,
]));
$this->assertSame('差额=平台支付立减', $this->remark([
'income_amount' => 16.43,
'refund_amount' => -12.00,
'product_amount' => 21.90,
'paid_amount' => 16.42,
'difference' => 0.01,
]));
}
public function test_large_unexplained_pinduoduo_difference_stays_pending(): void
{
$this->assertSame('差额待核实', $this->remark([
'income_amount' => 130.00,
'refund_amount' => -50.00,
'product_amount' => 150.00,
'paid_amount' => 100.00,
'difference' => 30.00,
]));
}
}

View File

@ -32,7 +32,7 @@ class StoreReceiptMemoryTest extends TestCase
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, '淘宝',
'income_amount', 'expense_amount', 'online_real_store', $planting, [], [], $map, '淘宝',
);
}
@ -40,10 +40,10 @@ class StoreReceiptMemoryTest extends TestCase
{
$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('订单收入', '260630-10001', 100, 0, ['occurred_at' => '2026-08-02 10:00:00']),
$this->bill('订单收入', '260630-10001', 25),
$this->bill('冲减收入', '260630-10001', 0, 2),
$this->bill('退款', '260630-10001', 0, 7),
$this->bill('优惠收入', '20002', 4),
$this->bill('退款', '20002', 0, 1),
$this->bill('冲减收入', '', 0, 8),
@ -56,7 +56,7 @@ class StoreReceiptMemoryTest extends TestCase
$result = $this->aggregate((function () use ($bills) { yield from $bills; })(), ['10001' => true]);
$key = SyncStoreReceiptOrderMatches::normalizeShop('天猫_测试店');
$this->assertCount(2, $result['groups']);
$paid = $result['groups'][$key . '|10001'];
$paid = $result['groups'][$key . '|260630-10001'];
$this->assertSame(2, $paid['payment_count']);
$this->assertSame(125.0, $paid['income_amount']);
$this->assertSame(-3.0, $paid['discount_amount']);
@ -84,6 +84,55 @@ class StoreReceiptMemoryTest extends TestCase
$this->assertSame(-2.0, $result['promoOnlyGroups'][$key . '||退款']['refund_amount']);
}
public function test_same_core_with_different_date_prefixes_stays_as_two_orders(): void
{
$result = $this->aggregate([
$this->bill('订单收入', '260627-475634091573139', 13.91),
$this->bill('订单收入', '260713-475634091573139', 29.51),
]);
$shop = SyncStoreReceiptOrderMatches::normalizeShop('天猫_测试店');
$this->assertCount(2, $result['groups']);
$first = $result['groups'][$shop . '|260627-475634091573139'];
$second = $result['groups'][$shop . '|260713-475634091573139'];
$this->assertSame('475634091573139', $first['order_core_no']);
$this->assertSame('475634091573139', $second['order_core_no']);
$this->assertSame('260627-475634091573139', $first['order_identity']);
$this->assertSame('260713-475634091573139', $second['order_identity']);
$this->assertSame(13.91, $first['income_amount']);
$this->assertSame(29.51, $second['income_amount']);
}
public function test_order_side_exact_full_number_wins_and_ambiguous_core_fallback_never_sums(): void
{
$command = new SyncStoreReceiptOrderMatches();
$method = new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'matchOrderSide');
$shop = SyncStoreReceiptOrderMatches::normalizeShop('拼多多-欧意朗家具官方旗舰店');
$core = '475634091573139';
$first = '260627-475634091573139';
$second = '260713-475634091573139';
$amounts = [
$shop . '|' . $first => 13.91,
$shop . '|' . $second => 29.51,
];
$orderSeen = [
$shop . '|' . $first => true,
$shop . '|' . $second => true,
];
$identitiesByCore = [$core => [$shop => [$first => true, $second => true]]];
$coresByCore = [$core => [$shop => true]];
$exact = $method->invoke(
$command, $first, $core, $shop, $amounts, $orderSeen, [$core => true], $coresByCore, $identitiesByCore, '无金额'
);
$this->assertSame(['已匹配', 13.91, ''], $exact);
$ambiguous = $method->invoke(
$command, $core, $core, $shop, $amounts, $orderSeen, [$core => true], $coresByCore, $identitiesByCore, '无金额'
);
$this->assertSame(['未匹配', null, '订单侧同号多条'], $ambiguous);
}
public function test_empty_order_promotions_remain_separate_even_with_an_empty_order_payment(): void
{
$result = $this->aggregate([
@ -134,7 +183,7 @@ class StoreReceiptMemoryTest extends TestCase
$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, '京东',
['label' => '京东'], 'order_no', 'bill_date', 'income', '', '', [], [], [], $map, '京东',
);
$group = reset($result['groups']);
$this->assertSame(12.5, $result['incomeTotal']);

View File

@ -0,0 +1,148 @@
#!/bin/sh
# Run in a disposable Linux container, never on a production host.
set -eu
REPO="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
fixture="$(mktemp -d /tmp/caiwu-release-guard.XXXXXX)"
cleanup() {
for legacy in activate-release.sh activate-incremental-release.sh; do
chattr -i "$fixture/legacy-entrypoints/$legacy" 2>/dev/null || true
done
rm -rf "$fixture"
}
trap cleanup EXIT
mkdir -p "$fixture/bin"
cat > "$fixture/bin/docker" <<'EOF'
#!/bin/sh
printf '%s\n' "$*" >> "$DOCKER_CALLS"
if [ "$1" = inspect ]; then
case "$*" in
*Config.Image*) printf 'caiwu_akaunting:old\n' ;;
*) printf '%s\n' "${FAKE_RUNNING_SHA:-}" ;;
esac
exit 0
fi
exit 91
EOF
chmod +x "$fixture/bin/docker"
PATH="$fixture/bin:$PATH"
export PATH
old=1111111111111111111111111111111111111111
new=2222222222222222222222222222222222222222
requested=3333333333333333333333333333333333333333
passed=0
pass() { passed=$((passed + 1)); printf 'PASS: %s\n' "$*"; }
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
new_runtime() {
REMOTE_DIR="$fixture/$1"
mkdir -p "$REMOTE_DIR/runtime"
: > "$REMOTE_DIR/runtime/.env"
: > "$REMOTE_DIR/runtime/config-db.env"
printf 'CLICKHOUSE_PASSWORD=fixture\nBUSINESS_CLICKHOUSE_PASSWORD=fixture\n' > "$REMOTE_DIR/runtime/clickhouse.env"
printf 'BUSINESS_STORAGE_DRIVER=qiniu\nQINIU_ACCESS_KEY=fixture\nQINIU_SECRET_KEY=fixture\n' > "$REMOTE_DIR/runtime/qiniu-storage.env"
printf '%s\n' "$old" > "$REMOTE_DIR/.deployed-sha"
printf 'previous runtime configuration\n' > "$REMOTE_DIR/compose.jiangling.yml"
DOCKER_CALLS="$REMOTE_DIR/docker-calls"
: > "$DOCKER_CALLS"
FAKE_RUNNING_SHA="$old"
export DOCKER_CALLS FAKE_RUNNING_SHA
}
for activation in activate-release.sh activate-incremental-release.sh; do
for scenario in stale missing-protocol marker-image-mismatch unchanged; do
new_runtime "$activation-$scenario"
: > "$REMOTE_DIR/upload.tar.gz"
expected="$old"
case "$scenario" in
stale) printf '%s\n' "$new" > "$REMOTE_DIR/.deployed-sha"; FAKE_RUNNING_SHA="$new" ;;
marker-image-mismatch) FAKE_RUNNING_SHA="$new" ;;
esac
set +e
if [ "$scenario" = missing-protocol ]; then
sh "$REPO/deploy/$activation" "$REMOTE_DIR/upload.tar.gz" tag "$REMOTE_DIR" "$requested" 0 2 > "$REMOTE_DIR/output" 2>&1
else
sh "$REPO/deploy/$activation" "$REMOTE_DIR/upload.tar.gz" tag "$REMOTE_DIR" "$requested" 0 2 "$expected" fixture > "$REMOTE_DIR/output" 2>&1
fi
status=$?
set -e
if [ "$scenario" = unchanged ]; then
[ "$status" -eq 91 ] || { cat "$REMOTE_DIR/output"; fail 'accepted release did not reach image loading'; }
else
[ "$status" -ne 0 ] || fail "$activation accepted $scenario"
if grep -Ev '^inspect ' "$DOCKER_CALLS" | grep -q .; then
fail "$activation mutated Docker before rejecting $scenario"
fi
[ "$(cat "$REMOTE_DIR/compose.jiangling.yml")" = 'previous runtime configuration' ] || fail 'rejected release overwrote runtime configuration'
fi
if [ "$scenario" != missing-protocol ]; then
[ -s "$REMOTE_DIR/runtime/private/deployments.tsv" ] || fail 'deployment receipt missing'
if [ "$scenario" != unchanged ]; then
grep -q 'blocked' "$REMOTE_DIR/runtime/private/deployments.tsv" || fail 'rejection not recorded'
fi
fi
pass "$activation: $scenario"
done
done
. "$REPO/deploy/release-guard.sh"
new_runtime bootstrap
rm "$REMOTE_DIR/.deployed-sha"
FAKE_RUNNING_SHA=''
EXPECTED_PREVIOUS_SHA=none
DEPLOY_SHA="$new"
caiwu_verify_release_base
pass 'first deployment with no running application'
EXPECTED_PREVIOUS_SHA=''
if caiwu_verify_release_base 2>/dev/null; then fail 'empty baseline accepted'; fi
pass 'empty baseline rejected'
new_runtime ordered-uploaders
EXPECTED_PREVIOUS_SHA="$old"
DEPLOY_SHA="$new"
# Both uploaders checked the same old revision. The newer one activates first.
(
exec 9> "$REMOTE_DIR/.deploy.lock"
flock -x 9
caiwu_verify_release_base
printf '%s\n' "$new" > "$REMOTE_DIR/.deployed-sha"
)
FAKE_RUNNING_SHA="$new"
DEPLOY_SHA="$requested"
if (
exec 9> "$REMOTE_DIR/.deploy.lock"
flock -x 9
caiwu_verify_release_base
) 2>/dev/null; then fail 'delayed uploader overwrote the newer release'; fi
[ "$(cat "$REMOTE_DIR/.deployed-sha")" = "$new" ] || fail 'production marker regressed'
pass 'delayed uploader is rejected after a newer activation'
new_runtime staged-config
RELEASE_DIR="$fixture/staging"
RELEASE_APP_URL=''
mkdir -p "$RELEASE_DIR"
for file in compose.jiangling.yml activate-release.sh activate-incremental-release.sh release-guard.sh retired-activation.sh install-release-protection.sh config-db-init.sh run-finance-batch.sh clickhouse-resource-policy.xml update-runtime-url.sh configure-dingtalk-callback.sh host-nginx-8442-dingtalk.conf setup-incremental-registry.sh registry-config.yml; do
printf 'new staged runtime configuration\n' > "$RELEASE_DIR/$file"
done
printf '#!/bin/sh\nexit 0\n' > "$RELEASE_DIR/install-release-protection.sh"
rm "$RELEASE_DIR/registry-config.yml"
if caiwu_install_release_files 2>/dev/null; then fail 'partial upload accepted'; fi
[ "$(cat "$REMOTE_DIR/compose.jiangling.yml")" = 'previous runtime configuration' ] || fail 'partial upload replaced runtime files'
pass 'incomplete staging directory leaves runtime files untouched'
printf 'complete\n' > "$RELEASE_DIR/registry-config.yml"
caiwu_install_release_files
[ "$(cat "$REMOTE_DIR/compose.jiangling.yml")" = 'new staged runtime configuration' ] || fail 'staged configuration not installed'
pass 'complete isolated upload installs the intended runtime files'
new_runtime legacy-entrypoints
for legacy in activate-release.sh activate-incremental-release.sh; do
printf '#!/bin/sh\nexit 0\n' > "$REMOTE_DIR/$legacy"
done
sh "$REPO/deploy/install-release-protection.sh" "$REMOTE_DIR"
for legacy in activate-release.sh activate-incremental-release.sh; do
if cp "$REPO/deploy/$legacy" "$REMOTE_DIR/$legacy" 2>/dev/null; then fail 'old uploader replaced the protected entry point'; fi
if sh "$REMOTE_DIR/$legacy" old-arguments 2>/dev/null; then fail 'old activation path accepted a release'; fi
pass "$legacy: legacy uploader and activation both rejected"
done
sh "$REPO/deploy/install-release-protection.sh" "$REMOTE_DIR"
pass 'installing entry point protection again is idempotent'
printf 'PASS: %s release protection scenarios\n' "$passed"