Compare commits

..

47 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
144937bfa3 补齐京东钱包每日账单同步并增加覆盖校验 2026-10-20 12:20:13 +08:00
chenxi
5fe92bd05c 商品成本:订单侧不取套餐/服务占位编码 TCXJ、FUWU
订单原始数据里的 TCXJ(套餐)、FUWU(服务)不是实物商品,商品成本同步时直接剔除
(SQL 层过滤 + 组合装拆出的组件里同样剔除),页面口径说明同步补充。
2026-10-20 11:53:03 +08:00
81bf2bd779 供应商对账:厂家端隐藏上传记录并调整内部位置 (#404)
厂家门户隐藏最近上传摘要;内部厂家端上传记录保留并移动到页面底部。
2026-10-20 11:39:20 +08:00
cad8369fdb 业绩明细:新增1688红包补贴备注并优化筛选统计 2026-10-20 11:28:53 +08:00
2a2df7451e 供应商对账:退货表格改为十条并固定表头 (#403)
退货表格调整为每页十条,并增加固定高度滚动区和吸顶表头。
2026-10-20 11:21:22 +08:00
chenxi
5e05a8728e 商品成本:未匹配单价统计排除「数量与单价口径不一致」行,避免两处重复计数 2026-10-20 11:19:53 +08:00
chenxi
6b044e727b 商品成本:单价增加「普通商品资料成本价」第三档兜底
取价顺序改为:商品月加权平均单价(仓库简称+商品编码)→ 线上代发价格(采购单价/钉钉代发单价)
→ 普通商品资料(operations_replica.rpt.jst_sku)里的商品成本价(sku_type=normal 且成本价>0)。
页面汇总新增「其中用商品资料成本价兜底」,同步命令日志同步输出三档兜底行数。

7 月未匹配单价行数 3,599→2,098(资料成本价补 1,071 行),8 月 2,713→2,765(含数据重跑差异)。
2026-10-20 11:16:56 +08:00
145a62c397 供应商对账:增加发货日期范围筛选 (#402)
内部厂家端和厂家门户增加日期面板与近7天、本月、上月、全部日期快捷筛选。
2026-10-20 11:00:43 +08:00
21b29d06eb 种菜未报销排除种菜明细已报销订单 2026-10-20 11:03:06 +08:00
ffa4bbf15f 业绩明细新增种菜未报销列 2026-10-20 10:55:41 +08:00
75aac2e5aa 供应商对账:厂家端退货汇总改为件数与金额 (#401)
内部厂家端和厂家门户改为大号退货件数、退货金额及确认状态卡片,汇总不再显示行数。
2026-10-20 10:50:08 +08:00
0104b9b18e 供应商对账:内部可搜索厂家并隔离厂家名称 (#400)
内部厂家端改为可搜索且精确匹配供应商名称;厂家账号仅能上传和查看自己绑定的供应商数据。
2026-10-20 10:35:54 +08:00
4511f6c71a 业绩明细新增种菜报销列 2026-10-20 10:36:24 +08:00
cdabffb3a6 业绩明细删除费用项列及相关归集逻辑 2026-10-20 10:20:55 +08:00
4e174963e8 fix(种菜损耗): 透明挂钩/虚拟条码整单不扣款;拍A发B 从种菜报销直接挑出发运营
财务确认(2026-10-20):种菜损耗是查种菜报销的单有没有真实发出有价值的货——
- 发货数据中交易单号下出现信誉订单虚拟条码或透明挂钩(含条形码 GG 开头)即整单不扣款;
- 任务备注/卖家备注含拍A发B/拍A发A 的不扣款,且无论是否匹配到发货都从种菜报销中挑出发运营;
- “导出拍A发B(发运营)”直接按「种菜报销」页字段输出;其余真实发货的单按成本价×数量+运费扣款。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-10-20 10:10:29 +08:00
9b6c3d2477 业绩明细排除京东网银钱包账户数据 2026-10-20 10:00:55 +08:00
366dcc1da7 业绩明细新增费用项并按科目映射费用备注归集 2026-10-20 10:00:53 +08:00
56909bd669 货款对账:每页固定展示15条数据 (#399) 2026-10-20 09:48:25 +08:00
19bb6a8a75 退货对账:每页固定展示15条数据 (#398) 2026-10-20 09:33:20 +08:00
dd9cab671b 合并业绩明细万元显示与口径说明整理 2026-10-20 09:32:52 +08:00
1acd97b9e0 业绩明细:金额改为万元并整理指标口径说明
金额构成统一按万元显示两位小数并逐项标注万;长口径说明拆为匹配口径、金额字段、差额判定、匹配原因四组,完整规则默认折叠。
2026-10-20 09:32:42 +08:00
c0cc8c7e60 供应商对账:同步退货转物流记账归属 (#397) 2026-10-20 09:21:55 +08:00
bf290d5353 fix(种菜损耗): 交易单号下含信誉订单虚拟条码的整单不扣款
10 月生产数据里,部分种菜单的交易单号在发货数据中与大量合并发货的别的订单商品挂在一起(同一交易单号下几十个实物+信誉订单虚拟条码),
被错算为扣款。财务确认信誉订单虚拟条码不扣款:该单号下出现信誉订单虚拟条码(X00006/X00002)即整单归入不扣款;
“晒图有奖励(无实物)”等无实物商品也不计扣款。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-10-20 08:53:17 +08:00
31f6c72bf3 优化订单与账单镜像分批读取及内存释放 2026-10-19 21:04:42 +08:00
bbb967e110 降低业绩匹配任务峰值内存 2026-10-19 19:08:39 +08:00
a6e0b2ac7d 业绩明细汇总拆开复杂差额规则
月度汇总先按默认差额聚合,只有实收/应付兜底行再计算完整差额原因。7月验证结果一致,汇总耗时由约3.49秒降到0.71秒。
2026-10-19 18:24:13 +08:00
8e4bf99483 优化京东账单首次汇总与候选查询的覆盖索引 2026-10-19 18:14:37 +08:00
d735de7864 修复发布冷缓存时店铺筛选退回大范围扫描 2026-10-19 18:07:12 +08:00
e4d82dab2a chore(部署): 发布前拦截会覆盖线上已发布提交的版本
今天连续发生多次热修复/旧版本发布把别人刚上线的改动冲掉(种菜损耗运费口径被撤回两次)。
发布脚本读取线上 .deployed-sha 后:本地没有该提交先 git fetch 再判断;线上提交不是本次发布版本的祖先时直接拒绝(服务器不做任何变更);
新增 -AllowRollback / --allow-rollback 仅供负责人决定撤回时使用。AGENTS.md 第 8 节补充热修复须基于线上版本并立即合入 main。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-10-19 18:00:07 +08:00
17429d65ab perf(财务): 减少大表扫描与重复重算,串行受限导出 2026-10-19 17:53:14 +08:00
a124e9906b 业绩明细收入统一按科目映射表判桶
账单明细页已优先使用科目映射表,但业绩同步的收入仍优先取旧总账备注表,导致两套口径冲突。改为收入、优惠、冲减、退款统一使用当前科目映射表,旧表仅在当前映射缺失时兜底。
2026-10-19 17:28:31 +08:00
4c617fd063 fix(种菜损耗): 扣款金额改为 成本价×数量 + 该单发货运费
财务更正口径:种菜损耗扣款 = Σ 发货条码在普通商品资料的成本价 × 数量 + 该订单在发货数据中的运费合计,
不再按成本价×1.2;运费为空的订单标"运费缺失",缺失部分不计入扣款金额。

涉及:金额计算展示(只读);不涉及迁移、权限和上游写入。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-10-19 16:46:46 +08:00
528701fdec 附加费结算导入核验:只填单号+类型,自动匹配快递并按报价核算 (#391) 2026-10-19 16:44:50 +08:00
103 changed files with 7775 additions and 817 deletions

View File

@ -64,6 +64,7 @@
## 8. 构建与生产部署
- 发布脚本会拒绝覆盖“不在本次版本里”的线上提交(防止互相冲掉已上线的改动)。热修复必须基于线上当前版本构建,发布后立即合入 `main`;遇到拦截先 `git fetch` 并合入最新 `main` 或线上热修复分支,不得随手加 `-AllowRollback`(`--allow-rollback`),撤回线上发布需负责人明确决定。
- 普通代码发布由任务 Codex 在完成验证与合并后直接执行,不要求用户再次说“部署、上线”;用户明确要求仅本地开发、仅审查或暂不上线时遵循其范围。正式发布只使用已合并的最新 `main`。
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`,macOS 开发机使用 `bash scripts/deploy-from-mac.sh`;两者流程一致(本机构建前端与镜像、迁移拦截、增量推送服务器本机 Registry、激活与健康检查、双版本保留)。服务器只运行开发机已构建的 `caiwu_akaunting:<commit>` 镜像,禁止在服务器执行 Composer、npm 或 Docker 构建。
- 新人只需表达部署需求;Codex 使用默认生产入口运行脚本。首次通过本机窗口提供一次服务器密码后,加密保存在本人电脑供后续自动使用,不要求新人准备私钥、执行命令或每次重输密码。已有私钥认证仍兼容。

View File

@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\WorkOrderWaybillLookup;
class BackfillWorkOrderWaybillLookup extends Command
{
protected $signature = 'business-mirror:index-work-order-waybills {--batch=500} {--max-batches=2} {--pause-ms=50}';
protected $description = '按稳定游标建立查件运单小索引;生产须使用 run-finance-batch.sh 执行';
public function handle(WorkOrderWaybillLookup $lookup): int
{
$lock = Cache::store('upstream_shared')->lock('work-order-waybill-lookup:backfill', 3600);
if (! $lock->get()) {
$this->info('已有运单索引任务运行,本轮跳过。');
return self::SUCCESS;
}
try {
$result = $lookup->backfill((int) $this->option('batch'), max(0, (int) $this->option('max-batches')), max(0, (int) $this->option('pause-ms')));
$this->info(json_encode($result));
return self::SUCCESS;
} finally {
$lock->release();
}
}
}

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

@ -0,0 +1,19 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
class RefreshStoreReportPaidSales extends Command
{
protected $signature = 'business-mirror:refresh-paid-sales {--full : 强制全量校准派生汇总}';
protected $description = '只刷新业绩明细派生的销量汇总,不修改原始财务明细';
public function handle(StoreReportMirrorSynchronizer $synchronizer): int
{
$result = $synchronizer->syncPaidSales((bool) $this->option('full'));
$this->info(json_encode($result));
return $result['status'] === 'succeeded' ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\AccountBillsExportTasks;
class RunAccountBillsExportQueue extends Command
{
protected $signature = 'account-bills:exports:run {--max-jobs=1}';
protected $description = '依次消费账单导出任务,生产从受限批容器启动';
public function handle(AccountBillsExportTasks $tasks): int
{
$limit = max(0, (int) $this->option('max-jobs'));
for ($processed = 0; $limit === 0 || $processed < $limit; $processed++) {
$task = $tasks->nextQueued();
if ($task === null) break;
$code = $this->call('account-bills:export-run', ['token' => $task['token']]);
if ($code === 75) break;
}
return self::SUCCESS;
}
}

View File

@ -28,6 +28,17 @@ class RunAccountBillsExportTask extends Command
protected $description = '执行店铺收款账户账单导出任务(后台进程调用)';
public function handle(AccountBillsExportTasks $tasks, AccountLedgerRemarkMap $remarkMap): int
{
$lock = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock('account-bills:export-worker', 10800);
if (! $lock->get()) return 75;
try {
return $this->runTask($tasks, $remarkMap);
} finally {
$lock->release();
}
}
private function runTask(AccountBillsExportTasks $tasks, AccountLedgerRemarkMap $remarkMap): int
{
$token = (string) $this->argument('token');
$task = $this->findTask($tasks, $token);
@ -36,6 +47,7 @@ class RunAccountBillsExportTask extends Command
return self::FAILURE;
}
if (($task['status'] ?? '') !== 'queued') return self::SUCCESS;
$task = $tasks->patch($task, ['status' => 'running', 'started_at' => AccountBillsExportTasks::beijingNow()]);
@ -110,7 +122,7 @@ class RunAccountBillsExportTask extends Command
private function findTask(AccountBillsExportTasks $tasks, string $token): ?array
{
$root = storage_path('app/account-bills-exports');
$root = $tasks->rootDir();
foreach (glob($root . '/*/' . $token . '.json') ?: [] as $path) {
$task = json_decode((string) file_get_contents($path), true);
if (is_array($task) && ($task['token'] ?? null) === $token) {

View File

@ -13,12 +13,12 @@ use Throwable;
/**
* 科目映射表变更后的业绩明细自动重跑队列。
*
* 一个常驻进程依次处理队列里的「平台 × 月份」;每个实际同步放在独立 PHP 子进程中,
* 避免大平台重跑时的内存累积。也可手动执行本命令补跑。
* 每轮按预算处理队列里的「平台 × 月份」;实际同步放在独立 PHP 子进程中,
* 避免大平台重跑时的内存累积,并让其它同步有机会取得全局锁。
*/
class RunAccountMappingResync extends Command
{
protected $signature = 'store-receipt:auto-resync';
protected $signature = 'store-receipt:auto-resync {--max-jobs=1 : 每轮最多任务数,0为不限} {--max-seconds=300 : 单个任务结束后检查本轮时间预算}';
protected $description = '处理科目映射表变更触发的业绩明细重跑队列';
@ -39,7 +39,10 @@ class RunAccountMappingResync extends Command
->where('updated_at', '<', now()->subMinutes(60))
->update(['status' => 'pending', 'updated_at' => now()]);
while (true) {
$processed = 0;
$maxJobs = max(0, (int) $this->option('max-jobs'));
$deadline = microtime(true) + max(1, (int) $this->option('max-seconds'));
while (($maxJobs === 0 || $processed < $maxJobs) && microtime(true) < $deadline) {
$job = DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('status', 'pending')
->orderBy('id')
@ -47,6 +50,7 @@ class RunAccountMappingResync extends Command
if ($job === null) {
break;
}
$processed++;
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('id', $job->id)
@ -58,19 +62,7 @@ class RunAccountMappingResync extends Command
]);
try {
$process = Process::fromShellCommandline(
implode(' ', [
escapeshellarg(PHP_BINARY ?: 'php'),
'artisan',
'store-receipt:order-match:sync',
escapeshellarg((string) $job->month),
'--platform=' . escapeshellarg((string) $job->platform),
]),
base_path()
);
$process->setTimeout(7200);
$process->run();
$output = trim($process->getOutput() . "\n" . $process->getErrorOutput());
[$succeeded, $output] = $this->runSync($job);
// 同步期间又保存了映射时,dispatchFor 已把状态改回 pending;
// 这里只在仍为 running 时写 done,不能吞掉新请求。
@ -78,9 +70,9 @@ class RunAccountMappingResync extends Command
->where('id', $job->id)
->where('status', 'running')
->update([
'status' => $process->isSuccessful() ? 'done' : 'failed',
'status' => $succeeded ? 'done' : 'failed',
'message' => mb_substr(
$output !== '' ? $output : ($process->isSuccessful() ? 'OK' : '同步命令执行失败'),
$output !== '' ? $output : ($succeeded ? 'OK' : '同步命令执行失败'),
0,
1000
),
@ -104,4 +96,17 @@ class RunAccountMappingResync extends Command
return self::SUCCESS;
}
/** @return array{bool,string} */
protected function runSync(object $job): array
{
$process = new Process([
PHP_BINARY ?: 'php', 'artisan', 'store-receipt:order-match:sync',
(string) $job->month, '--platform=' . $job->platform,
], base_path());
$process->setTimeout(7200);
$process->run();
return [$process->isSuccessful(), trim($process->getOutput() . "\n" . $process->getErrorOutput())];
}
}

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

File diff suppressed because it is too large Load Diff

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 = '未匹配商品单价';
@ -54,6 +57,9 @@ class SyncStoreReceiptProductCost extends Command
private const PRICE_SOURCE_DING = '线上代发价格-代发单价';
/** 最后兜底:普通商品资料(operations_replica.rpt.jst_sku)里的成本价。 */
private const PRICE_SOURCE_CATALOG = '普通商品资料成本价';
/** 线上代发价格的采购单来源(与「线上代发价格」页面同一口径)。 */
private const PURCHASE_PRICE_TABLE = 'product_purchase_price_mirror_rows';
@ -114,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);
}
@ -168,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(
@ -181,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());
@ -236,11 +249,14 @@ class SyncStoreReceiptProductCost extends Command
$prices = $this->weightedAverageUnitPrices($mirror, $reportMonth);
// 月加权平均单价匹配不到时,用「线上代发价格」(采购单价 / 钉钉代发单价)兜底。
$fallbackPrices = $this->onlineDaifaUnitPrices($mirror);
// 代发价格也没有时,再用普通商品资料里的商品成本价兜底。
$catalogPrices = $this->catalogCostPrices($mirror);
$this->info(sprintf(
'仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d',
'仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d,商品资料成本价条目:%d',
count($shortNames['by_provider']),
count($prices),
count($fallbackPrices)
count($fallbackPrices),
count($catalogPrices)
));
$this->pruneTarget($connection, $reportMonth, $platform);
@ -254,13 +270,12 @@ class SyncStoreReceiptProductCost extends Command
$missingPriceRows = 0;
$missingWarehouseRows = 0;
$fallbackPriceRows = 0;
$catalogPriceRows = 0;
$mismatchRows = 0;
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;
// 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。
@ -272,6 +287,15 @@ class SyncStoreReceiptProductCost extends Command
$fallbackPriceRows++;
}
}
// 线上代发价格也没有时,最后用普通商品资料的商品成本价兜底。
if ($price === null) {
$catalogPrice = $catalogPrices[$line['sku']] ?? null;
if ($catalogPrice !== null) {
$price = $catalogPrice;
$priceSource = self::PRICE_SOURCE_CATALOG;
$catalogPriceRows++;
}
}
$quantity = round($line['qty'], 4);
$unitPrice = $price === null ? null : round((float) $price, 2);
$cost = $unitPrice === null ? null : round($quantity * $unitPrice, 2);
@ -342,9 +366,10 @@ class SyncStoreReceiptProductCost extends Command
$this->info(sprintf(' 商品数量合计:%s', number_format($quantityTotal, 2)));
$this->info(sprintf(' 商品成本合计:%s', number_format($costTotal, 2)));
$this->info(sprintf(
' 未匹配仓库简称:%d 行;线上代发价格兜底:%d 行;仍未匹配商品单价:%d 行;数量与单价口径不一致(不计成本):%d 行',
' 未匹配仓库简称:%d 行;线上代发价格兜底:%d 行;商品资料成本价兜底:%d 行;仍未匹配商品单价:%d 行;数量与单价口径不一致(不计成本):%d 行',
$missingWarehouseRows,
$fallbackPriceRows,
$catalogPriceRows,
$missingPriceRows,
$mismatchRows
));
@ -442,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;
}
/**
@ -459,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);
@ -468,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];
}
/**
@ -589,6 +628,32 @@ class SyncStoreReceiptProductCost extends Command
return $prices;
}
/**
* 普通商品资料(operations_replica.rpt.jst_sku → ops_jst_sku)里的商品成本价:
* 只取普通商品(sku_type=normal)且成本价大于 0 的记录,按商品编码建索引。
*
* @return array<string,float>
*/
private function catalogCostPrices($mirror): array
{
$prices = [];
try {
foreach ($mirror->table('ops_jst_sku')
->where('sku_type', 'normal')
->where('cost_price', '>', 0)
->get(['sku_id', 'cost_price']) as $row) {
$sku = mb_strtoupper(trim((string) $row->sku_id));
if ($sku !== '') {
$prices[$sku] = round((float) $row->cost_price, 2);
}
}
} catch (Throwable $exception) {
$this->warn('普通商品资料成本价读取失败(跳过该兜底来源):' . $exception->getMessage());
}
return $prices;
}
private function pruneTarget(string $connection, string $reportMonth, string $platform): void
{
$query = DB::connection($connection)->table(self::TARGET_TABLE)->where('report_month', $reportMonth);

View File

@ -0,0 +1,172 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class VerifyErpDailyBillCoverage extends Command
{
protected $signature = 'business-mirror:verify-erp-daily-coverage
{--from=2026-07-01 : 起始日期 YYYY-MM-DD}
{--to= : 截止日期 YYYY-MM-DD,默认昨日}
{--json : 输出 JSON}';
protected $description = '核对京东日账单、京东钱包、头条放心购的月份与截止日覆盖是否完整';
private const TARGETS = [
'jd-daily' => [
'label' => '京东日账单',
'periods' => 'jd_daily_bill_periods',
'rows' => 'jd_daily_bill_rows',
],
'jd-wallet' => [
'label' => '京东钱包',
'periods' => 'jd_wallet_bill_periods',
'rows' => 'jd_wallet_bill_rows',
],
'douyin' => [
'label' => '头条放心购',
'periods' => 'douyin_authoritative_periods',
'rows' => 'douyin_authoritative_bills',
],
];
public function handle(): int
{
$fromText = (string) $this->option('from');
$toText = (string) ($this->option('to') ?: now('Asia/Shanghai')->subDay()->toDateString());
$from = $this->date($fromText);
$to = $this->date($toText);
if ($from === null || $to === null || $from->gt($to)) {
$this->error('日期范围无效,应为 from <= to,格式 YYYY-MM-DD。');
return self::FAILURE;
}
$db = DB::connection(config('upstream_mirrors.connection', 'business_mirror'));
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
$results = [];
$failed = false;
foreach (self::TARGETS as $key => $target) {
if (! $schema->hasTable($target['periods']) || ! $schema->hasTable($target['rows'])) {
$results[] = [
'dataset' => $key,
'label' => $target['label'],
'month' => null,
'required_start' => $from->toDateString(),
'required_through' => $to->toDateString(),
'imported_start' => null,
'imported_end' => null,
'row_count' => 0,
'registered_rows' => null,
'income' => null,
'expense' => null,
'status' => 'missing_table',
'issue' => '镜像表不存在',
];
$failed = true;
continue;
}
$month = $from->copy()->startOfMonth();
while ($month->lte($to)) {
$monthEnd = $month->copy()->endOfMonth();
$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())
->first();
$status = 'ok';
$rowCount = 0;
$issue = null;
$summary = null;
if ($period === null) {
$status = 'missing_month';
$issue = '缺少该月份导入记录';
} elseif (Carbon::parse((string) $period->period_end)->lt($requiredEndExclusive)) {
$status = 'partial';
$issue = '导入截止日早于要求日期';
} else {
$summary = $db->table($target['rows'])
->where('generation', $period->active_generation)
->selectRaw('COUNT(*) AS rows_count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')
->first();
$rowCount = (int) ($summary->rows_count ?? 0);
if ($rowCount === 0) {
$status = 'empty';
$issue = '有效版本没有明细';
} elseif ($rowCount !== (int) $period->row_count) {
$status = 'row_count_mismatch';
$issue = '有效版本实际行数与期间登记不一致';
} elseif (abs(round((float) $summary->income, 2) - (float) $period->income) > 0.005
|| abs(round((float) $summary->expense, 2) - (float) $period->expense) > 0.005) {
$status = 'amount_mismatch';
$issue = '有效版本收支合计与期间登记不一致';
}
}
if ($status !== 'ok') {
$failed = true;
}
$results[] = [
'dataset' => $key,
'label' => $target['label'],
'month' => $month->format('Y-m'),
'required_start' => $requiredStart->toDateString(),
'required_through' => $requiredThrough->toDateString(),
'imported_start' => $period?->period_start,
'imported_end' => $period?->period_end,
'row_count' => $rowCount,
'registered_rows' => $period === null ? null : (int) $period->row_count,
'income' => isset($summary) ? round((float) $summary->income, 2) : null,
'expense' => isset($summary) ? round((float) $summary->expense, 2) : null,
'status' => $status,
'issue' => $issue,
];
unset($summary);
$month->addMonthNoOverflow()->startOfMonth();
}
}
if ($this->option('json')) {
$this->line(json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
} else {
$this->table(
['数据集', '月份', '要求覆盖', '导入覆盖', '实际行数', '登记行数', '收入', '支出', '状态', '说明'],
array_map(static fn (array $row): array => [
$row['label'],
$row['month'] ?? '—',
($row['required_start'] ?? '—') . ' ~ ' . ($row['required_through'] ?? '—'),
($row['imported_start'] ?? '—') . ' ~ ' . ($row['imported_end'] ?? '—'),
number_format((int) $row['row_count']),
$row['registered_rows'] === null ? '—' : number_format((int) $row['registered_rows']),
$row['income'] === null ? '—' : number_format((float) $row['income'], 2),
$row['expense'] === null ? '—' : number_format((float) $row['expense'], 2),
$row['status'],
$row['issue'] ?? '',
], $results),
);
}
return $failed ? self::FAILURE : self::SUCCESS;
}
private function date(string $value): ?Carbon
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
return null;
}
$date = Carbon::createFromFormat('!Y-m-d', $value, 'Asia/Shanghai');
return $date !== false && $date->format('Y-m-d') === $value ? $date : null;
}
}

View File

@ -89,9 +89,10 @@ class AccountMappingActions extends Controller
}
$values = $this->payloadValues($request);
$this->store->update($id, $values, $this->userId());
$after = $this->store->update($id, $values, $this->userId());
$hint = $this->dispatchResync([(string) ($before['platform'] ?? ''), (string) ($values['platform'] ?? '')]);
$hint = AccountMappingStore::requiresReceiptResync($before, $after)
? $this->dispatchResync([(string) ($before['platform'] ?? ''), (string) ($after['platform'] ?? '')]) : '';
return response()->json(['message' => '已保存。' . $hint] + $this->payload());
}
@ -241,10 +242,7 @@ class AccountMappingActions extends Controller
$parsed = $this->importer->parse($file);
$result = $this->store->importRows($parsed['rows'], $request->boolean('overwrite'), $this->userId());
$message = "导入完成:识别 {$result['total']} 行,新增 {$result['created']} 条,更新 {$result['updated']} 条,跳过重复 {$result['skipped']} 条。";
$platforms = array_values(array_unique(array_map(
static fn (array $row): string => (string) ($row['platform'] ?? ''),
$parsed['rows']
)));
$platforms = $result['resync_platforms'];
$message .= $this->dispatchResync($platforms);
return response()->json(['message' => $message, 'result' => $result] + $this->payload());

View File

@ -105,11 +105,15 @@ class Pages extends Controller
'拼多多',
'京东账单',
'京喜账单',
'京东网银钱包',
'抖音',
'得物',
];
/** 业绩明细不纳入的收款账户:京东网银钱包的收、支均不取。 */
private const RECEIPT_MATCH_EXCLUDED_PLATFORMS = [
'京东网银钱包',
];
/**
* 京东「直营补贴款」备注适用的京喜店铺(前缀)。
*
@ -136,6 +140,12 @@ class Pages extends Controller
'未匹配',
];
/** 差额按实收/应付口径兜底时使用的备注;这部分行才需要判断复杂差额原因。 */
private const RECEIPT_BASIS_FALLBACK_REMARKS = [
'订单实付按卖家实收核对',
'订单实付按应付/已付核对',
];
/** 业绩明细「异常标注」筛选项,取值与同步命令写入的标注一致。 */
private const RECEIPT_REMARK_OPTIONS = [
'账单无单号',
@ -279,14 +289,21 @@ class Pages extends Controller
];
}
private static function receiptReasonNotBasisMarkedSql(): string
{
return "COALESCE(remark, '') NOT IN ('订单实付按卖家实收核对', '订单实付按应付/已付核对')";
}
/**
* 「差额异常备注」原因(不落库):说明差额为什么产生,一行最多一个,按顺序取第一条命中。
* 未匹配行 → 订单实付未匹配(细分原因见「未匹配原因」列);已匹配但基准金额=0 → 订单实付取数为0;
* 基准金额没有减掉优惠/冲减时,差额正好等于对应项。差额按有效值判定(未匹配按基准金额=0)。
* 「差额异常备注」基础原因(不落库),按页面优先级排列。
*
* @return array<string,string> 备注文字 => SQL 条件(已按顺序排除前面命中的行)
* 这里保留可重叠的原始条件;需要“一行只取第一个原因”时走
* receiptDifferenceRemarkIndexSql(),由单次 CASE 按顺序命中,避免把
* 前序大段条件重复拼进后续每一条,拖慢筛选联动。
*
* @return array<string,string> 备注文字 => 原始 SQL 条件
*/
private static function receiptDifferenceRemarkConditions(): array
private static function receiptDifferenceRemarkBaseRules(): array
{
$basis = self::differenceBasis();
$column = $basis['column'];
@ -355,9 +372,13 @@ 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 = "COALESCE(remark, '') NOT IN ('订单实付按卖家实收核对', '订单实付按应付/已付核对')";
$notBasisMarked = self::receiptReasonNotBasisMarkedSql();
$rules[$label . '未减冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(discount_amount, 0))) < 0.005 AND " . $notBasisMarked;
$rules[$label . '未减优惠'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(offset_amount, 0))) < 0.005 AND " . $notBasisMarked;
$rules[$label . '未减优惠和冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - COALESCE(income_amount, 0)) < 0.005 AND " . $notBasisMarked;
@ -393,29 +414,66 @@ 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 先采后付订单的订单实付包含平台红包补贴,但支付宝实际到账是扣除补贴后的现金;
// 仅对已匹配、负差额、无优惠/冲减/退款且当前没有口径标识的行标注,不改差额金额。
$rules['订单实付含平台红包补贴'] = "platform = '淘系支付宝'"
. " AND (store_name LIKE '新佰广%' OR store_name LIKE '阿里巴巴%' OR store_name LIKE '1688%')"
. ' AND match_status = \'已匹配\''
. ' AND ' . $effective . ' < -0.005'
. ' AND ABS(COALESCE(discount_amount, 0)) < 0.005'
. ' AND ABS(COALESCE(offset_amount, 0)) < 0.005'
. ' AND ABS(COALESCE(refund_amount, 0)) < 0.005'
. ' AND COALESCE(paid_amount, 0) > COALESCE(income_amount, 0) + 0.005'
. ' AND ' . $notBasisMarked;
// 京喜分位尾差:具体原因都没命中时,1–3 分钱按四舍五入尾差标注。
$rules['差额=分位四舍五入尾差'] = "platform = '京喜账单' AND ABS(" . $effective . ') <= 0.0301 AND ' . $notBasisMarked;
$conditions = [];
$seen = [];
foreach ($rules as $label => $condition) {
$condition = 'ABS(' . $effective . ') > 0.005 AND (' . $condition . ')';
$conditions[$label] = $seen === [] ? $condition : $condition . ' AND NOT (' . implode(' OR ', $seen) . ')';
$seen[] = '(' . $condition . ')';
return $rules;
}
/** @return list<string> 差额异常备注的下拉顺序(含兜底档) */
private static function receiptDifferenceRemarkLabels(): array
{
return array_merge(array_keys(self::receiptDifferenceRemarkBaseRules()), ['差额待核实']);
}
/**
* 差额异常备注的优先级序号(1 开始;0=无备注)。
*
* 单次 CASE 命中后即停止,筛选统计与备注筛选共用同一口径。相比把
* 18 条条件逐条带上前序 NOT 再分别 SUM,避免重复计算退款等关联子查询。
*/
private static function receiptDifferenceRemarkIndexSql(): string
{
$effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))';
$sql = 'CASE WHEN ABS(' . $effective . ') <= 0.005 THEN 0';
$index = 1;
foreach (self::receiptDifferenceRemarkBaseRules() as $condition) {
$sql .= ' WHEN (' . $condition . ') THEN ' . $index;
$index++;
}
// 兜底档:有差额但没命中任何原因 →「差额待核实」(与页面渲染的兜底备注保持一致)。
$conditions['差额待核实'] = 'ABS(' . $effective . ') > 0.005 AND NOT (' . implode(' OR ', $seen) . ') AND ' . $notBasisMarked;
return $conditions;
return $sql . ' WHEN ' . self::receiptReasonNotBasisMarkedSql() . ' THEN ' . $index . ' ELSE 0 END';
}
/**
* 该单的聚水潭应付(主单优先 pay_amount)是否大于账单收入(= 含国补/补贴类差额的口径)。
*
* 与 receiptDifferenceRemarkConditions() 里的聚水潭应付款子查询同口径;按单号缓存应付金额,
* 与 receiptDifferenceRemarkBaseRules() 里的聚水潭应付款子查询同口径;按单号缓存应付金额,
* 页面渲染与导出逐行调用时不会对同一单重复查询。
*/
private static function receiptJdSubsidyPayExceedsIncome(string $orderNo, float $income): bool
@ -486,6 +544,13 @@ class Pages extends Controller
return abs((float) ($row->paid_amount ?? 0) - (float) ($row->product_amount ?? 0)) < 0.02;
}
private static function is1688StoreName(string $storeName): bool
{
return str_starts_with($storeName, '新佰广')
|| str_starts_with($storeName, '阿里巴巴')
|| str_starts_with($storeName, '1688');
}
/** 单行的「差额异常备注」(与筛选条件同一套口径,页面与导出共用)。 */
/**
* 页面口径的「有效差额」SQL:口径兜底行(备注为空、但能对上实收/应付)按 0 计。
@ -493,18 +558,36 @@ class Pages extends Controller
*/
private static function receiptEffectiveDifferenceSql(): string
{
// 注意:要把「差额待核实」这条兜底规则排除掉,否则它本身就是"都没命中",
// 会让这里永远不成立(口径兜底行就归不了零)。
$rules = self::receiptDifferenceRemarkConditions();
unset($rules['差额待核实']);
$ruleUnion = '(' . implode(' OR ', array_map(
static fn (string $condition): string => '(' . $condition . ')',
$rules
)) . ')';
// 备注序号 > 0 表示命中了明确原因;只有口径兜底且没有明确原因时才归零。
$indexSql = self::receiptDifferenceRemarkIndexSql();
return "CASE WHEN remark IN (" . self::receiptBasisFallbackSqlList() . ')'
. " AND COALESCE({$indexSql}, 0) = 0 THEN 0"
. ' ELSE ' . self::receiptDefaultDifferenceSql() . ' END';
}
return "CASE WHEN remark IN ('订单实付按卖家实收核对','订单实付按应付/已付核对')"
. " AND NOT COALESCE({$ruleUnion}, 0) THEN 0"
. ' ELSE COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) END';
/** 未命中复杂原因时,差额的默认口径。 */
private static function receiptDefaultDifferenceSql(): string
{
return 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))';
}
/**
* 口径兜底行的差额修正量:只对「实收/应付核对」行计算复杂规则,
* 避免把京东、京喜、得物、淘系退款等关联子查询放进整月汇总的每一行。
*/
private static function receiptFallbackDifferenceAdjustmentSql(): string
{
return 'CASE WHEN COALESCE(' . self::receiptDifferenceRemarkIndexSql() . ', 0) = 0 THEN -('
. self::receiptDefaultDifferenceSql() . ') ELSE 0 END';
}
/** @return string SQL 的字符串字面量列表 */
private static function receiptBasisFallbackSqlList(): string
{
return implode(', ', array_map(
static fn (string $remark): string => "'" . str_replace("'", "''", $remark) . "'",
self::RECEIPT_BASIS_FALLBACK_REMARKS
));
}
private static function receiptDifferenceRemark(object $row): ?string
@ -533,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) {
@ -639,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 ?? ''));
@ -646,6 +745,18 @@ class Pages extends Controller
return null;
}
// 1688 先采后付红包补贴:订单实付包含平台补贴,支付宝到账只含现金部分。
// 先只作为已知原因标注,不改变差额金额或税务收入。
if (($row->platform ?? '') === '淘系支付宝'
&& $difference < -0.005
&& self::is1688StoreName((string) ($row->store_name ?? ''))
&& abs($discount) < 0.005
&& abs($offset) < 0.005
&& abs((float) ($row->refund_amount ?? 0)) < 0.005
&& (float) ($row->paid_amount ?? 0) > $income + 0.005) {
return '订单实付含平台红包补贴';
}
// 京喜分位尾差:具体原因都没命中时,1–3 分钱按四舍五入尾差标注。
if (($row->platform ?? '') === '京喜账单' && abs($difference) <= 0.0301) {
return '差额=分位四舍五入尾差';
@ -1213,13 +1324,15 @@ class Pages extends Controller
// 镜像表本身就是「一个店铺 + 一个订单号 + 一个商品金额」的行口径,直接统计即可。
$base = static fn () => DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('store_receipt_order_match_rows')
->where('report_month', $month);
->where('report_month', $month)
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
// 计数与合计合并为一次全表扫描:重筛选项(如按备注筛选)下少一次全表重算。
$aggQuery = $base();
$applyFilters($aggQuery);
// 口径兜底行(备注为空、口径对上)的差额按 0 计——与页面行展示、差额筛选同一套判定。
$differenceExpr = self::receiptEffectiveDifferenceSql();
$differenceBaseExpr = self::receiptDefaultDifferenceSql();
$fallbackSql = self::receiptBasisFallbackSqlList();
$agg = $aggQuery->selectRaw("
COUNT(*) as total,
SUM(payment_count) as payments,
@ -1230,13 +1343,24 @@ class Pages extends Controller
SUM(planting_amount) as planting_total,
SUM(product_amount) as goods_total,
SUM(paid_amount) as paid_total,
SUM({$differenceExpr}) as difference_total,
SUM({$differenceBaseExpr}) as difference_base,
SUM(CASE WHEN remark IN ({$fallbackSql}) THEN 1 ELSE 0 END) as basis_fallback_rows,
SUM(CASE WHEN payment_count > 1 THEN 1 ELSE 0 END) as multi_pay,
SUM(CASE WHEN income_amount = 0 THEN 1 ELSE 0 END) as zero_income,
SUM(CASE WHEN match_status = '已匹配' THEN 1 ELSE 0 END) as matched,
SUM(CASE WHEN match_status = '未匹配' THEN 1 ELSE 0 END) as unmatched
")->first();
$total = (int) ($agg->total ?? 0);
$differenceTotal = (float) ($agg->difference_base ?? 0);
if ((int) ($agg->basis_fallback_rows ?? 0) > 0) {
$adjustmentQuery = $base();
$applyFilters($adjustmentQuery);
$adjustment = $adjustmentQuery
->whereIn('remark', self::RECEIPT_BASIS_FALLBACK_REMARKS)
->selectRaw('SUM(' . self::receiptFallbackDifferenceAdjustmentSql() . ') as adjustment')
->first();
$differenceTotal += (float) ($adjustment->adjustment ?? 0);
}
$stats = [
'month' => $month,
@ -1255,7 +1379,7 @@ class Pages extends Controller
'planting_total' => round((float) ($agg->planting_total ?? 0), 2),
'goods_total' => round((float) ($agg->goods_total ?? 0), 2),
'paid_total' => round((float) ($agg->paid_total ?? 0), 2),
'difference_total' => round((float) ($agg->difference_total ?? 0), 2),
'difference_total' => round($differenceTotal, 2),
'per_page' => $limit,
];
@ -1266,7 +1390,8 @@ class Pages extends Controller
now()->addHours(6),
static function () use ($connection, $month): array {
$maker = static fn () => DB::connection($connection)->table('store_receipt_order_match_rows')
->where('report_month', $month);
->where('report_month', $month)
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
return [
'platforms' => $maker()->whereNotNull('platform')->distinct()->orderBy('platform')->pluck('platform')->all(),
@ -1277,17 +1402,20 @@ class Pages extends Controller
);
$stats['selected_platforms'] = $platformFilter;
$stats['selected_stores'] = $storeFilter;
$stats['platform_options'] = array_values(array_unique(array_merge(
self::RECEIPT_PLATFORM_OPTIONS,
array_map('strval', $filterOptions['platforms'] ?? []),
)));
$stats['platform_options'] = array_values(array_diff(
array_unique(array_merge(
self::RECEIPT_PLATFORM_OPTIONS,
array_map('strval', $filterOptions['platforms'] ?? []),
)),
self::RECEIPT_MATCH_EXCLUDED_PLATFORMS,
));
$stats['store_options'] = array_values(array_unique(array_merge(
$storeFilter,
array_map('strval', $filterOptions['stores'] ?? []),
)));
$stats['status_options'] = self::RECEIPT_STATUS_OPTIONS;
// 差额异常备注下拉:只保留「差额为什么产生」的原因备注(按差额公式判定)
$stats['remark_options'] = array_values(array_keys(self::receiptDifferenceRemarkConditions()));
$stats['remark_options'] = self::receiptDifferenceRemarkLabels();
// 未匹配原因下拉:未匹配行在库里的原因标注
$stats['unmatched_reason_options'] = self::unmatchedReasonOptions();
@ -1360,20 +1488,21 @@ class Pages extends Controller
return;
}
$differenceConditions = self::receiptDifferenceRemarkConditions();
$differenceLabels = array_values(array_intersect($remarkFilter, array_keys($differenceConditions)));
if ($differenceLabels === []) {
$labels = self::receiptDifferenceRemarkLabels();
$indexes = [];
foreach ($remarkFilter as $label) {
$index = array_search($label, $labels, true);
if ($index !== false) {
$indexes[] = (int) $index + 1;
}
}
if ($indexes === []) {
return;
}
$query->where(function ($group) use ($differenceLabels, $differenceConditions): void {
foreach ($differenceLabels as $index => $label) {
$method = $index === 0 ? 'where' : 'orWhere';
$group->{$method}(function ($sub) use ($label, $differenceConditions): void {
$sub->whereRaw($differenceConditions[$label]);
});
}
});
$query->whereRaw(
self::receiptDifferenceRemarkIndexSql() . ' IN (' . implode(', ', array_unique($indexes)) . ')'
);
}
/**
@ -1432,7 +1561,8 @@ class Pages extends Controller
{
$query = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('store_receipt_order_match_rows')
->where('report_month', $month);
->where('report_month', $month)
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
foreach ($filters as $key => $values) {
if ($key === 'difference_state' || $values === [] || $values === '') {
@ -1498,6 +1628,7 @@ class Pages extends Controller
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$months = DB::connection($connection)->table('store_receipt_order_match_rows')
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
->select('report_month')
->distinct()
->orderByDesc('report_month')
@ -1561,22 +1692,196 @@ 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' => [
'platform' => $this->receiptOrderMatchFacet($month, 'platform', $filters, $orderNo, self::RECEIPT_PLATFORM_OPTIONS),
'store_name' => $this->receiptOrderMatchFacet($month, 'store_name', $filters, $orderNo),
'match_status' => $this->receiptOrderMatchFacet($month, 'match_status', $filters, $orderNo, self::RECEIPT_STATUS_OPTIONS),
'remark' => $this->receiptOrderMatchFacet($month, 'remark', $filters, $orderNo, array_keys(self::receiptDifferenceRemarkConditions())),
'unmatched_reason' => $this->receiptOrderMatchFacet($month, 'unmatched_reason', $filters, $orderNo, self::unmatchedReasonOptions()),
'difference_state' => $this->receiptDifferenceStateFacet($month, $filters, $orderNo),
],
'facets' => $this->receiptOrderMatchFacets($month, $filters, $orderNo),
];
}));
}
/**
* 一次请求生成全部筛选联动计数。
*
* 多个筛选项如果共用同一组“其它条件”,只查一次再一起聚合。页面当前先按
* 差额备注筛选时,原先 6 次重复全表条件判断会合并成 2 次查询。
*
* @param array<string,list<string>> $filters
* @return array<string,array{values:list<string>,counts:array<string,int>}>
*/
private function receiptOrderMatchFacets(string $month, array $filters, string $orderNo): array
{
$facetOrder = [
'platform' => self::RECEIPT_PLATFORM_OPTIONS,
'store_name' => [],
'match_status' => self::RECEIPT_STATUS_OPTIONS,
'remark' => self::receiptDifferenceRemarkLabels(),
'unmatched_reason' => self::unmatchedReasonOptions(),
'difference_state' => ['有差额', '无差额'],
];
// “统计某个筛选本身”时排除该筛选;条件完全相同的筛选项合并成一个查询。
$groups = [];
foreach (array_keys($facetOrder) as $column) {
$signature = $filters;
unset($signature[$column]);
$signature = array_filter(
$signature,
static fn ($values): bool => $values !== [] && $values !== ''
);
ksort($signature);
$key = md5(serialize([$orderNo, $signature]));
$groups[$key]['filters'] = $signature;
$groups[$key]['columns'][] = $column;
}
$facets = [];
foreach ($groups as $group) {
if ($group['filters'] === []) {
foreach ($group['columns'] as $column) {
$facets[$column] = $column === 'difference_state'
? $this->receiptDifferenceStateFacet($month, $filters, $orderNo)
: $this->receiptOrderMatchFacet($month, $column, $filters, $orderNo, $facetOrder[$column]);
}
continue;
}
$counts = $this->receiptOrderMatchFilteredFacetCounts(
$month,
$group['filters'],
$orderNo,
$group['columns']
);
foreach ($group['columns'] as $column) {
$columnCounts = $counts[$column] ?? [];
$ordered = array_values(array_intersect($facetOrder[$column], array_keys($columnCounts)));
$rest = array_values(array_diff(array_keys($columnCounts), $ordered));
sort($rest, SORT_NATURAL | SORT_FLAG_CASE);
$facets[$column] = [
'values' => array_merge($ordered, $rest),
'counts' => $columnCounts,
];
}
}
return array_replace(array_fill_keys(array_keys($facetOrder), ['values' => [], 'counts' => []]), $facets);
}
/**
* 共用同一组筛选条件时,一次分组查询算出多个筛选项的计数。
*
* @param array<string,list<string>> $filters
* @param list<string> $columns
* @return array<string,array<string,int>>
*/
private function receiptOrderMatchFilteredFacetCounts(string $month, array $filters, string $orderNo, array $columns): array
{
$labels = self::receiptDifferenceRemarkLabels();
$basisMarks = ['订单实付按卖家实收核对', '订单实付按应付/已付核对'];
$remarkIndexes = [];
foreach ((array) ($filters['remark'] ?? []) as $label) {
$index = array_search($label, $labels, true);
if ($index !== false) {
$remarkIndexes[] = (int) $index + 1;
}
}
$differenceValues = array_map('strval', (array) ($filters['difference_state'] ?? []));
$wantDifference = in_array('有差额', $differenceValues, true);
$wantNoDifference = in_array('无差额', $differenceValues, true);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$inner = DB::connection($connection)
->table('store_receipt_order_match_rows')
->where('report_month', $month)
->select(['platform', 'store_name', 'match_status', 'remark'])
->selectRaw(self::receiptDefaultDifferenceSql() . ' AS base_difference')
->selectRaw(self::receiptDifferenceRemarkIndexSql() . ' AS reason_index');
foreach ($filters as $key => $values) {
if ($key === 'remark' || $key === 'difference_state' || $values === []) {
continue;
}
if ($key === 'unmatched_reason') {
$this->applyReceiptUnmatchedReasonFilter($inner, (array) $values);
continue;
}
$inner->whereIn($key, $values);
}
if (trim($orderNo) !== '') {
$inner->where('online_order_no', 'like', '%' . addcslashes(trim($orderNo), '%_\\') . '%');
}
$outer = DB::connection($connection)
->query()
->fromSub($inner, 'facet_rows');
if ($remarkIndexes !== []) {
$outer->whereIn('reason_index', array_values(array_unique($remarkIndexes)));
}
if ($wantDifference !== $wantNoDifference) {
$effective = 'CASE WHEN remark IN ('
. implode(', ', array_map(static fn (string $mark): string => "'" . $mark . "'", $basisMarks))
. ') AND reason_index = 0 THEN 0 ELSE base_difference END';
$outer->whereRaw('ABS(' . $effective . ')' . ($wantDifference ? ' > 0.005' : ' <= 0.005'));
}
$rows = $outer
->select(['platform', 'store_name', 'match_status', 'remark', 'reason_index', 'base_difference'])
->selectRaw('COUNT(*) AS total')
->groupBy(['platform', 'store_name', 'match_status', 'remark', 'reason_index', 'base_difference'])
->get();
$counts = [
'platform' => [],
'store_name' => [],
'match_status' => [],
'remark' => [],
'unmatched_reason' => [],
'difference_state' => [],
];
$wanted = array_fill_keys($columns, true);
foreach ($rows as $row) {
$total = (int) ($row->total ?? 0);
$platform = (string) ($row->platform ?? '');
$store = (string) ($row->store_name ?? '');
$status = (string) ($row->match_status ?? '');
$rawRemark = trim((string) ($row->remark ?? ''));
$reasonIndex = (int) ($row->reason_index ?? 0);
$reasonLabel = $reasonIndex > 0 ? ($labels[$reasonIndex - 1] ?? null) : null;
if (isset($wanted['platform']) && $platform !== '') {
$counts['platform'][$platform] = ($counts['platform'][$platform] ?? 0) + $total;
}
if (isset($wanted['store_name']) && $store !== '') {
$counts['store_name'][$store] = ($counts['store_name'][$store] ?? 0) + $total;
}
if (isset($wanted['match_status']) && $status !== '') {
$counts['match_status'][$status] = ($counts['match_status'][$status] ?? 0) + $total;
}
if (isset($wanted['remark']) && $reasonLabel !== null) {
$counts['remark'][$reasonLabel] = ($counts['remark'][$reasonLabel] ?? 0) + $total;
}
if (isset($wanted['unmatched_reason'])) {
$unmatchedReason = ($status === '未匹配' && $rawRemark !== '')
? $rawRemark
: (in_array($rawRemark, $basisMarks, true) ? $rawRemark : null);
if ($unmatchedReason !== null) {
$counts['unmatched_reason'][$unmatchedReason] = ($counts['unmatched_reason'][$unmatchedReason] ?? 0) + $total;
}
}
if (isset($wanted['difference_state'])) {
$difference = (float) ($row->base_difference ?? 0);
if ($reasonIndex === 0 && in_array($rawRemark, $basisMarks, true)) {
$difference = 0.0;
}
$differenceLabel = abs($difference) > 0.005 ? '有差额' : '无差额';
$counts['difference_state'][$differenceLabel] = ($counts['difference_state'][$differenceLabel] ?? 0) + $total;
}
}
return $counts;
}
/**
* 单个筛选项的可选值与行数:应用除该筛选项以外的所有条件。
*
@ -1615,23 +1920,24 @@ class Pages extends Controller
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
}
// 差额异常备注联动:5 条原因一次查询算完(原来是 5 次全表 count,下拉要等 1 秒多)
$differenceCounts = [];
$differenceLabels = array_keys(self::receiptDifferenceRemarkConditions());
if ($column === 'remark') {
$expressions = [];
foreach (array_values(self::receiptDifferenceRemarkConditions()) as $index => $condition) {
$expressions[] = 'SUM(CASE WHEN (' . $condition . ') THEN 1 ELSE 0 END) as c' . $index;
}
$row = (clone $query)->selectRaw(implode(', ', $expressions))->first();
foreach ($differenceLabels as $index => $label) {
$differenceCounts[$label] = (int) ($row->{'c' . $index} ?? 0);
}
}
$counts = [];
// 未匹配原因是页面级判定(只统计未匹配行,按库里的 remark 分组)
if ($column === 'unmatched_reason') {
if ($column === 'remark') {
// 一行只按优先级命中一次,再按序号分组,避免 18 条规则各跑一遍全表。
$indexSql = self::receiptDifferenceRemarkIndexSql();
$rows = (clone $query)
->selectRaw($indexSql . ' AS reason_index')
->selectRaw('COUNT(*) AS total')
->groupByRaw('reason_index')
->pluck('total', 'reason_index');
$labels = self::receiptDifferenceRemarkLabels();
foreach ($rows as $index => $total) {
$label = $labels[(int) $index - 1] ?? null;
if ($label !== null && (int) $total > 0) {
$counts[$label] = (int) $total;
}
}
} elseif ($column === 'unmatched_reason') {
// 未匹配原因是页面级判定(只统计未匹配行,按库里的 remark 分组)
$base = clone $query;
$rows = $query->where('match_status', '未匹配')
->select('remark')
@ -1650,8 +1956,7 @@ class Pages extends Controller
$counts[$basisMark] = (int) $marker;
}
}
} elseif ($column !== 'remark') {
// 差额异常备注是页面级判定,不再按库里的标注列分组
} else {
$rows = $query->select($column)
->selectRaw('count(*) as total')
->whereNotNull($column)
@ -1662,11 +1967,6 @@ class Pages extends Controller
$counts[(string) $value] = (int) $total;
}
}
foreach ($differenceCounts as $label => $total) {
if ((int) $total > 0) {
$counts[$label] = (int) $total;
}
}
$ordered = array_values(array_intersect($order, array_keys($counts)));
$rest = array_values(array_diff(array_keys($counts), $ordered));
@ -1748,6 +2048,8 @@ class Pages extends Controller
'offset_amount' => ['offset_amount', '冲减金额', 'money'],
'refund_amount' => ['refund_amount', '退款金额', 'money'],
'planting_amount' => ['planting_amount', '种菜金额', 'money'],
'planting_reimbursement_amount' => ['planting_reimbursement_amount', '种菜报销', 'money'],
'planting_unreimbursed_amount' => ['planting_unreimbursed_amount', '种菜未报销', 'money'],
'product_amount' => ['product_amount', '商品金额', 'money'],
'paid_amount' => ['paid_amount', '订单实付金额', 'money'],
'difference' => ['difference', '差额', 'money'],
@ -1758,6 +2060,7 @@ class Pages extends Controller
[, $month] = $this->receiptOrderMatchMonths();
$query = DB::connection($connection)->table('store_receipt_order_match_rows')
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
->when($month !== null, static fn ($builder) => $builder->where('report_month', $month));
$platforms = $this->receiptOrderMatchFilterValues($request->query('platform'));
@ -1906,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, [
@ -1927,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']);
@ -1940,41 +2243,22 @@ 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('
SUM(CASE WHEN unit_price IS NOT NULL THEN product_quantity ELSE 0 END) as quantity_total,
SUM(CASE WHEN unit_price IS NOT NULL THEN product_cost ELSE 0 END) as cost_total,
SUM(CASE WHEN unit_price IS NULL THEN 1 ELSE 0 END) as missing_price_rows,
SUM(CASE WHEN unit_price IS NULL THEN product_quantity ELSE 0 END) as missing_price_quantity,
SUM(CASE WHEN unit_price IS NULL AND (remark IS NULL OR remark NOT LIKE \'%口径不一致%\') THEN 1 ELSE 0 END) as missing_price_rows,
SUM(CASE WHEN unit_price IS NULL AND (remark IS NULL OR remark NOT LIKE \'%口径不一致%\') THEN product_quantity ELSE 0 END) as missing_price_quantity,
SUM(CASE WHEN warehouse_short_name IS NULL THEN 1 ELSE 0 END) as missing_warehouse_rows,
SUM(CASE WHEN price_source LIKE \'线上代发价格%\' THEN 1 ELSE 0 END) as fallback_price_rows,
SUM(CASE WHEN price_source = \'普通商品资料成本价\' THEN 1 ELSE 0 END) as catalog_price_rows,
SUM(CASE WHEN remark LIKE \'%口径不一致%\' THEN 1 ELSE 0 END) as unit_mismatch_rows
')->first();
@ -2018,8 +2302,8 @@ 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),
],
'productCostMonths' => $months,
@ -2157,6 +2441,7 @@ class Pages extends Controller
'financial-analysis-months-v1',
now()->addMinutes(10),
static fn () => DB::connection($connection)->table('store_receipt_order_match_rows')
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
->distinct()->orderByDesc('report_month')->pluck('report_month')
->map(static fn ($value): string => substr((string) $value, 0, 7))->all(),
);
@ -2183,6 +2468,7 @@ class Pages extends Controller
$rows = [];
$incomeRows = DB::connection($connection)->table('store_receipt_order_match_rows')
->where('report_month', $reportMonth)
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
->selectRaw('platform, store_name, SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)) as income')
->groupBy('platform', 'store_name')
@ -2297,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';
@ -2691,7 +2969,8 @@ class Pages extends Controller
}));
}
$perPage = 50;
// 货款/退货明细较宽,固定每页 15 行,减少纵向滚动并保持横向核对区稳定。
$perPage = in_array($dataset, ['payment-reconciliation', 'return-reconciliation'], true) ? 15 : 50;
$currentPage = max((int) request('page', 1), 1);
return [
@ -4791,7 +5070,13 @@ class Pages extends Controller
$amountByRow[(int) ($pair['row_number'] ?? 0)] = $pair['amount'] ?? null;
}
$results = app(CourierSurchargeSettlementMirrorRepository::class)->decideImportRows($pairs);
$verificationKey = 'courier-surcharge:verify:' . company_id() . ':' . auth()->id() . ':'
. hash('sha256', json_encode($pairs, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$results = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock($verificationKey, 300)
->get(fn () => app(CourierSurchargeSettlementMirrorRepository::class)->decideImportRows($pairs));
if ($results === false) {
return back()->with('error', '同一份数据正在核验,请等待本次核验完成后再提交。');
}
return response()->streamDownload(function () use ($results, $amountByRow): void {
$writer = new \App\Support\StreamingXlsxWriter('导入核验');
@ -4858,9 +5143,9 @@ class Pages extends Controller
return response()->streamDownload(function (): void {
$writer = new \App\Support\StreamingXlsxWriter('导入模板');
$writer->appendRow(['单号', '附加费类型', '快递名称', '金额(可选)']);
$writer->appendRow(['DPK317138931411', '退回费', '曹县德邦小件', 20]);
$writer->appendRow(['DPK379220912936', '超长费', '常州德邦', null]);
$writer->appendRow(['单号', '附加费类型', '金额(可选)']);
$writer->appendRow(['DPK317138931411', '退回费', 20]);
$writer->appendRow(['DPK379220912936', '超长费', null]);
$output = fopen('php://output', 'wb');

View File

@ -32,6 +32,15 @@ class PlantingWorkOrders extends Controller
default => '-扣款',
};
// 发运营的拍A发B 名单直接按「种菜报销」的字段导出。
if (request()->query('format') === 'reimbursement') {
return $this->csv(
'拍A发B发运营-' . $filters['month'],
PlantingReimbursementPageData::COLUMNS,
static fn () => $data->rows($filters),
);
}
return $this->csv(
PlantingLossPageData::TITLE . '-' . $filters['month'] . $suffix,
$data->exportColumns(),

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

@ -834,6 +834,8 @@ $navigation = [
['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'],
['name' => 'refund_amount', 'label' => '退款金额', 'kind' => 'number'],
['name' => 'planting_amount', 'label' => '种菜金额', 'kind' => 'number'],
['name' => 'planting_reimbursement_amount', 'label' => '种菜报销', 'kind' => 'number'],
['name' => 'planting_unreimbursed_amount', 'label' => '种菜未报销', 'kind' => 'number'],
['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'],
['name' => 'paid_amount', 'label' => '订单实付/实收金额', 'kind' => 'number'],
['name' => 'difference', 'label' => '差额', 'kind' => 'number'],

View File

@ -193,6 +193,13 @@ return [
*/
'table_sync' => [
'batch_size' => (int) env('BUSINESS_MIRROR_TABLE_BATCH_SIZE', 500),
// These wide SQL Server views time out when TOP/pagination is planned
// together with every payload column. Select the bounded keys first,
// then look up only those rows in the same read-only statement.
'key_first_datasets' => [
'operations_replica.rpt.jst_Orders',
'operations_replica.rpt.jst_originalBillInfo',
],
'max_batches_per_run' => (int) env('BUSINESS_MIRROR_TABLE_MAX_BATCHES', 2),
'max_datasets_per_run' => (int) env('BUSINESS_MIRROR_MAX_DATASETS', 1),
'overlap_hours' => (int) env('BUSINESS_MIRROR_OVERLAP_HOURS', 48),

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,38 @@
<?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 (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if (! $schema->hasTable('work_order_waybill_lookup')) {
$schema->create('work_order_waybill_lookup', function (Blueprint $table): void {
$table->char('mirror_key', 64)->primary();
$table->string('waybill', 191);
$table->unsignedBigInteger('source_id');
$table->index(['waybill', 'source_id'], 'work_order_waybill_source_idx');
});
}
if (! $schema->hasTable('work_order_waybill_lookup_states')) {
$schema->create('work_order_waybill_lookup_states', function (Blueprint $table): void {
$table->unsignedTinyInteger('id')->primary();
$table->char('cursor', 64)->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('updated_at')->nullable();
});
}
}
public function down(): void
{
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$schema->dropIfExists('work_order_waybill_lookup_states');
$schema->dropIfExists('work_order_waybill_lookup');
}
};

View File

@ -0,0 +1,50 @@
<?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 (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if (! $schema->hasTable('store_report_paid_sales_dirty_dates')) {
$schema->create('store_report_paid_sales_dirty_dates', function (Blueprint $table): void {
$table->date('occurred_on')->primary();
$table->uuid('revision');
});
}
if ($schema->hasTable('store_receipt_order_match_rows')) {
$indexed = collect($schema->getIndexes('store_receipt_order_match_rows'))
->contains(fn (array $index) => ($index['columns'][0] ?? null) === 'occurred_date');
if (! $indexed) {
$connection = $schema->getConnection();
if ($connection->getDriverName() === 'mysql') {
// Fail quickly on metadata contention and require an online index build.
$wait = (int) $connection->selectOne('SELECT @@SESSION.lock_wait_timeout AS seconds')->seconds;
$connection->statement('SET SESSION lock_wait_timeout=5');
try {
$table = $connection->getQueryGrammar()->wrapTable('store_receipt_order_match_rows');
$connection->statement('ALTER TABLE ' . $table . ' ADD INDEX store_receipt_paid_sales_date_index (occurred_date), ALGORITHM=INPLACE, LOCK=NONE');
} finally {
$connection->statement('SET SESSION lock_wait_timeout=' . $wait);
}
} else {
$schema->table('store_receipt_order_match_rows', fn (Blueprint $table) => $table->index('occurred_date', 'store_receipt_paid_sales_date_index'));
}
}
}
}
public function down(): void
{
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$schema->dropIfExists('store_report_paid_sales_dirty_dates');
if ($schema->hasTable('store_receipt_order_match_rows') && collect($schema->getIndexes('store_receipt_order_match_rows'))->contains('name', 'store_receipt_paid_sales_date_index')) {
$schema->table('store_receipt_order_match_rows', fn (Blueprint $table) => $table->dropIndex('store_receipt_paid_sales_date_index'));
}
}
};

View File

@ -0,0 +1,40 @@
<?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 (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if (! $schema->hasTable('jd_daily_bill_rows')) return;
if (collect($schema->getIndexes('jd_daily_bill_rows'))->contains('name', 'jd_daily_summary_cover')) return;
$columns = ['generation', 'occurred_at', 'income', 'expense', 'store_name', 'bill_item'];
$connection = $schema->getConnection();
if ($connection->getDriverName() === 'mysql') {
// Platform scope reads store_name: without it SUM must fetch every wide JSON row.
$wait = (int) $connection->selectOne('SELECT @@SESSION.lock_wait_timeout AS seconds')->seconds;
$connection->statement('SET SESSION lock_wait_timeout=5');
try {
$table = $connection->getQueryGrammar()->wrapTable('jd_daily_bill_rows');
$connection->statement('ALTER TABLE ' . $table . ' ADD INDEX jd_daily_summary_cover (generation, occurred_at, income, expense, store_name, bill_item), ALGORITHM=INPLACE, LOCK=NONE');
} finally {
$connection->statement('SET SESSION lock_wait_timeout=' . $wait);
}
} else {
$schema->table('jd_daily_bill_rows', fn (Blueprint $table) => $table->index($columns, 'jd_daily_summary_cover'));
}
}
public function down(): void
{
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if ($schema->hasTable('jd_daily_bill_rows') && collect($schema->getIndexes('jd_daily_bill_rows'))->contains('name', 'jd_daily_summary_cover')) {
$schema->table('jd_daily_bill_rows', fn (Blueprint $table) => $table->dropIndex('jd_daily_summary_cover'));
}
}
};

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 业绩明细新增「费用项」列:
* 按科目映射表的 fee_memo(费用备注)归集到订单,一单多费用时用顿号连接。
* 只用于展示,不参与收入、优惠、冲减、退款和差额计算。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')) {
return;
}
if (! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->string('fee_item', 191)->nullable()->after('planting_amount');
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('fee_item');
});
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 京东网银钱包账户不再进入业绩明细:清理历史镜像行,收、支均不展示。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (! Schema::connection($this->connection())->hasTable('store_receipt_order_match_rows')) {
return;
}
DB::connection($this->connection())
->table('store_receipt_order_match_rows')
->where('platform', '京东网银钱包')
->delete();
}
public function down(): void
{
// 历史镜像可由同步命令重建;本迁移只做排除清理,不回填已删除的数据。
}
};

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 撤销「费用项」功能:删除业绩明细镜像中的 fee_item 列。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('fee_item');
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->string('fee_item', 191)->nullable()->after('planting_amount');
});
}
};

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 业绩明细新增「种菜报销」列(放在种菜金额后面):
* 仅按「种菜明细表」的订单号命中,金额取该订单的收入金额。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')) {
return;
}
if (! $schema->hasColumn('store_receipt_order_match_rows', 'planting_reimbursement_amount')) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->decimal('planting_reimbursement_amount', 20, 4)
->nullable()
->after('planting_amount');
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'planting_reimbursement_amount')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('planting_reimbursement_amount');
});
}
};

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 业绩明细新增「种菜未报销」列(放在种菜报销后面):
* 按聚水潭订单标签明细「是否种菜订单=是」且未出现在种菜明细表的订单号命中,金额取收入金额。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')) {
return;
}
if (! $schema->hasColumn('store_receipt_order_match_rows', 'planting_unreimbursed_amount')) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->decimal('planting_unreimbursed_amount', 20, 4)
->nullable()
->after('planting_reimbursement_amount');
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'planting_unreimbursed_amount')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('planting_unreimbursed_amount');
});
}
};

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
@ -209,6 +225,7 @@ CRON_TZ=Asia/Shanghai
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --quiet >/dev/null 2>&1
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1
28 1 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh jushuitan:sync-shipping --days=3 >/dev/null 2>&1
# 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用
# 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。

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
@ -213,6 +229,7 @@ CRON_TZ=Asia/Shanghai
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --quiet >/dev/null 2>&1
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1
28 1 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh jushuitan:sync-shipping --days=3 >/dev/null 2>&1
# 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用
# 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。

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

@ -4,14 +4,35 @@ set -eu
REMOTE_DIR="${CAIWU_REMOTE_DIR:-/opt/caiwuxitong}"
LOCK_FILE="${CAIWU_UPSTREAM_LOCK:-/run/caiwu-upstream-sync.lock}"
case "${1:-}" in
jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates) ;;
jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run) ;;
*) echo 'Unsupported finance batch command' >&2; exit 64 ;;
esac
cd "$REMOTE_DIR"
exec 9>"$LOCK_FILE"
if ! flock -w "${CAIWU_BATCH_LOCK_WAIT:-1800}" 9; then
echo 'Upstream synchronization is busy; no batch container started' >&2
exit 75
if [ "$1" = 'account-bills:exports:run' ]; then
storage_root="$(docker inspect caiwu_app --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/storage"}}{{.Source}}{{end}}{{end}}')"
[ -n "$storage_root" ] && [ -d "$storage_root" ] || exit 65
# Check only tiny task metadata; do not start an idle container every minute.
if ! python3 - "$storage_root" <<'PY'
import json,pathlib,sys
for path in pathlib.Path(sys.argv[1]).glob('app/account-bills-exports/*/*.json'):
try:
task = json.loads(path.read_text())
if isinstance(task, dict) and task.get('status') == 'queued':
sys.exit(0)
except (OSError,ValueError):
continue
sys.exit(1)
PY
then
exit 0
fi
fi
if [ "$1" != 'account-bills:exports:run' ]; then
exec 9>"$LOCK_FILE"
if ! flock -w "${CAIWU_BATCH_LOCK_WAIT:-1800}" 9; then
echo 'Upstream synchronization is busy; no batch container started' >&2
exit 75
fi
fi
image="$(docker inspect caiwu_app --format '{{.Config.Image}}')"
case "$image" in
@ -20,5 +41,5 @@ case "$image" in
esac
RUN_MIGRATIONS=0
export IMAGE_TAG RUN_MIGRATIONS
# Holds the same lock as scheduled sync and release activation for the full job.
# Mutating jobs hold the upstream lock; read-only exports use their own single-worker lock.
docker compose -f compose.jiangling.yml --profile batch run --rm -T --no-deps batch "$@"

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` 启动即报“拒绝访问”。
@ -278,6 +289,14 @@ sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:order-match:sync YYYY-MM
入口持有现有上游同步锁,使用 `batch` Profile 的独立容器(1 核、3GiB 内存、不使用 Swap、无 Web 健康检查);正常发布不启动该 Profile。先备份再执行回补,失败时保留原有未完成状态,不重复并行启动任务。店铺候选平时按镜像同步时间增量刷新,02:45 使用 `--full` 全量核对。ClickHouse 系统日志的保留期由 `deploy/clickhouse-resource-policy.xml` 管理;业务表不受日志策略影响。
查件运单索引 `work_order_waybill_lookup` 只保存原始工单镜像的稳定键、运单号与排序键;工单 JSON 仍为事实来源。升级后先通过上述批入口执行 `business-mirror:index-work-order-waybills --max-batches=0 --pause-ms=50` 完成一次可续传初始化,完成前保留现有核验路径。其后索引与原始工单批次在同一事务中更新,全量校准同时移除失效关联。不得每次导入重新扫描工单 JSON。
销量汇总按 `store_report_paid_sales_dirty_dates` 中的受影响日期更新。业绩明细写入与汇总共用互斥锁,改金额、替换账期和删除都必须登记旧日期及新日期。镜像人工校准后,可通过批入口执行 `business-mirror:refresh-paid-sales --full` 全量重建派生汇总;普通刷新无需全表指纹扫描。科目重算每轮默认处理一个平台月份,再释放同步锁,剩余任务由下一轮继续。
账单导出保存在原有文件任务队列,每分钟由 `account-bills:exports:run` 消费一个任务,任一时刻只有一个重型导出。该只读任务使用同一受限批 Profile 和独立导出锁,不长期持有上游写同步锁;空队列不启动容器。状态、取消、下载和七天文件保留沿用现有逻辑。
生产临时排查先使用元数据和 `EXPLAIN`,业务读取须设置合理的 SQL 执行时限,并使用已有索引缩小范围。不要在网页容器内对归档大表进行无时限计数、模糊查找或循环重试;需要全表核对时先安排受控批处理窗口。
### 8.3 开发者需要尚未接入的上游数据时
系统数据库保存团队配置、数据字典和同步状态,共享业务库保存已接入业务行;二者都不是 SQL Server 网络代理。开发探索与正式页面的读取方式不同:

View File

@ -38,6 +38,9 @@ class CourierSurchargeSettlementMirrorRepository
/** 导入核验时最多回查多少张扣单工单,避免大表扫描过重。 */
private const IMPORT_RETURN_CONTEXT_LIMIT = 100;
/** 导入核验时最多按单号回查多少张销售出库单(超长费)。 */
private const IMPORT_OVERLENGTH_CONTEXT_LIMIT = 200;
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
@ -276,9 +279,26 @@ class CourierSurchargeSettlementMirrorRepository
}
}
$contextComplete = count($unmatchedDeductWaybills) <= self::IMPORT_RETURN_CONTEXT_LIMIT;
$contextComplete = count($unmatchedDeductWaybills) <= self::IMPORT_RETURN_CONTEXT_LIMIT
|| app(\Modules\EcommerceFinance\Services\WorkOrderWaybillLookup::class)->ready();
$returnContext = $this->deductOrderContext(array_keys($unmatchedDeductWaybills));
$ruleModes = $this->activeRuleModes();
$quoteRules = $this->importQuoteRules();
// 超长费没有结算行时,按单号回销售出库单查快递/尺寸并按启用的报价核算。
$unmatchedOverlengthWaybills = [];
foreach ($normalized as $pair) {
if ($pair['waybill'] === '' || $pair['surcharge_type'] !== '超长费') {
continue;
}
if ($this->matchImportRow($rowsByWaybill[$pair['waybill']] ?? [], $pair['surcharge_type'], $pair['courier_name']) === null) {
$unmatchedOverlengthWaybills[$pair['waybill']] = true;
}
}
$overlengthContext = $this->importOverlengthContext(array_keys($unmatchedOverlengthWaybills));
$fallbackRows = [];
@ -323,7 +343,16 @@ class CourierSurchargeSettlementMirrorRepository
$results[] = $matched !== null
? $this->importMatchedRow($pair, $matched)
: $this->importMissingRow(
: ($this->importQuoteResult(
$pair,
$fallbackRows[$pair['waybill']] ?? null,
$returnContext,
$ruleModes,
$quoteRules,
$overlengthContext,
$contextWeights,
$contextReturns,
) ?? $this->importMissingRow(
$pair,
$returnContext,
$ruleModes,
@ -332,7 +361,7 @@ class CourierSurchargeSettlementMirrorRepository
$fallbackRows[$pair['waybill']] ?? null,
$contextWeights,
$contextReturns,
);
));
}
return $results;
@ -422,7 +451,7 @@ class CourierSurchargeSettlementMirrorRepository
* @param array<string,array<string,mixed>> $returnContext
* @param array<string,array<string,list<string>>> $ruleModes
* @param array<string,float> $contextWeights
* @param array<int,string> $contextReturns
* @param array<int,list<array<string,mixed>>> $contextReturns
* @return array<string,mixed>
*/
private function importMissingRow(
@ -474,7 +503,9 @@ class CourierSurchargeSettlementMirrorRepository
$onlineOrderNo = trim((string) ($context['online_order_no'] ?? ''));
$inspectionType = trim((string) ($context['inspection_type'] ?? '')) ?: null;
$inspectionReason = trim((string) ($context['inspection_reason'] ?? ''));
$returnWaybillNo = $contextReturns[(int) ($context['o_id'] ?? 0)] ?? null;
$orderReturns = $contextReturns[(int) ($context['o_id'] ?? 0)] ?? [];
$returnWaybills = array_values(array_unique(array_filter(array_column($orderReturns, 'l_id'))));
$returnWaybillNo = $returnWaybills === [] ? null : implode(',', $returnWaybills);
$settlementWeight = $this->importWeightSum($context['items'] ?? [], $contextWeights);
$sendTime = trim((string) ($context['send_time'] ?? ''));
$shippedAt = $sendTime !== '' ? $sendTime : null;
@ -517,6 +548,591 @@ class CourierSurchargeSettlementMirrorRepository
];
}
/**
* 导入核验的「按报价核算」:结算表里没有该运单+类型的结算行时,
* 用单号回销售出库单(超长费)或查件扣单(退回费/拦截费)取上下文,
* 再按启用的报价规则计算应结金额。返回 null 表示该单号取不到上下文,
* 交给 importMissingRow 输出兜底的不结原因。
*
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
* @param array<string,array<string,mixed>> $returnContext
* @param array<string,array<string,list<string>>> $ruleModes
* @param array{overlength:list<array<string,mixed>>,return:list<array<string,mixed>>,intercept:list<array<string,mixed>>} $quoteRules
* @param array<string,array<string,mixed>> $overlengthContext
* @param array<string,float> $contextWeights
* @param array<int,list<array<string,mixed>>> $contextReturns
* @return array<string,mixed>|null
*/
private function importQuoteResult(
array $pair,
?object $fallbackRow,
array $returnContext,
array $ruleModes,
array $quoteRules,
array $overlengthContext,
array $contextWeights,
array $contextReturns,
): ?array {
$type = $pair['surcharge_type'];
if ($type === '超长费') {
$context = $overlengthContext[$pair['waybill']] ?? null;
return $context === null
? null
: $this->importOverlengthQuoteResult($pair, $context, $quoteRules['overlength'] ?? []);
}
if (! in_array($type, ['退回费', '拦截费'], true)) {
return null;
}
$context = $returnContext[$pair['waybill']] ?? null;
return $context === null
? null
: $this->importDeductQuoteResult($pair, $context, $fallbackRow, $ruleModes, $quoteRules, $contextWeights, $contextReturns);
}
/**
* 按单号回销售出库单(JianSun)取快递、明细、尺寸与平台商品ID。
*
* @param list<string> $waybills
* @return array<string,array<string,mixed>>
*/
private function importOverlengthContext(array $waybills): array
{
if ($waybills === [] || count($waybills) > self::IMPORT_OVERLENGTH_CONTEXT_LIMIT) {
return [];
}
try {
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
} catch (Throwable) {
return [];
}
$orders = [];
$orderByWaybill = [];
foreach (array_chunk($waybills, 100) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT Id, io_id, o_id, l_id, so_id, shop_name, '
. "CONVERT(varchar(19), io_date, 120) AS io_date, logistics_company "
. 'FROM dbo.jst_salesOutboundInfo WHERE l_id IN (' . $placeholders . ') ORDER BY Id DESC',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$waybill = trim((string) ($row['l_id'] ?? ''));
if ($waybill === '' || isset($orderByWaybill[$waybill])) {
continue;
}
$orderByWaybill[$waybill] = $row;
$orders[] = $row;
}
}
if ($orders === []) {
return [];
}
$ioIds = array_values(array_unique(array_filter(array_map(
static fn (array $order): int => (int) ($order['io_id'] ?? 0),
$orders,
))));
$items = [];
$skuIds = [];
foreach (array_chunk($ioIds, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT io_id, sku_id, i_id, qty FROM dbo.jst_salesOutboundItem WHERE io_id IN (' . $placeholders . ')',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$ioId = (int) ($row['io_id'] ?? 0);
$skuId = trim((string) ($row['sku_id'] ?? ''));
if ($ioId <= 0) {
continue;
}
$items[$ioId][] = $row;
if ($skuId !== '') {
$skuIds[$skuId] = true;
}
}
}
$dimensions = $this->importDimensions(array_keys($skuIds));
$platform = [];
foreach (array_chunk($ioIds, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT io_id, sku_id, shop_i_id FROM dbo.Jst_SendDataDetail_View WHERE io_id IN (' . $placeholders . ')'
. ' AND LEN(shop_i_id) > 0',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$ioId = (int) ($row['io_id'] ?? 0);
$shopIId = trim((string) ($row['shop_i_id'] ?? ''));
$skuId = trim((string) ($row['sku_id'] ?? ''));
if ($ioId <= 0 || $shopIId === '') {
continue;
}
if ($skuId !== '') {
$platform[$ioId . '|' . $skuId] = $shopIId;
}
$platform[$ioId . '|'] ??= $shopIId;
}
}
$contexts = [];
foreach ($orderByWaybill as $waybill => $order) {
$ioId = (int) ($order['io_id'] ?? 0);
$contexts[$waybill] = [
'order' => $order,
'items' => $items[$ioId] ?? [],
'dimensions' => $dimensions,
'platform' => $platform,
];
}
return $contexts;
}
/**
* @param array<string,mixed> $context
* @param list<array{id:int,courier:string,threshold:float,amount:float}> $rules
* @return array<string,mixed>
*/
private function importOverlengthQuoteResult(array $pair, array $context, array $rules): array
{
$order = $context['order'];
$actualCourier = trim((string) ($order['logistics_company'] ?? ''));
$importedCourier = $pair['courier_name'];
$courier = $importedCourier !== '' ? $importedCourier : $actualCourier;
$ioId = (int) ($order['io_id'] ?? 0);
$best = null;
foreach ($context['items'] as $item) {
$skuId = trim((string) ($item['sku_id'] ?? ''));
$dimension = $context['dimensions'][$skuId] ?? null;
if ($dimension === null) {
continue;
}
$maxSide = max($dimension['l'], $dimension['w'], $dimension['h']);
if ($best === null || $maxSide > $best['max_side']) {
$best = [
'max_side' => $maxSide,
'sku_id' => $skuId,
'i_id' => trim((string) ($item['i_id'] ?? '')),
'length' => $dimension['l'],
'width' => $dimension['w'],
'height' => $dimension['h'],
'platform_product_id' => $context['platform'][$ioId . '|' . $skuId]
?? ($context['platform'][$ioId . '|'] ?? null),
];
}
}
$fields = [
'courier_name' => $courier !== '' ? $courier : null,
'shipped_at' => trim((string) ($order['io_date'] ?? '')) ?: null,
'platform_product_id' => $best['platform_product_id'] ?? null,
'product_code' => $best === null
? null
: ($best['sku_id'] !== '' ? $best['sku_id'] : ($best['i_id'] !== '' ? $best['i_id'] : null)),
'shop_name' => trim((string) ($order['shop_name'] ?? '')) ?: null,
'online_order_no' => trim((string) ($order['so_id'] ?? '')) ?: null,
'length' => $best['length'] ?? null,
'width' => $best['width'] ?? null,
'height' => $best['height'] ?? null,
'volume' => $best === null ? null : round($best['length'] * $best['width'] * $best['height'], 2),
'inspection_type' => null,
'inspection_reason' => null,
'return_waybill_no' => null,
'settlement_weight' => null,
];
if ($importedCourier !== '' && $actualCourier !== '' && $importedCourier !== $actualCourier) {
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
'不结:该运单实际快递为「' . $actualCourier . '」,与导入的「' . $importedCourier . '」不一致', null);
}
$rule = null;
foreach ($rules as $candidate) {
if ($candidate['courier'] === $actualCourier) {
$rule = $candidate;
break;
}
}
if ($rule === null) {
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
'不结:快递「' . ($actualCourier !== '' ? $actualCourier : '未知') . '」没有启用的「超长费」报价规则', null);
}
if ($best === null) {
return $this->importQuoteRow($pair, $fields, null, '待核价',
'待核价:商品资料缺少长宽高,暂无法核算超长费', '销售出库单已匹配,但条形码在商品资料中没有长宽高');
}
$evidence = mb_substr(sprintf(
'规则 #%d;条形码 %s 尺寸 %s×%s×%s,最长边 %scm %s %scm',
$rule['id'],
$best['sku_id'] !== '' ? $best['sku_id'] : '(无)',
$this->trimNumber($best['length']),
$this->trimNumber($best['width']),
$this->trimNumber($best['height']),
$this->trimNumber($best['max_side']),
$best['max_side'] > $rule['threshold'] ? '>' : '≤',
$this->trimNumber($rule['threshold']),
), 0, 500);
if ($best['max_side'] <= $rule['threshold']) {
return $this->importQuoteRow($pair, $fields, 0.0, '已计算',
'不结:最长边 ' . $this->trimNumber($best['max_side']) . 'cm 未超过报价阈值 ' . $this->trimNumber($rule['threshold']) . 'cm',
$evidence);
}
return $this->importQuoteRow($pair, $fields, (float) $rule['amount'], '已计算', '', $evidence);
}
/**
* @param array<string,mixed> $context
* @param array<string,array<string,list<string>>> $ruleModes
* @param array{overlength:list<array<string,mixed>>,return:list<array<string,mixed>>,intercept:list<array<string,mixed>>} $quoteRules
* @param array<string,float> $contextWeights
* @param array<int,list<array<string,mixed>>> $contextReturns
* @return array<string,mixed>
*/
private function importDeductQuoteResult(
array $pair,
array $context,
?object $fallbackRow,
array $ruleModes,
array $quoteRules,
array $contextWeights,
array $contextReturns,
): array {
$type = $pair['surcharge_type'];
$importedCourier = $pair['courier_name'];
$contextCourier = trim((string) ($context['courier'] ?? ''));
$fallbackCourier = trim((string) ($fallbackRow->courier_name ?? ''));
$courier = $importedCourier !== '' ? $importedCourier : ($contextCourier !== '' ? $contextCourier : $fallbackCourier);
$inspectionType = trim((string) ($context['inspection_type'] ?? ''));
$items = $context['items'] ?? [];
$primary = $this->primaryImportItem($items);
$orderWeight = $this->importWeightSum($items, $contextWeights) ?? 0.0;
$oId = (int) ($context['o_id'] ?? 0);
$returnWaybills = [];
$hasReturn = false;
$returnedWeight = 0.0;
$hasReturnWeight = false;
foreach ($contextReturns[$oId] ?? [] as $return) {
$rQty = (float) ($return['r_qty'] ?? 0);
$receiveDate = trim((string) ($return['receive_date'] ?? ''));
if ($rQty <= 0 || $receiveDate === '') {
continue;
}
$hasReturn = true;
$returnWaybill = trim((string) ($return['l_id'] ?? ''));
if ($returnWaybill !== '') {
$returnWaybills[$returnWaybill] = true;
}
$skuId = strtoupper(trim((string) ($return['sku_id'] ?? '')));
$weight = $skuId !== '' ? ($contextWeights[$skuId] ?? null) : null;
if ($weight !== null) {
$returnedWeight += $rQty * (float) $weight;
$hasReturnWeight = true;
}
}
$settlementWeight = $hasReturn
? ($hasReturnWeight && $returnedWeight > 0 ? $returnedWeight : $orderWeight)
: $orderWeight;
$fields = [
'courier_name' => $courier !== '' ? $courier : null,
'shipped_at' => trim((string) ($context['send_time'] ?? '')) ?: null,
'platform_product_id' => $primary['platform_product_id'] !== '' ? $primary['platform_product_id'] : null,
'product_code' => $primary['sku_id'] !== '' ? $primary['sku_id'] : ($primary['i_id'] !== '' ? $primary['i_id'] : null),
'shop_name' => trim((string) ($context['shop_name'] ?? '')) ?: null,
'online_order_no' => trim((string) ($context['online_order_no'] ?? '')) ?: null,
'length' => null,
'width' => null,
'height' => null,
'volume' => null,
'inspection_type' => $inspectionType !== '' ? $inspectionType : null,
'inspection_reason' => trim((string) ($context['inspection_reason'] ?? '')) ?: null,
'return_waybill_no' => $returnWaybills === [] ? null : mb_substr((string) array_key_first($returnWaybills), 0, 191),
'settlement_weight' => $settlementWeight > 0 ? round($settlementWeight, 2) : null,
];
if ($importedCourier !== '' && $contextCourier !== '' && $importedCourier !== $contextCourier) {
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
'不结:该运单实际快递为「' . $contextCourier . '」,与导入的「' . $importedCourier . '」不一致', null);
}
if ($inspectionType !== '扣单') {
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
'不结:该运单在查件工单里不是「扣单」类型', null);
}
$rules = $type === '拦截费' ? ($quoteRules['intercept'] ?? []) : ($quoteRules['return'] ?? []);
$rule = null;
foreach ($rules as $candidate) {
if ($candidate['courier'] === $courier) {
$rule = $candidate;
break;
}
}
if ($rule === null) {
$modes = array_values(array_unique($ruleModes[$courier][$type] ?? []));
if ($modes !== []) {
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
'不结:快递「' . $courier . '」的' . $type . '报价规则(' . implode('、', $modes) . ')暂不支持自动核算', null);
}
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
'不结:快递「' . ($courier !== '' ? $courier : '未知') . '」没有启用的「' . $type . '」报价规则', null);
}
if ($type === '拦截费') {
return $this->importQuoteRow($pair, $fields, (float) $rule['value'], '已计算', '',
'规则 #' . $rule['id'] . ';查件扣单按固定单价 ' . $this->trimNumber((float) $rule['value']) . ' 元/单');
}
$evidenceParts = ['规则 #' . $rule['id']];
if ($hasReturn) {
$evidenceParts[] = '售后退货退款已进仓,结算重量 ' . $this->trimNumber($settlementWeight) . 'kg';
} else {
$evidenceParts[] = '售后退货退款无进仓时间/实退数量';
}
if ($rule['mode'] === '固定单价') {
return $this->importQuoteRow(
$pair,
$fields,
$hasReturn ? (float) $rule['value'] : 0.0,
'已计算',
$hasReturn ? '' : '不结:售后退货退款无进仓时间/实退数量',
implode(';', $evidenceParts),
);
}
if (! $hasReturn) {
return $this->importQuoteRow($pair, $fields, 0.0, '已计算',
'不结:售后退货退款无进仓时间/实退数量', implode(';', $evidenceParts));
}
if ($settlementWeight <= 0) {
return $this->importQuoteRow($pair, $fields, null, '待核价',
'待核价:商品资料缺少重量,暂无法按重量核算退回费', implode(';', $evidenceParts));
}
return $this->importQuoteRow(
$pair,
$fields,
round($settlementWeight * (float) $rule['value'], 2),
'已计算',
'',
implode(';', $evidenceParts) . ';' . $this->trimNumber($settlementWeight) . 'kg × '
. $this->trimNumber((float) $rule['value']) . ' 元/kg',
);
}
/**
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
* @param array<string,mixed> $fields
* @return array<string,mixed>
*/
private function importQuoteRow(
array $pair,
array $fields,
?float $amount,
string $status,
string $reason,
?string $evidence,
): array {
$decision = $amount !== null && $amount > 0 ? '正常结' : '不结';
return array_merge([
'row_number' => $pair['row_number'],
'waybill' => $pair['waybill'],
'surcharge_type' => $pair['surcharge_type'],
'decision' => $decision,
'reason' => $decision === '正常结'
? ''
: ($reason !== '' ? $reason : '结算金额为 0'),
], $fields, [
'settlement_amount' => $amount,
'settlement_status' => $status,
'evidence' => $evidence,
]);
}
/**
* 当前启用的报价规则里,可用于导入核算的部分。
*
* @return array{overlength:list<array{id:int,courier:string,threshold:float,amount:float}>,
* return:list<array{id:int,courier:string,mode:string,value:float}>,
* intercept:list<array{id:int,courier:string,value:float}>}
*/
private function importQuoteRules(): array
{
$rules = ['overlength' => [], 'return' => [], 'intercept' => []];
try {
$rows = DB::table('business_courier_surcharge_quotes')
->where('status', 'active')
->orderBy('id')
->get();
} catch (Throwable) {
return $rules;
}
foreach ($rows as $rule) {
$types = json_decode((string) $rule->types, true) ?: [];
$courier = trim((string) $rule->courier);
$mode = trim((string) $rule->mode);
$condition = (string) $rule->condition;
$standard = (string) $rule->standard;
if ($courier === '') {
continue;
}
if (in_array('超长费', $types, true)
&& (str_contains($mode, '按尺寸') || str_contains($mode, '最长边'))
&& preg_match('/单边超\s*(\d+(?:\.\d+)?)\s*(?:cm|厘米|CM|㎝)/u', $condition, $threshold) === 1
&& preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/?\s*单/u', $standard, $price) === 1) {
$rules['overlength'][] = [
'id' => (int) $rule->id,
'courier' => $courier,
'threshold' => (float) $threshold[1],
'amount' => (float) $price[1],
];
}
if (! str_contains($condition, '扣单')) {
continue;
}
if (in_array('拦截费', $types, true)
&& $mode === '固定单价'
&& preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $price) === 1) {
$rules['intercept'][] = [
'id' => (int) $rule->id,
'courier' => $courier,
'value' => (float) $price[1],
];
}
if (in_array('退回费', $types, true)) {
if ($mode === '固定单价'
&& preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $price) === 1) {
$rules['return'][] = [
'id' => (int) $rule->id,
'courier' => $courier,
'mode' => '固定单价',
'value' => (float) $price[1],
];
} elseif ($mode === '按重量'
&& preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/\s*(?:kg|KG|公斤|千克)/u', $standard, $price) === 1) {
$rules['return'][] = [
'id' => (int) $rule->id,
'courier' => $courier,
'mode' => '按重量',
'value' => (float) $price[1],
];
}
}
}
return $rules;
}
/**
* @param list<string> $skuIds
* @return array<string,array{l:float,w:float,h:float}>
*/
private function importDimensions(array $skuIds): array
{
$map = [];
foreach (array_chunk(array_values(array_unique(array_filter($skuIds))), 500) as $chunk) {
try {
$rows = $this->connection->table('ops_jst_sku')
->whereIn('sku_id', $chunk)
->get(['sku_id', 'l', 'w', 'h']);
} catch (Throwable) {
return $map;
}
foreach ($rows as $row) {
$length = (float) $row->l;
$width = (float) $row->w;
$height = (float) $row->h;
if ($length > 0 && $width > 0 && $height > 0) {
$map[trim((string) $row->sku_id)] = ['l' => $length, 'w' => $width, 'h' => $height];
}
}
}
return $map;
}
private function trimNumber(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
/**
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
* @param array<string,array{courier:string}> $returnContext
@ -677,23 +1293,33 @@ class CourierSurchargeSettlementMirrorRepository
static fn (string $waybill): bool => $waybill !== '',
)));
if ($waybills === [] || count($waybills) > self::IMPORT_RETURN_CONTEXT_LIMIT) {
$lookup = app(\Modules\EcommerceFinance\Services\WorkOrderWaybillLookup::class);
$indexed = $lookup->ready();
if ($waybills === [] || (! $indexed && count($waybills) > self::IMPORT_RETURN_CONTEXT_LIMIT)) {
return [];
}
$context = [];
foreach (array_chunk($waybills, 50) as $chunk) {
foreach (array_chunk($waybills, $indexed ? 300 : 50) as $chunk) {
try {
$rows = $this->connection->table('js_jst_work_order_info')
->where('title', '查件')
->where(function ($query) use ($chunk): void {
foreach ($chunk as $waybill) {
$query->orWhere('businessFieldList', 'like', '%' . str_replace(['%', '_'], '', $waybill) . '%');
}
})
->orderByDesc('Id')
->get(['businessFieldList']);
if ($indexed) {
$rows = $this->connection->table($lookup::TABLE . ' as lookup')
->join('js_jst_work_order_info as raw', 'raw._mirror_key', '=', 'lookup.mirror_key')
->whereIn('lookup.waybill', $chunk)
->orderByDesc('lookup.source_id')->get(['raw.businessFieldList']);
} else {
// Retain the existing path only until the resumable initial index is ready.
$rows = $this->connection->table('js_jst_work_order_info')
->where('title', '查件')
->where(function ($query) use ($chunk): void {
foreach ($chunk as $waybill) {
$query->orWhere('businessFieldList', 'like', '%' . str_replace(['%', '_'], '', $waybill) . '%');
}
})
->orderByDesc('Id')
->get(['businessFieldList']);
}
} catch (Throwable) {
continue;
}
@ -851,7 +1477,7 @@ class CourierSurchargeSettlementMirrorRepository
/**
* @param array<string,array<string,mixed>> $returnContext
* @return array<int,string>
* @return array<int,list<array{o_id:int,l_id:string,sku_id:string,r_qty:float,receive_date:string}>>
*/
private function importReturns(array $returnContext): array
{
@ -882,7 +1508,8 @@ class CourierSurchargeSettlementMirrorRepository
try {
$rows = $upstream->select(
'SELECT a.o_id, a.l_id, ri.r_qty, CONVERT(varchar(19), ri.receive_date, 120) AS receive_date '
'SELECT a.o_id, a.l_id, ri.sku_id, ri.r_qty, '
. 'CONVERT(varchar(19), ri.receive_date, 120) AS receive_date '
. 'FROM rpt.jst_returnAndRefundInfo a '
. 'INNER JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = a.as_id '
. 'WHERE a.o_id IN (' . $placeholders . ')',
@ -904,17 +1531,17 @@ class CourierSurchargeSettlementMirrorRepository
continue;
}
$byOrder[$oId][$waybill] = true;
$byOrder[$oId][] = [
'o_id' => $oId,
'l_id' => $waybill,
'sku_id' => trim((string) ($row['sku_id'] ?? '')),
'r_qty' => (float) ($row['r_qty'] ?? 0),
'receive_date' => trim((string) ($row['receive_date'] ?? '')),
];
}
}
$map = [];
foreach ($byOrder as $oId => $waybills) {
$map[(int) $oId] = implode(',', array_keys($waybills));
}
return $map;
return $byOrder;
}
/** @return array<string,array<string,list<string>>> */

View File

@ -6,6 +6,7 @@ use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\WorkOrderWaybillLookup;
use RuntimeException;
class GenericBusinessMirrorRepository
@ -161,6 +162,9 @@ class GenericBusinessMirrorRepository
foreach (array_chunk($mapped, $chunkSize) as $chunk) {
$this->connection->table($dataset->targetTable())->upsert($chunk, ['_mirror_key'], $updates);
}
if ($dataset->targetTable() === WorkOrderWaybillLookup::SOURCE) {
app(WorkOrderWaybillLookup::class)->storeBatch($mapped);
}
$this->connection->table('business_mirror_table_states')
->where('dataset_key', $dataset->key())
@ -197,6 +201,9 @@ class GenericBusinessMirrorRepository
->orWhere('_mirror_generation', '!=', $generation);
})
->delete();
if ($dataset->targetTable() === WorkOrderWaybillLookup::SOURCE) {
app(WorkOrderWaybillLookup::class)->prune();
}
}
$countState = $this->supportsExactCount()

View File

@ -4,6 +4,7 @@ namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\JdBillPlatform;
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
use RuntimeException;
@ -28,7 +29,8 @@ class JdDailyBillRepository
{
$periods = $this->periods();
$query = $this->query($filters, $periods);
$summary = (clone $query)->selectRaw('COUNT(*) AS count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')->first();
$summary = Cache::remember($this->cacheKey('summary', $periods, $filters), 3600,
fn () => (clone $query)->selectRaw('COUNT(*) AS count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')->first());
$total = (int) $summary->count;
$page = max(1, $page);
$perPage = min(100, max(1, $perPage));
@ -64,11 +66,24 @@ class JdDailyBillRepository
public function filterOptions(): array
{
$q = $this->query([], $this->periods());
return [
'shops' => (clone $q)->distinct()->orderBy('store_name')->pluck('store_name')->all(),
'bill_items' => (clone $q)->distinct()->orderBy('bill_item')->pluck('bill_item')->all(),
];
$periods = $this->periods();
return Cache::remember($this->cacheKey('options', $periods), 3600, function () use ($periods): array {
$q = $this->query([], $periods);
return [
'shops' => (clone $q)->distinct()->orderBy('store_name')->pluck('store_name')->all(),
'bill_items' => (clone $q)->distinct()->orderBy('bill_item')->pluck('bill_item')->all(),
];
});
}
/** Imported generations are immutable; replacing a month invalidates both caches. */
private function cacheKey(string $scope, \Illuminate\Support\Collection $periods, array $filters = []): string
{
ksort($filters);
return 'jd-daily-bill:' . $scope . ':v1:' . hash('sha256', json_encode([
$this->db()->getDatabaseName(), $this->platform,
$periods->pluck('active_generation')->sort()->values()->all(), $filters,
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
private function periods(): \Illuminate\Support\Collection

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

@ -495,12 +495,18 @@ class OrderDataMirrorRepository
$shopNames = $this->selectedShopNames($filters);
if ($shopNames !== []) {
// 多店铺:每个店铺做一个模糊匹配再取并集,选中的是完整店铺名时等同于精确匹配。
$query->where(function (Builder $shop) use ($shopNames): void {
foreach ($shopNames as $name) {
$shop->orWhereRaw("o.shop_name LIKE ? ESCAPE '!'", [$this->contains($name)]);
}
});
// A release clears the complete cache; the small shop directory remains valid.
$known = $this->shopNames();
if (! empty($filters['shop_names_exact']) && array_diff($shopNames, $known) === []) {
$query->whereIn('o.shop_name', $shopNames);
} else {
// Free text and legacy callers keep their existing contains semantics.
$query->where(function (Builder $shop) use ($shopNames): void {
foreach ($shopNames as $name) {
$shop->orWhereRaw("o.shop_name LIKE ? ESCAPE '!'", [$this->contains($name)]);
}
});
}
}
if (($filters['sku_id'] ?? '') !== '') {
// 商品编码同样支持批量:每个编码一组"商品/款式/店铺编码"匹配,多组取并集。

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

@ -11,7 +11,7 @@ use Throwable;
* 店铺收款账户账单的「导出任务」:后台生成 CSV 落盘,页面按任务列表查看状态并下载。
*
* 任务与文件都存在 storage/app/account-bills-exports/{userId}/ 下(JSON 记录状态 + CSV 文件),
* 不依赖浏览器保持连接,也不需要队列表:请求里用 detach 的 php-cli 进程跑导出。
* 不依赖浏览器保持连接;生产由受限批处理容器逐个消费,本地由单个 PHP 进程消费。
*/
class AccountBillsExportTasks
{
@ -29,7 +29,12 @@ class AccountBillsExportTasks
public function baseDir(int $userId): string
{
return storage_path('app/account-bills-exports/' . $userId);
return $this->rootDir() . '/' . $userId;
}
public function rootDir(): string
{
return storage_path('app/account-bills-exports');
}
public function jsonPath(array $task): string
@ -167,6 +172,8 @@ class AccountBillsExportTasks
/** 后台执行导出(脱离当前请求)。 */
public function dispatch(array $task): void
{
// Production consumes the durable file queue from the limited batch profile.
if (config('upstream_pipeline.batch_worker_enabled', false)) return;
$php = PHP_BINDIR . '/php';
if (! is_file($php)) {
$php = 'php';
@ -174,7 +181,7 @@ class AccountBillsExportTasks
$artisan = base_path('artisan');
$command = escapeshellcmd($php) . ' ' . escapeshellarg($artisan)
. ' account-bills:export-run ' . escapeshellarg((string) $task['token'])
. ' account-bills:exports:run --max-jobs=0'
. ' > /dev/null 2>&1 &';
try {
@ -185,4 +192,15 @@ class AccountBillsExportTasks
// 起不了后台进程时保持 queued,页面会提示重试。
}
}
public function nextQueued(): ?array
{
$next = null;
foreach (glob($this->rootDir() . '/*/*.json') ?: [] as $path) {
$task = json_decode((string) file_get_contents($path), true);
if (! is_array($task) || ($task['status'] ?? '') !== 'queued') continue;
if ($next === null || strcmp((string) $task['planned_at'], (string) $next['planned_at']) < 0) $next = $task;
}
return $next;
}
}

View File

@ -49,20 +49,20 @@ class AccountMappingResyncDispatcher
}
$mirror = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$months = $mirror->table('store_receipt_order_match_rows')
$periods = $mirror->table('store_receipt_order_match_rows')
->whereIn('platform', array_values($targets))
->selectRaw("DATE_FORMAT(report_month, '%Y-%m') as ym")
->select(['platform', 'report_month'])
->distinct()
->orderBy('ym')
->pluck('ym')
->all();
if ($months === []) {
->orderBy('report_month')
->get()->groupBy('platform');
if ($periods->isEmpty()) {
return ['jobs' => 0];
}
$now = now();
$jobs = 0;
foreach ($targets as $key => $label) {
$months = $periods->get($label, collect())->map(fn ($row) => substr((string) $row->report_month, 0, 7))->unique();
foreach ($months as $month) {
// running 时再次保存映射,会把状态改回 pending;当前进程完成时发现状态已变,
// 不会覆盖成 done,于是会紧接着再跑一遍,避免漏掉刚保存的新规则。

View File

@ -188,12 +188,24 @@ class AccountMappingStore
throw new RuntimeException('未找到要修改的规则。');
}
$this->persist($rules);
$this->logEvent('update', $id, $before, $updated, $userId);
if ($before !== $updated) {
$this->persist($rules);
$this->logEvent('update', $id, $before, $updated, $userId);
}
return $updated;
}
/** Fee memo is display-only; all other fields retain conservative invalidation. */
public static function requiresReceiptResync(?array $before, ?array $after): bool
{
foreach (self::FIELDS as $field) {
if ($field === 'feeMemo') continue;
if (trim((string) ($before[$field] ?? '')) !== trim((string) ($after[$field] ?? ''))) return true;
}
return false;
}
public function destroy(int $id, ?int $userId = null): bool
{
$rules = $this->all();
@ -222,7 +234,7 @@ class AccountMappingStore
* 导入:按「平台 + 账单项目 + 财务备注」判重。
*
* @param array<int,array<string,mixed>> $rows
* @return array{created:int,updated:int,skipped:int,duplicates:array<int,string>,total:int}
* @return array{created:int,updated:int,skipped:int,duplicates:array<int,string>,total:int,resync_platforms:list<string>}
*/
public function importRows(array $rows, bool $overwrite, ?int $userId = null): array
{
@ -242,6 +254,7 @@ class AccountMappingStore
$skipped = 0;
$duplicates = [];
$resyncPlatforms = [];
foreach ($rows as $row) {
$values = $this->normalize($row);
if ($this->isEmptyRow($values)) {
@ -256,7 +269,16 @@ class AccountMappingStore
continue;
}
$index = $byKey[$key];
$rules[$index] = ['id' => $rules[$index]['id']] + $values;
$replacement = ['id' => $rules[$index]['id']] + $values;
if ($rules[$index] === $replacement) {
$skipped++;
continue;
}
if (self::requiresReceiptResync($rules[$index], $replacement)) {
$resyncPlatforms[$rules[$index]['platform']] = true;
$resyncPlatforms[$replacement['platform']] = true;
}
$rules[$index] = $replacement;
$updated++;
continue;
}
@ -265,6 +287,7 @@ class AccountMappingStore
$byKey[$key] = count($rules) - 1;
$nextId++;
$created++;
$resyncPlatforms[$values['platform']] = true;
}
if ($created + $updated > 0) {
@ -282,6 +305,7 @@ class AccountMappingStore
'skipped' => $skipped,
'duplicates' => array_slice($duplicates, 0, 20),
'total' => count($rows),
'resync_platforms' => array_keys($resyncPlatforms),
];
}
@ -510,6 +534,7 @@ class AccountMappingStore
/** @param array<int,array<string,mixed>> $rules */
private function persist(array $rules): void
{
self::forgetMemo();
if (! $this->rulesTableReady()) {
$this->persistFile($rules);

View File

@ -3,9 +3,11 @@
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use PDOException;
use RuntimeException;
use Throwable;
@ -64,9 +66,12 @@ class BusinessMirrorSynchronizer
$batches = 0;
$deadline = microtime(true) + max((int) config('upstream_pipeline.sync.dataset_seconds', 8), 1);
$rows = (int) ($state['last_upserted_rows'] ?? 0);
$started = microtime(true);
$stage = 'read_upstream';
try {
while (true) {
$stage = 'read_upstream';
$batch = $this->readBatch(
$dataset,
$columns,
@ -79,17 +84,24 @@ class BusinessMirrorSynchronizer
);
if ($batch === []) {
$stage = 'complete_mirror';
$this->mirror->complete($dataset, $mode, $generation, $cursor ?? $highWatermark, $rows);
return $this->result($dataset, 'succeeded', $mode, $batches, $rows);
}
$stage = 'normalize_rows';
$cursor = $this->cursor($dataset, $batch[array_key_last($batch)]);
$rows += count($batch);
$batchRows = count($batch);
$rows += $batchRows;
$batches++;
$this->mirror->storeBatch($dataset, $columns, $this->normalize($columns, $batch), $cursor, $generation, $rows);
$batch = $this->normalize($columns, $batch);
$stage = 'write_mirror';
$this->mirror->storeBatch($dataset, $columns, $batch, $cursor, $generation, $rows);
unset($batch);
if (count($batch) < $batchSize) {
if ($batchRows < $batchSize) {
$stage = 'complete_mirror';
$this->mirror->complete($dataset, $mode, $generation, $cursor, $rows);
return $this->result($dataset, 'succeeded', $mode, $batches, $rows);
@ -100,6 +112,28 @@ class BusinessMirrorSynchronizer
}
}
} catch (Throwable $exception) {
// PDOException alone cannot distinguish an upstream timeout from a
// local transaction failure. Keep the stage and numeric diagnostics,
// never the exception message, SQL, bindings or connection details.
$sqlState = null;
$driverCode = null;
for ($cause = $exception; $cause !== null; $cause = $cause->getPrevious()) {
if ($cause instanceof PDOException) {
$code = (string) ($cause->errorInfo[0] ?? $cause->getCode());
$sqlState = preg_match('/^[A-Z0-9]{5}$/D', $code) === 1 ? $code : null;
$native = $cause->errorInfo[1] ?? null;
$driverCode = is_numeric($native) ? (int) $native : null;
}
}
Log::warning('共享业务镜像同步阶段失败。', [
'dataset' => $dataset->key(),
'source' => $dataset->sourceKey(),
'stage' => $stage,
'sql_state' => $sqlState,
'driver_code' => $driverCode,
'elapsed_seconds' => round(microtime(true) - $started, 3),
'exception' => $exception::class,
]);
$this->mirror->fail($dataset, $exception::class);
throw $exception;
}
@ -157,33 +191,49 @@ class BusinessMirrorSynchronizer
implode(', ', array_map(fn (array $column): string => $this->selectExpression($column), $columns)), $tail, $limit,
);
if ($this->driver === 'sqlsrv' && $mode === 'incremental'
&& in_array($dataset->key(), (array) config('upstream_mirrors.table_sync.key_first_datasets', []), true)) {
$keys = (new UpstreamSqlDialect($this->driver))->select(
implode(', ', array_map(fn (string $name): string => $this->quote($name), $order)), $tail, $limit,
);
$keyColumn = fn (string $name): string => $this->quote('mirror_keys') . '.' . $this->quote($name);
$rowColumn = fn (string $name): string => $this->quote('mirror_rows') . '.' . $this->quote($name);
$sql = 'SELECT ' . implode(', ', array_map(fn (array $column): string => $this->selectExpression($column, 'mirror_rows'), $columns))
. ' FROM (' . $keys . ') AS ' . $this->quote('mirror_keys')
. ' INNER LOOP JOIN ' . $this->quote($dataset->sourceSchema()) . '.' . $this->quote($dataset->sourceObject())
. ' AS ' . $this->quote('mirror_rows')
. ' ON ' . implode(' AND ', array_map(fn (string $name): string => $rowColumn($name) . ' = ' . $keyColumn($name), $order))
. ' ORDER BY ' . implode(', ', array_map($keyColumn, $order));
}
return $this->queries->forSource($dataset->sourceKey())->select($sql, $bindings);
}
/** @param array<string,mixed> $column */
private function selectExpression(array $column): string
private function selectExpression(array $column, ?string $alias = null): string
{
$name = (string) $column['column_name'];
$quoted = $this->quote($name);
$reference = $alias === null ? $quoted : $this->quote($alias) . '.' . $quoted;
if ($this->driver !== 'sqlsrv') {
if ($this->driver === 'pgsql' && ($column['data_type'] ?? '') === 'timestamp with time zone') {
// Store timezone-aware PostgreSQL instants as UTC in the shared mirror.
return "to_char({$quoted} AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US') AS {$quoted}";
return "to_char({$reference} AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US') AS {$quoted}";
}
if ($this->driver === 'pgsql' && ($column['data_type'] ?? '') === 'bytea') {
return "encode({$quoted}, 'hex') AS {$quoted}";
return "encode({$reference}, 'hex') AS {$quoted}";
}
return $quoted;
return $reference;
}
// FreeTDS may return SQL Server date values using the connection locale
// (for example "Jan 1 2026"). Convert temporal columns at the source so
// every mirror receives deterministic ISO values that MariaDB accepts.
return match (strtolower((string) ($column['data_type'] ?? ''))) {
'date' => 'CONVERT(varchar(10), ' . $quoted . ', 23) AS ' . $quoted,
'datetime', 'datetime2', 'smalldatetime' => 'CONVERT(varchar(27), ' . $quoted . ', 126) AS ' . $quoted,
default => $quoted,
'date' => 'CONVERT(varchar(10), ' . $reference . ', 23) AS ' . $quoted,
'datetime', 'datetime2', 'smalldatetime' => 'CONVERT(varchar(27), ' . $reference . ', 126) AS ' . $quoted,
default => $reference,
};
}

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

@ -14,7 +14,7 @@ use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
* 种菜损耗(2026-10-19 财务确认的口径):
* 1. 取 M 月种菜报销明细(完成时间 [M-02, M+1-02));
* 2. 用线上订单号去匹配发货数据(jst_oms_shipping)中 M-1、M 两个月发货的交易单号,匹配到的保留;
* 3. 匹配到的单里,任务备注或卖家备注写了“拍A发B”或“拍A发A”的不扣款,单独列出发给运营;
* 3. 任务备注或卖家备注写了“拍A发B”或“拍A发A”的不扣款,不论是否匹配到发货都单独列出发给运营;
* 4. 实际只发了「信誉订单虚拟条码」「透明挂钩」这类商品的不扣款;
* 5. 其余匹配到的作为扣款项,扣款金额 = Σ 发货商品成本价 × 数量 + 该订单在发货数据中的运费(2026-10-19 更正),
* 成本价按发货条形码(=商品编码)取普通商品资料。未匹配到发货的不计入种菜损耗。
@ -32,7 +32,16 @@ class PlantingLossCalculator
public const RESULTS = [self::RESULT_DEDUCT, self::RESULT_EXEMPT, self::RESULT_FREE_GOODS, self::RESULT_UNMATCHED];
/** 发货商品名称或条码含这些字样的不计扣款。 */
public const FREE_GOODS_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩'];
public const FREE_GOODS_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩', '无实物'];
/**
* 信誉订单虚拟条码(种菜单自身的虚拟商品)。发货数据里同一交易单号下若出现它,说明这单本身是按虚拟条码走的,
* 同单号下的其它商品是合并发货的别的订单,整单不扣款(2026-10-19 财务确认:信誉订单虚拟条码不扣款)。
* 透明挂钩同理:同单号下出现透明挂钩即整单不扣款(2026-10-20 财务确认)。
*/
public const VIRTUAL_ORDER_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩'];
public const VIRTUAL_ORDER_BARCODES = ['X00006', 'X00002'];
public const SKU_DATASET = 'operations_replica.rpt.jst_sku';
@ -101,6 +110,22 @@ class PlantingLossCalculator
return false;
}
public static function isVirtualOrderItem(string $barcode, string $productName): bool
{
$code = strtoupper(trim($barcode));
// 透明挂钩条形码以 GG 开头(如 GG003-SZ / GG-xxx),2026-10-20 财务确认。
if (in_array($code, self::VIRTUAL_ORDER_BARCODES, true) || str_starts_with($code, 'GG')) {
return true;
}
foreach (self::VIRTUAL_ORDER_KEYWORDS as $keyword) {
if (str_contains($productName, $keyword)) {
return true;
}
}
return false;
}
/** 成本价(元,字符串)× 数量 → 分。 */
public static function deductionCents(string $costPrice, int $qty): int
{
@ -153,7 +178,13 @@ class PlantingLossCalculator
$deductLines = [];
$missingCost = [];
$cents = 0;
$virtualOrder = false;
foreach ($shipment['lines'] ?? [] as $line) {
if (self::isVirtualOrderItem($line['barcode'], $line['product_name'])) {
$virtualOrder = true;
}
}
foreach ($virtualOrder ? [] : ($shipment['lines'] ?? []) as $line) {
if (self::isFreeGoods($line['barcode'], $line['product_name'])) {
continue;
}
@ -182,11 +213,13 @@ class PlantingLossCalculator
$deductLines !== [] && $freight === null ? '运费缺失' : '',
]));
$keyword = $shipment === null ? null : self::exemptionKeyword((string) $row['task_remark'], (string) $row['seller_remark']);
// 拍A发B/拍A发A 不论是否匹配到发货都单独列出发给运营(2026-10-20 财务确认);匹配发货只用于挑出真实发货的单。
$keyword = self::exemptionKeyword((string) $row['task_remark'], (string) $row['seller_remark']);
[$result, $basis] = match (true) {
$keyword !== null => [self::RESULT_EXEMPT, '备注含“' . $keyword . '”' . ($shipment === null ? '(未匹配到发货)' : '')],
$shipment === null => [self::RESULT_UNMATCHED, self::shippingLabel($month) . ' 发货数据中无此交易单号'],
$keyword !== null => [self::RESULT_EXEMPT, '备注含“' . $keyword . '”'],
$deductLines === [] => [self::RESULT_FREE_GOODS, '只发了虚拟条码/透明挂钩'],
$virtualOrder => [self::RESULT_FREE_GOODS, '发货含信誉订单虚拟条码/透明挂钩,整单不扣款'],
$deductLines === [] => [self::RESULT_FREE_GOODS, '只发了虚拟条码/透明挂钩/无实物商品'],
$missingText !== '' => [self::RESULT_DEDUCT, $missingText . ',扣款金额未含缺失部分'],
default => [self::RESULT_DEDUCT, '已匹配发货,按成本价×数量+运费扣款'],
};

View File

@ -457,6 +457,15 @@ class StoreReportMirrorSynchronizer
* @return array{status:string,source_rows:int,stored_rows:int,changed:bool}
*/
public function syncPaidSales(bool $force = false): array
{
$result = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock('store-report:paid-sales', 600)
->get(fn () => $this->syncPaidSalesUnlocked($force));
return $result === false
? ['status' => 'succeeded', 'source_rows' => 0, 'stored_rows' => 0, 'changed' => false]
: $result;
}
private function syncPaidSalesUnlocked(bool $force): array
{
if (! Schema::connection($this->connection->getName())
->hasTable(StoreReportRepository::PAID_SALES_SOURCE_TABLE)) {
@ -464,55 +473,66 @@ class StoreReportMirrorSynchronizer
}
$state = $this->state(StoreReportRepository::PAID_SALES_DATASET);
$changes = app(StoreReportPaidSalesChanges::class);
try {
$fingerprint = $this->paidSalesFingerprint();
$active = trim((string) ($state->active_generation ?? ''));
if (! $force && $active !== '' && ! empty($state->initial_completed_at)
&& (string) ($state->source_fingerprint ?? '') === $fingerprint) {
return [
'status' => 'succeeded',
'source_rows' => 0,
'stored_rows' => (int) ($state->row_count ?? 0),
'changed' => false,
];
if (! $force && $changes->available() && ! empty($state->active_generation)
&& ! empty($state->initial_completed_at)
&& str_starts_with((string) ($state->source_fingerprint ?? ''), StoreReportPaidSalesChanges::VERSION)) {
return $this->syncPaidSalesDates($state, $changes);
}
return $this->connection->transaction(function () use ($state, $force, $changes): array {
$pending = $changes->available() ? $changes->pending() : [];
$fingerprint = $changes->available()
? StoreReportPaidSalesChanges::VERSION . Str::uuid() : $this->paidSalesFingerprint();
$active = trim((string) ($state->active_generation ?? ''));
if (! $force && $active !== '' && ! empty($state->initial_completed_at)
&& (string) ($state->source_fingerprint ?? '') === $fingerprint) {
return [
'status' => 'succeeded',
'source_rows' => 0,
'stored_rows' => (int) ($state->row_count ?? 0),
'changed' => false,
];
}
$now = now();
$generation = (string) Str::uuid();
$this->connection->statement(
'INSERT INTO ' . StoreReportRepository::PAID_SALES_TABLE
. ' (generation, store_name, occurred_on, paid_amount, order_count, updated_at)'
. ' SELECT ?, store_name, occurred_date,'
. ' SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)), COUNT(*), ?'
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE
. " WHERE store_name IS NOT NULL AND store_name <> '' AND occurred_date IS NOT NULL"
. ' GROUP BY store_name, occurred_date',
[$generation, $now->format('Y-m-d H:i:s')],
);
$stored = (int) $this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
->where('generation', $generation)->count();
$sourceRows = (int) ($this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)->count());
$this->connection->table('store_report_import_states')
->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET)
->update([
'status' => 'succeeded',
'run_mode' => 'full',
'active_generation' => $generation,
'pending_generation' => null,
'row_count' => $stored,
'source_fingerprint' => $fingerprint,
'initial_completed_at' => $state->initial_completed_at ?? $now,
'last_attempted_at' => $now,
'last_succeeded_at' => $now,
'last_error_code' => null,
'updated_at' => $now,
]);
// 版本切换成功后再清理上一版,避免中途失败时页面读到被清空的当前版本。
$this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
->where('generation', '<>', $generation)->delete();
$now = now();
$generation = (string) Str::uuid();
$this->connection->statement(
'INSERT INTO ' . StoreReportRepository::PAID_SALES_TABLE
. ' (generation, store_name, occurred_on, paid_amount, order_count, updated_at)'
. ' SELECT ?, store_name, occurred_date,'
. ' SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)), COUNT(*), ?'
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE
. " WHERE store_name IS NOT NULL AND store_name <> '' AND occurred_date IS NOT NULL"
. ' GROUP BY store_name, occurred_date',
[$generation, $now->format('Y-m-d H:i:s')],
);
$stored = (int) $this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
->where('generation', $generation)->count();
$sourceRows = (int) ($this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)->count());
$this->connection->table('store_report_import_states')
->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET)
->update([
'status' => 'succeeded',
'run_mode' => 'full',
'active_generation' => $generation,
'pending_generation' => null,
'row_count' => $stored,
'source_fingerprint' => $fingerprint,
'initial_completed_at' => $state->initial_completed_at ?? $now,
'last_attempted_at' => $now,
'last_succeeded_at' => $now,
'last_error_code' => null,
'updated_at' => $now,
]);
// 版本切换成功后再清理上一版,避免中途失败时页面读到被清空的当前版本。
$this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
->where('generation', '<>', $generation)->delete();
if ($changes->available()) $changes->acknowledge($pending);
return ['status' => 'succeeded', 'source_rows' => $sourceRows, 'stored_rows' => $stored, 'changed' => true];
return ['status' => 'succeeded', 'source_rows' => $sourceRows, 'stored_rows' => $stored, 'changed' => true];
});
} catch (Throwable $exception) {
$this->connection->table('store_report_import_states')
->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET)
@ -532,6 +552,41 @@ class StoreReportMirrorSynchronizer
}
}
private function syncPaidSalesDates(object $state, StoreReportPaidSalesChanges $changes): array
{
$pending = $changes->pending();
if ($pending === []) {
return ['status' => 'succeeded', 'source_rows' => 0, 'stored_rows' => (int) $state->row_count, 'changed' => false];
}
$dates = array_column($pending, 'occurred_on');
$generation = (string) $state->active_generation;
return $this->connection->transaction(function () use ($changes, $pending, $dates, $generation): array {
$this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
->where('generation', $generation)->whereIn('occurred_on', $dates)->delete();
$this->connection->statement(
'INSERT INTO ' . StoreReportRepository::PAID_SALES_TABLE
. ' (generation, store_name, occurred_on, paid_amount, order_count, updated_at)'
. ' SELECT ?, store_name, occurred_date,'
. ' SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)), COUNT(*), ?'
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE
. " WHERE store_name IS NOT NULL AND store_name <> '' AND occurred_date IN ("
. implode(',', array_fill(0, count($dates), '?')) . ') GROUP BY store_name, occurred_date',
[$generation, now()->format('Y-m-d H:i:s'), ...$dates],
);
$stored = (int) $this->connection->table(StoreReportRepository::PAID_SALES_TABLE)->where('generation', $generation)->count();
$sourceRows = (int) $this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)->whereIn('occurred_date', $dates)->count();
$this->connection->table('store_report_import_states')
->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET)->update([
'status' => 'succeeded', 'run_mode' => 'incremental', 'row_count' => $stored,
'source_fingerprint' => StoreReportPaidSalesChanges::VERSION . Str::uuid(),
'last_attempted_at' => now(), 'last_succeeded_at' => now(), 'last_error_code' => null, 'updated_at' => now(),
]);
$changes->acknowledge($pending);
return ['status' => 'succeeded', 'source_rows' => $sourceRows, 'stored_rows' => $stored, 'changed' => true];
});
}
/** 业绩明细是否变化的指纹:行数 + 最近写入时间 + 销量口径四项金额合计。 */
private function paidSalesFingerprint(): string
{

View File

@ -0,0 +1,62 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
/** Persistent invalidation by affected date, including dates removed by a period replacement. */
class StoreReportPaidSalesChanges
{
public const TABLE = 'store_report_paid_sales_dirty_dates';
public const VERSION = 'dates:v1:';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
public function available(): bool
{
return $this->connection->getSchemaBuilder()->hasTable(self::TABLE);
}
public function markPeriod(string $reportMonth, string $platform): void
{
if (! $this->available()) return;
$dates = $this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)
->where('report_month', substr($reportMonth, 0, 7) . '-01')->where('platform', $platform)
->whereNotNull('occurred_date')->distinct()->pluck('occurred_date');
$this->markDates($dates->all());
}
public function markDates(array $dates): void
{
if (! $this->available()) return;
$revision = (string) Str::uuid();
$rows = array_map(fn ($date) => ['occurred_on' => $date, 'revision' => $revision], array_values(array_unique(array_filter($dates))));
foreach (array_chunk($rows, 500) as $chunk) {
$this->connection->table(self::TABLE)->upsert($chunk, ['occurred_on'], ['revision']);
}
}
public function pending(): array
{
return $this->connection->table(self::TABLE)->orderBy('occurred_on')->get()->map(fn ($row) => (array) $row)->all();
}
public function acknowledge(array $snapshot): void
{
foreach (array_chunk($snapshot, 100) as $chunk) {
// A write arriving during aggregation retains its newer revision for the next run.
$this->connection->table(self::TABLE)->where(function ($query) use ($chunk): void {
foreach ($chunk as $row) {
$query->orWhere(fn ($match) => $match->where('occurred_on', $row['occurred_on'])->where('revision', $row['revision']));
}
})->delete();
}
}
}

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

@ -0,0 +1,133 @@
<?php
namespace Modules\EcommerceFinance\Services;
/**
* 退货记账归属:同一笔退货数据同时供财务端和厂家端展示。
*
* 备注中出现“记账物流”时,表示该部分不扣厂家、转物流记账;
* 当前终版数据由业务先调整厂家净数量/金额,备注保留原始数量及转运数量。
*/
class SupplierReturnAccounting
{
/** @param array<string,mixed> $row */
public function enrich(array $row): array
{
$remark = trim((string) ($row['remark'] ?? ''));
$title = trim((string) ($row['title'] ?? ''));
$isLogistics = preg_match('/记账物流|物流记账|物流.*不记账|不记账.*物流/u', $remark) === 1;
$isNoBookkeeping = ! $isLogistics && str_contains($remark, '不记账');
$target = $isLogistics ? '物流' : ($isNoBookkeeping ? '不记账' : '厂家');
$logisticsQty = 0.0;
$originalQty = 0.0;
if ($isLogistics) {
if (preg_match('/\*\s*(\d+(?:\.\d+)?)\s*记账物流/u', $remark, $matches) === 1
|| preg_match('/记账物流\s*[×xX*]?\s*(\d+(?:\.\d+)?)/u', $remark, $matches) === 1) {
$logisticsQty = (float) $matches[1];
}
if (preg_match('/原数量\s*(\d+(?:\.\d+)?)/u', $remark, $matches) === 1) {
$originalQty = (float) $matches[1];
}
if ($logisticsQty === 0.0 && $originalQty > 0) {
$logisticsQty = max(0.0, $originalQty - $this->number($row['qty'] ?? 0));
}
}
$qty = $this->number($row['qty'] ?? 0);
$unitPrice = $this->number($row['unit_price'] ?? $row['guess_unit_price'] ?? 0);
$amount = $this->number($row['amount'] ?? $row['guess_amount'] ?? 0);
if ($amount === 0.0 && $qty !== 0.0) {
$amount = $qty * $unitPrice;
}
if ($unitPrice === 0.0 && $qty !== 0.0) {
$unitPrice = $amount / $qty;
}
$supplierQty = $qty;
$supplierAmount = $amount;
$amountContainsLogistics = $originalQty > 0
&& $logisticsQty > 0
&& abs($amount - ($originalQty * $unitPrice)) < 0.01;
if ($amountContainsLogistics) {
$supplierQty = max(0.0, $originalQty - $logisticsQty);
$supplierAmount = max(0.0, $amount - ($logisticsQty * $unitPrice));
}
$row['_accounting_target'] = $target;
$row['_accounting_label'] = $isLogistics ? '转物流记账' : ($isNoBookkeeping ? '不记账' : '厂家扣减');
$row['_accounting_note'] = $isLogistics
? '不扣本厂家,转' . ($title !== '' ? $title : '物流') . '记账'
: ($isNoBookkeeping ? '本厂家不记账' : '按厂家退货扣减');
$row['_logistics_qty'] = $isLogistics ? $logisticsQty : 0.0;
$row['_logistics_amount'] = $isLogistics
? round($logisticsQty * $unitPrice, 2)
: 0.0;
$row['_logistics_company'] = $isLogistics ? $title : '';
$row['_supplier_qty'] = round($supplierQty, 2);
$row['_supplier_amount'] = round($supplierAmount, 2);
return $row;
}
/**
* @param list<array<string,mixed>> $rows
* @return array{count:int,supplier_rows:int,supplier_qty:float,supplier_amount:float,logistics_rows:int,logistics_qty:float,logistics_amount:float,logistics_companies:list<string>,no_bookkeeping_rows:int}
*/
public function summarize(array $rows): array
{
$summary = [
'count' => count($rows),
'supplier_rows' => 0,
'supplier_qty' => 0.0,
'supplier_amount' => 0.0,
'logistics_rows' => 0,
'logistics_qty' => 0.0,
'logistics_amount' => 0.0,
'logistics_companies' => [],
'no_bookkeeping_rows' => 0,
];
$companies = [];
foreach ($rows as $row) {
$row = $this->enrich($row);
$target = (string) $row['_accounting_target'];
if ($target === '不记账') {
$summary['no_bookkeeping_rows']++;
continue;
}
$summary['supplier_rows']++;
$summary['supplier_qty'] += (float) $row['_supplier_qty'];
$summary['supplier_amount'] += (float) $row['_supplier_amount'];
if ($target !== '物流') {
continue;
}
$summary['logistics_rows']++;
$summary['logistics_qty'] += (float) $row['_logistics_qty'];
$summary['logistics_amount'] += (float) $row['_logistics_amount'];
$company = trim((string) $row['_logistics_company']);
if ($company !== '') {
$companies[$company] = true;
}
}
$summary['supplier_qty'] = round($summary['supplier_qty'], 2);
$summary['supplier_amount'] = round($summary['supplier_amount'], 2);
$summary['logistics_qty'] = round($summary['logistics_qty'], 2);
$summary['logistics_amount'] = round($summary['logistics_amount'], 2);
$summary['logistics_companies'] = array_keys($companies);
return $summary;
}
private function number(mixed $value): float
{
$raw = trim((string) $value);
return is_numeric(str_replace(',', '', $raw)) ? (float) str_replace(',', '', $raw) : 0.0;
}
}

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

@ -0,0 +1,102 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
/** A small index into the existing raw mirror; the original JSON stays authoritative. */
class WorkOrderWaybillLookup
{
public const SOURCE = 'js_jst_work_order_info';
public const TABLE = 'work_order_waybill_lookup';
public const STATE = 'work_order_waybill_lookup_states';
private readonly ConnectionInterface $connection;
private ?bool $available = null;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
public function available(): bool
{
return $this->available ??= $this->connection->getSchemaBuilder()->hasTable(self::TABLE)
&& $this->connection->getSchemaBuilder()->hasTable(self::STATE);
}
public function ready(): bool
{
return $this->available() && $this->connection->table(self::STATE)->where('id', 1)->whereNotNull('completed_at')->exists();
}
/** Called inside the raw mirror's transaction, including changes to title/waybill. */
public function storeBatch(array $rows): void
{
if ($rows === [] || ! $this->available()) return;
$keys = [];
$values = [];
foreach ($rows as $row) {
$row = (array) $row;
$key = (string) $row['_mirror_key'];
$keys[] = $key;
if (($row['title'] ?? '') !== '查件') continue;
$fields = json_decode((string) ($row['businessFieldList'] ?? ''), true);
if (! is_array($fields)) continue;
$waybill = '';
// Same last-field-wins semantics as the existing settlement parser.
foreach ($fields as $field) {
if (is_array($field) && ($field['fieldId'] ?? null) === 'lIdField') {
$waybill = trim((string) ($field['value'] ?? ''));
}
}
if ($waybill !== '' && mb_strlen($waybill) <= 191) {
$values[] = ['mirror_key' => $key, 'waybill' => $waybill, 'source_id' => (int) ($row['Id'] ?? 0)];
}
}
// Remove renamed/invalid records too, so an old waybill never survives an update.
$this->connection->table(self::TABLE)->whereIn('mirror_key', $keys)->delete();
foreach (array_chunk($values, 500) as $chunk) {
$this->connection->table(self::TABLE)->insert($chunk);
}
}
public function prune(): void
{
if (! $this->available()) return;
$this->connection->table(self::TABLE)->whereNotExists(function ($query): void {
$query->selectRaw('1')->from(self::SOURCE)
->whereColumn(self::SOURCE . '._mirror_key', self::TABLE . '.mirror_key');
})->delete();
}
/** Resume a bounded keyset pass; production invokes this through the global sync lock. */
public function backfill(int $batchSize = 500, int $maxBatches = 2, int $pauseMs = 50): array
{
if (! $this->available()) throw new \RuntimeException('work_order_waybill_lookup_not_migrated');
$state = $this->connection->table(self::STATE)->where('id', 1)->first();
if ($state?->completed_at) return ['complete' => true, 'rows' => 0, 'batches' => 0];
$cursor = $state?->cursor;
$rows = $batches = 0;
$batchSize = min(max(1, $batchSize), 1000);
do {
$batch = $this->connection->table(self::SOURCE)->when($cursor, fn ($query) => $query->where('_mirror_key', '>', $cursor))
->orderBy('_mirror_key')->limit($batchSize)->get(['_mirror_key', 'Id', 'title', 'businessFieldList']);
$complete = $batch->count() < $batchSize;
$this->connection->transaction(function () use ($batch, $complete, &$cursor): void {
$this->storeBatch($batch->all());
$cursor = $batch->last()?->_mirror_key ?? $cursor;
if ($complete) $this->prune();
$this->connection->table(self::STATE)->updateOrInsert(['id' => 1], [
'cursor' => $cursor, 'completed_at' => $complete ? now() : null, 'updated_at' => now(),
]);
});
$rows += $batch->count();
$batches++;
if (! $complete && $pauseMs > 0) usleep(min($pauseMs, 1000) * 1000);
} while (! $complete && ($maxBatches === 0 || $batches < $maxBatches));
return compact('complete', 'rows', 'batches');
}
}

View File

@ -81,6 +81,7 @@ class OrderDataPageData
// shop_name 保留单值形态,兼容只认单值的调用方;多选统一用 shop_names。
'shop_name' => $shopNames[0] ?? '',
'shop_names' => $shopNames,
'shop_names_exact' => ! empty($input['shop_names_exact']) || is_array($input['shop_name'] ?? null),
'sku_id' => $this->batchText($input['sku_id'] ?? null),
];
}

View File

@ -80,7 +80,7 @@ class PlantingLossPageData
$sync = $this->repository->syncStatus();
$shippingSync = $this->calculator->shippingStatus();
$empty = ['count' => 0, 'cost_missing' => 0] + array_fill_keys(self::SUM_KEYS, 0);
$summary = ['total' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
$summary = ['total' => 0, 'matched' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
$items = [];
$error = $this->syncError($sync);
@ -89,6 +89,7 @@ class PlantingLossPageData
$all = $this->calculator->rows($filters['month']);
foreach ($all as $row) {
$summary['total']++;
$summary['matched'] += ($row['shipped_at'] ?? '') !== '' ? 1 : 0;
$this->add($summary[$row['loss_result']], $row);
}
foreach ($this->rows($filters, $all) as $row) {
@ -100,7 +101,7 @@ class PlantingLossPageData
} catch (\Throwable $exception) {
report($exception);
$error = '种菜损耗暂时无法计算(工单镜像或发货数据读取失败),请稍后重试。';
$summary = ['total' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
$summary = ['total' => 0, 'matched' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
$items = [];
}
}

View File

@ -5,13 +5,14 @@ namespace Modules\EcommerceFinance\View;
use App\Http\Controllers\Business\PayablesFinalActions;
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\Services\SupplierReturnAccounting;
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
/**
* 「供应商对账」页面数据:
* - 财务端数据:按供应商汇总 + 行级差异(条码 + 单价 匹配,四类结果带结算口径)
* - 财务端数据:按供应商汇总 + 行级差异(采购单号 + 日期 + 条码匹配,四类结果带结算口径)
* - 厂家端数据:厂家上传的对账单(实时)与只读的退货明细
*/
class SupplierReconciliationPageData
@ -19,12 +20,19 @@ class SupplierReconciliationPageData
/** 厂家上传的对账数据表:每页行数(短表 + 翻页)。 */
private const PER_PAGE = 15;
/** 退货明细行较高:每页 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,
private readonly SupplierStatementMatcher $matcher,
private readonly SupplierReturnConfirmation $returnConfirmations,
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
private readonly SupplierReturnAccounting $returnAccounting,
) {
}
@ -85,6 +93,15 @@ class SupplierReconciliationPageData
foreach ($this->statements->uploadedSuppliers($companyId) as $name) {
$suppliers[$name] = true;
}
foreach ($this->store->listRows(
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
$companyId,
) as $row) {
$name = trim((string) ($row['supplier'] ?? ''));
if ($name !== '') {
$suppliers[$name] = true;
}
}
$supplierNames = array_keys($suppliers);
sort($supplierNames);
@ -95,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,
@ -113,6 +144,9 @@ class SupplierReconciliationPageData
'reconciler' => $reconciler,
'selectedSupplier' => $selected,
'detail' => $detail,
'financeReturnPanel' => $detail !== null
? $this->financeReturnPanel($selected, $companyId, $month, $reconciler)
: null,
'robot' => $selected !== '' ? $this->dingTalkNotifier->configuration($selected) : null,
'listUrl' => route('business.pages.show', array_filter([
'section' => 'supplier-reconciliation',
@ -144,11 +178,22 @@ class SupplierReconciliationPageData
foreach ($this->statements->uploadedSuppliers($companyId) as $name) {
$suppliers[$name] = true;
}
foreach ($this->store->listRows(
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
$companyId,
) as $row) {
$name = trim((string) ($row['supplier'] ?? ''));
if ($name !== '') {
$suppliers[$name] = true;
}
}
$supplierNames = array_keys($suppliers);
sort($supplierNames);
$selected = trim((string) request()->query('supplier', ''));
if ($selected === '' || ! in_array($selected, $supplierNames, true)) {
$supplierQuery = trim((string) request()->query('supplier', ''));
$selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : '';
$supplierNotFound = $supplierQuery !== '' && $selected === '';
if ($supplierQuery === '') {
$selected = $supplierNames[0] ?? '';
}
@ -161,6 +206,8 @@ class SupplierReconciliationPageData
return [
'view' => 'business.pages.supplier-reconciliation.supplier-data',
'supplierNames' => $supplierNames,
'supplierQuery' => $supplierQuery,
'supplierNotFound' => $supplierNotFound,
'selectedSupplier' => $selected,
'uploads' => $selected !== '' ? $this->statements->uploads($selected, $companyId) : [],
'statementRows' => array_slice($statementRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE),
@ -231,6 +278,46 @@ class SupplierReconciliationPageData
return $this->detail($mine, $supplier, $companyId);
}
/**
* 财务端比对页的退货面板:与厂家端使用同一终版数据集,并保留月份/对账人筛选。
*
* @return array<string,mixed>
*/
private function financeReturnPanel(
string $supplier,
int $companyId,
string $month,
string $reconciler,
): array {
$allRows = $this->returnRows($supplier, $companyId);
$statuses = $this->returnConfirmations->statuses($supplier, $companyId);
$rows = [];
foreach ($allRows as $row) {
$rowMonth = substr(trim((string) ($row['send_time'] ?? '')), 0, 7);
if ($rowMonth === '') {
$rowMonth = substr(trim((string) ($row['summary_month'] ?? '')), 0, 7);
}
if ($month !== '' && $rowMonth !== $month) {
continue;
}
if ($reconciler !== '' && trim((string) ($row['reconciler'] ?? '')) !== $reconciler) {
continue;
}
$row['_status'] = $statuses[(string) ($row['_action_key'] ?? '')] ?? '';
$rows[] = $row;
}
return $this->returnConfirmationPanel(
$supplier,
$rows,
$this->returnFilters([]),
count($allRows),
'finance_return_page',
);
}
/**
* @param array<string,mixed> $input
* @return array{returnPanel:array<string,mixed>,returnWarehouses:list<string>}
@ -268,36 +355,52 @@ class SupplierReconciliationPageData
* @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters
* @return array<string,mixed>
*/
private function returnConfirmationPanel(string $supplier, array $returnRows, array $filters, int $allRows): array
private function returnConfirmationPanel(
string $supplier,
array $returnRows,
array $filters,
int $allRows,
string $pageQueryKey = 'return_page',
): array
{
$total = 0.0;
$confirmedAmount = 0.0;
$disputedAmount = 0.0;
$returnQuantity = 0.0;
$confirmedQuantity = 0.0;
$disputedQuantity = 0.0;
$disputedCount = 0;
$confirmedCount = 0;
$rows = [];
foreach ($returnRows as $row) {
$row = $this->returnAccounting->enrich($row);
$key = (string) ($row['_action_key'] ?? '');
$deduction = $this->returnDeduction($row);
$deduction = $row['_accounting_target'] === '不记账'
? 0.0
: (float) $row['_supplier_amount'];
$quantity = (float) $row['_supplier_qty'];
$status = (string) ($row['_status'] ?? '');
$total += $deduction;
$returnQuantity += $quantity;
if ($status === SupplierReturnConfirmation::STATUS_CONFIRMED) {
$confirmedAmount += $deduction;
$confirmedQuantity += $quantity;
$confirmedCount++;
} elseif ($status === SupplierReturnConfirmation::STATUS_DISPUTED) {
$disputedAmount += $deduction;
$disputedQuantity += $quantity;
$disputedCount++;
}
$rows[] = ['_key' => $key, 'status' => $status, 'deduction' => $deduction] + $row;
}
// 全部退货行按导入顺序分页展示(每页 15 行),不做金额排序。
$perPage = self::PER_PAGE;
// 全部退货行按导入顺序分页展示(每页 10 行),不做金额排序。
$perPage = self::RETURN_PER_PAGE;
$pages = max((int) ceil(count($rows) / $perPage), 1);
$page = min(max((int) request()->query('return_page', 1), 1), $pages);
$page = min(max((int) request()->query($pageQueryKey, 1), 1), $pages);
$filterQuery = $this->returnFilterQuery($filters);
@ -310,29 +413,22 @@ class SupplierReconciliationPageData
'total_rows' => count($returnRows),
'all_rows' => $allRows,
'total_amount' => round($total, 2),
'return_qty' => round($returnQuantity, 2),
'confirmed_qty' => round($confirmedQuantity, 2),
'disputed_qty' => round($disputedQuantity, 2),
'pending_qty' => round(max($returnQuantity - $confirmedQuantity - $disputedQuantity, 0), 2),
'pending_amount' => round(max($total - $confirmedAmount - $disputedAmount, 0), 2),
'confirmed_count' => $confirmedCount,
'confirmed_amount' => round($confirmedAmount, 2),
'disputed_count' => $disputedCount,
'disputed_amount' => round($disputedAmount, 2),
'accounting_summary' => $this->returnAccounting->summarize($returnRows),
'filters' => $filters,
'filter_query' => $filterQuery,
'is_filtered' => $filterQuery !== [],
];
}
/** 扣减金额:金额为空时用预估金额(与采购方记账口径一致)。 */
private function returnDeduction(array $row): float
{
$amount = trim((string) ($row['amount'] ?? ''));
if ($amount !== '' && is_numeric(str_replace(',', '', $amount))) {
return (float) str_replace(',', '', $amount);
}
$guess = trim((string) ($row['guess_amount'] ?? ''));
return is_numeric(str_replace(',', '', $guess)) ? (float) str_replace(',', '', $guess) : 0.0;
}
/** @return array<string,mixed> */
private function detail(array $mine, string $supplier, int $companyId): array
{

View File

@ -13,7 +13,7 @@
@endphp
<div class="pw-page" data-preference-key="planting-loss:{{ company_id() }}:{{ optional(user())->id }}">
<div class="pw-heading">
<p>{{ $plantingFilters['month'] }} 月种菜报销明细(完成时间 {{ substr($plantingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingWindow['to'])->subDay()->format('Y-m-d') }})的线上订单号,匹配 {{ $shippingLabel }} 发货数据(发货时间 {{ substr($plantingShippingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingShippingWindow['to'])->subDay()->format('Y-m-d') }})中的交易单号。匹配到的单中,任务备注或卖家备注写了“拍A发B / 拍A发A”的、以及只发了信誉订单虚拟条码/透明挂钩的不扣款,其余按发货商品成本价×数量+该单运费扣款。</p>
<p>{{ $plantingFilters['month'] }} 月种菜报销明细(完成时间 {{ substr($plantingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingWindow['to'])->subDay()->format('Y-m-d') }})的线上订单号,匹配 {{ $shippingLabel }} 发货数据(发货时间 {{ substr($plantingShippingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingShippingWindow['to'])->subDay()->format('Y-m-d') }})中的交易单号。任务备注或卖家备注写了“拍A发B / 拍A发A”的(不论是否匹配到发货)、以及匹配到的单中只发了信誉订单虚拟条码/透明挂钩的不扣款,其余按发货商品成本价×数量+该单运费扣款。</p>
<div class="pw-tags">
<span class="pw-tag {{ $plantingSync['code'] === 'ready' ? '' : 'warn' }}">{{ $syncLabels[$plantingSync['code']] ?? $plantingSync['code'] }}@if($plantingSync['last_succeeded_at']) · {{ \Illuminate\Support\Str::of($plantingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span>
<span class="pw-tag {{ ($plantingShippingSync['code'] ?? '') === 'succeeded' ? '' : 'warn' }}">{{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}@if($plantingShippingSync['last_succeeded_at'] ?? null) · {{ \Illuminate\Support\Str::of($plantingShippingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span>
@ -31,18 +31,18 @@
<button type="submit" class="pw-primary">查询</button>
<a class="pw-button" href="{{ request()->url() }}">重置</a>
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-loss.export', $exportQuery) }}">导出当前筛选</a>
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-loss.export', ['month' => $plantingFilters['month'], 'result' => Calculator::RESULT_EXEMPT]) }}">导出拍A发B(发运营)</a>
<a class="pw-button" href="{{ route('business.planting-data.planting-loss.export', ['month' => $plantingFilters['month'], 'result' => Calculator::RESULT_EXEMPT, 'format' => 'reimbursement']) }}" title="按种菜报销字段导出当月全部拍A发B/拍A发A">导出拍A发B(发运营)</a>
<details class="pw-column-settings"><summary class="pw-button">列设置</summary><div class="pw-column-menu"><p>勾选显示,拖动调整顺序</p><div data-pw-column-list>@foreach($columns as $key => $label)<label draggable="true" data-column-key="{{ $key }}"><input type="checkbox" value="{{ $key }}" checked> {{ $label }}</label>@endforeach</div><button type="button" class="pw-button" data-pw-reset>恢复默认</button><span data-pw-column-message role="status"></span></div></details>
</form>
@php $deduct = $plantingSummary[Calculator::RESULT_DEDUCT]; $exempt = $plantingSummary[Calculator::RESULT_EXEMPT]; $free = $plantingSummary[Calculator::RESULT_FREE_GOODS]; $unmatched = $plantingSummary[Calculator::RESULT_UNMATCHED]; @endphp
<div class="pw-metrics">
<div><span>当月种菜报销</span><strong>{{ number_format($plantingSummary['total']) }}</strong><small>匹配到发货 {{ number_format($deduct['count'] + $exempt['count'] + $free['count']) }} 单</small></div>
<div><span>当月种菜报销</span><strong>{{ number_format($plantingSummary['total']) }}</strong><small>匹配到发货 {{ number_format($plantingSummary['matched']) }} 单</small></div>
<a href="{{ $resultUrl(Calculator::RESULT_DEDUCT) }}" class="is-key {{ $plantingFilters['result'] === Calculator::RESULT_DEDUCT ? 'is-active' : '' }}"><span>扣款金额(元)</span><strong>{{ $money($deduct['deduction_amount']) }}</strong><small>扣款 {{ number_format($deduct['count']) }} 单@if($deduct['cost_missing'] > 0) · <b class="pw-warn-text">{{ number_format($deduct['cost_missing']) }} 单成本价/运费缺失</b>@endif</small></a>
<a href="{{ $resultUrl(Calculator::RESULT_EXEMPT) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_EXEMPT ? 'is-active' : '' }}"><span>不扣款(拍A发B/拍A发A)</span><strong>{{ number_format($exempt['count']) }}</strong><small>需发运营 · 最终报销 {{ $money($exempt['reimbursement_amount']) }}</small></a>
<a href="{{ $resultUrl(Calculator::RESULT_FREE_GOODS) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_FREE_GOODS ? 'is-active' : '' }}"><span>不扣款(虚拟条码/透明挂钩)</span><strong>{{ number_format($free['count']) }}</strong><small>最终报销 {{ $money($free['reimbursement_amount']) }}</small></a>
<a href="{{ $resultUrl(Calculator::RESULT_UNMATCHED) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_UNMATCHED ? 'is-active' : '' }}"><span>未匹配发货(不计入)</span><strong>{{ number_format($unmatched['count']) }}</strong><small>最终报销 {{ $money($unmatched['reimbursement_amount']) }}</small></a>
</div>
<details class="pw-rules"><summary>计算口径</summary><p>① 取该月种菜报销明细:工单类型“种菜报销”、状态“已完成”,完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59(与「种菜报销」页同一口径)。② 用线上订单号(交易单号)精确匹配「快递应付 → 发货数据」中发货时间在上月 1 日至本月末的交易单号,匹配到的保留,同一单多个包裹/商品合并为一行。③ 匹配到的单中,任务备注或卖家备注写了“拍A发B”或“拍A发A”(不区分全半角、大小写和空格)的不做扣款,单独列出发给运营。④ 实际只发了“信誉订单虚拟条码”“透明挂钩”这类商品的不扣款;同一单里这类商品也不计入扣款。⑤ 其余匹配到的为扣款项:扣款金额 = Σ 发货条形码(商品编码)在「普通商品资料」中的成本价 × 发货数量 + 该订单在「发货数据」中的运费合计;普通商品资料里没有成本价(空或 0)的商品、发货数据里运费为空的订单分别标为“成本价缺失”“运费缺失”,缺失部分不计入扣款金额,补齐后自动重算。未匹配到发货的单不计入种菜损耗,仅供核对。</p></details>
<details class="pw-rules"><summary>计算口径</summary><p>① 取该月种菜报销明细:工单类型“种菜报销”、状态“已完成”,完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59(与「种菜报销」页同一口径)。② 用线上订单号(交易单号)精确匹配「快递应付 → 发货数据」中发货时间在上月 1 日至本月末的交易单号,匹配到的保留,同一单多个包裹/商品合并为一行。③ 任务备注或卖家备注写了“拍A发B”或“拍A发A”(不区分全半角、大小写和空格)的不做扣款,不论是否匹配到发货都单独列出,「导出拍A发B(发运营)」按种菜报销字段导出。④ 发货数据里该交易单号下出现“信誉订单虚拟条码”(X00006/X00002)或透明挂钩(条形码 GG 开头)的整单不扣款(同单号下的其它商品视为合并发货的别的订单);只发了“无实物”商品的也不扣款。⑤ 其余匹配到的为扣款项:扣款金额 = Σ 发货条形码(商品编码)在「普通商品资料」中的成本价 × 发货数量 + 该订单在「发货数据」中的运费合计;普通商品资料里没有成本价(空或 0)的商品、发货数据里运费为空的订单分别标为“成本价缺失”“运费缺失”,缺失部分不计入扣款金额,补齐后自动重算。未匹配到发货的单不计入种菜损耗,仅供核对。</p></details>
@if(($plantingShippingSync['code'] ?? '') !== 'succeeded' && ! $plantingError)<div class="pw-warn">发货数据当前状态为“{{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}”{{ ($plantingShippingSync['warning'] ?? '') !== '' ? ':' . $plantingShippingSync['warning'] : '' }},匹配结果可能不完整。</div>@endif
@if($plantingError)<div class="pw-error" role="alert">{{ $plantingError }}</div>@endif
<section class="pw-panel" aria-label="种菜损耗明细">

View File

@ -249,7 +249,7 @@
<a class="settlement-button" href="{{ route('business.courier-surcharge.settlement.import-template') }}"><span class="material-icons-outlined" aria-hidden="true">download</span>下载导入模板</a>
</div>
<div class="settlement-import-meta" role="status"><span>当前筛选结果:<strong>{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }}</strong> 条</span><span>数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif</span></div>
<span class="settlement-table-hint">首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回与系统的差异金额。</span>
<span class="settlement-table-hint">首行只需「单号」「附加费类型」:系统会按单号自动匹配快递,并按启用的报价核算应结金额;「金额」可选,填了会返回与系统的差异金额。</span>
</form>
<div class="settlement-table-surface" data-finance-table-surface>

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 的账单行,一行 = 一个发货仓库的一个商品;发货仓库取订单的聚水潭仓库名称,仓库简称查仓库映射表,
商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价,匹配不到时退回「线上代发价格」,
商品成本 = 商品数量 × 商品单价。
拆单子单重复列出的同一批商品只算一次,同一单分多仓发货的按子单实际仓库分行;虚拟条码(X0000x)不计入。
只取收入金额有数据且不为 0 的账单行,「种菜金额」有数据(&gt;0)的账单行整条排除、不参与匹配;
一行 = 一个发货仓库的一个商品;发货仓库取订单的聚水潭仓库名称,
仓库简称按仓库映射表用「仓储方」匹配「仓库名含主仓」的那条取简称(没有主仓行时认同名仓库,
常州简尚家居有限公司对应映射表里的「配件单仓」行),
商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价;匹配不到时退回「线上代发价格」
(采购单价优先、其次钉钉代发单价),仍匹配不到时用「普通商品资料」里的商品成本价兜底;商品成本 = 商品数量 × 商品单价。
拆单子单重复列出的同一批商品只算一次,同一单分多仓发货的按子单实际仓库分行;虚拟条码(X0000x)不计入;
套餐/服务占位编码 TCXJ、FUWU 不取数(组合装里拆出的这两个编码同样剔除)。
定制配件这类明细行,聚水潭把配件件数写进了「商品数量」(例如数量 9524、金额 2000.04,实际单价 0.21),
乘成品的月加权/代发单价会放大几百倍;成本单价高于该行明细单价 3 倍时判为「数量与单价口径不一致」,成本留空、不计入合计。
仓库映射表里标记为「-不看」的仓库(次品 / 销退 / 虚拟 / 信誉订单 / 京东云仓等)默认不展示,
需要核对时可以勾选「包含『-不看』仓」。
页面默认展示全部数据:已匹配/未匹配商品单价的行、仓库映射表里标记为「-不看」的仓
(次品 / 销退 / 虚拟 / 信誉订单 / 京东云仓等)都纳入列表与统计。
</div>
<div class="product-cost-summary">
@ -130,9 +126,9 @@
<div class="item {{ ($summary['missing_price_rows'] ?? 0) > 0 ? 'is-warn' : '' }}"><span>未匹配商品单价行数</span><b>{{ number_format((int) ($summary['missing_price_rows'] ?? 0)) }}</b></div>
<div class="item"><span>未匹配单价的商品数量</span><b>{{ $qty($summary['missing_price_quantity'] ?? null) }}</b></div>
<div class="item"><span>其中用线上代发价格兜底</span><b>{{ number_format((int) ($summary['fallback_price_rows'] ?? 0)) }}</b></div>
<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

@ -739,6 +739,86 @@
display: none;
}
/* 业绩明细:把长口径说明拆成可扫描的规则分组,完整原文默认折叠 */
.receipt-method-guide {
margin: 0 0 1rem;
padding: .9rem 1rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: .75rem;
background: #fff;
}
.receipt-method-guide__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: .7rem 1.5rem;
}
.receipt-method-guide__section {
min-width: 0;
}
.receipt-method-guide__section h3 {
margin: 0 0 .3rem;
color: var(--vip-text, #171717);
font-size: .75rem;
font-weight: 600;
line-height: 1.4;
}
.receipt-method-guide__section ul {
display: grid;
gap: .12rem;
margin: 0;
padding-left: 1rem;
color: var(--vip-text-muted, #595959);
font-size: .75rem;
line-height: 1.55;
}
.receipt-method-guide__formula {
color: var(--vip-text, #171717);
font-weight: 600;
}
.receipt-method-guide__chips {
display: flex;
flex-wrap: wrap;
gap: .3rem;
}
.receipt-method-guide__chip {
display: inline-flex;
align-items: center;
padding: .08rem .4rem;
border: 1px solid #e8dfcd;
border-radius: .3rem;
background: #fffdf8;
color: var(--vip-text-muted, #595959);
font-size: .6875rem;
line-height: 1.5;
}
.receipt-method-guide__details {
margin-top: .7rem;
padding-top: .65rem;
border-top: 1px solid #eee7da;
}
.receipt-method-guide__details summary {
color: var(--vip-content-accent-dark, #856632);
cursor: pointer;
font-size: .75rem;
font-weight: 600;
}
.receipt-method-guide__details p {
margin: .55rem 0 0;
color: var(--vip-text-muted, #595959);
font-size: .75rem;
line-height: 1.7;
}
/* 业绩明细:店铺收款匹配统计条 */
.receipt-match-summary {
display: flex;
@ -774,6 +854,18 @@
gap: .35rem 1.25rem;
}
.receipt-match-summary__items--amounts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .4rem 1.75rem;
}
@media (max-width: 900px) {
.receipt-match-summary__items--amounts {
grid-template-columns: 1fr;
}
}
.receipt-match-summary__item--lead strong {
font-size: 1.25rem;
color: #b80000;
@ -797,6 +889,25 @@
color: var(--vip-text, #1f2937);
}
.receipt-match-summary__item--amount {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline;
gap: .75rem;
}
.receipt-match-summary__item--amount strong {
text-align: right;
white-space: nowrap;
}
.receipt-match-summary__value-unit {
margin-left: .15em;
color: #8a8377;
font-size: .75rem;
font-weight: 500;
}
.receipt-match-summary__item.is-ok strong {
color: var(--vip-success, #137a3c);
}
@ -1076,7 +1187,7 @@
<span class="material-icons-outlined" aria-hidden="true">{{ $directory['icon'] ?? 'table_view' }}</span>
<div>
<strong>{{ $page['title'] ?? $section['title'] }} · 字段结构</strong>
@if (!empty($directory['description']))
@if (!empty($directory['description']) && ($page['slug'] ?? '') !== 'performance-details')
<p class="directory-table-description">{{ $directory['description'] }}</p>
@endif
</div>
@ -1118,6 +1229,57 @@
@endif
@endif
@if (($page['slug'] ?? '') === 'performance-details' && !empty($directory['description']))
<section class="receipt-method-guide" aria-label="业绩明细指标口径">
<div class="receipt-method-guide__grid">
<div class="receipt-method-guide__section">
<h3>匹配口径</h3>
<ul>
<li>一行 = 一个店铺 + 一个线上订单号</li>
<li>商品金额:主单 → 原单行 → 全部行</li>
<li>合单/拆单只挂一次,排除 X0000x 虚拟条码</li>
<li>订单实付取订单级实付,再扣虚拟条码行实付</li>
</ul>
</div>
<div class="receipt-method-guide__section">
<h3>金额字段</h3>
<ul>
<li>收入:总账备注 = 订单收入</li>
<li>优惠/冲减:分别取优惠收入、冲减收入,不计入收入</li>
<li>退款:单独列示,不参与收入和差额</li>
<li>种菜:种菜订单的收入子集,不参与差额</li>
</ul>
</div>
<div class="receipt-method-guide__section">
<h3>差额判定</h3>
<ul>
<li><span class="receipt-method-guide__formula">差额 = 收入 + 优惠 + 冲减 − 订单实付</span></li>
<li>未匹配时按订单实付 = 0 计入差额</li>
<li>原因按顺序命中;无明确原因时标「差额待核实」</li>
<li>卖家实收 / 应付已付能对平的行,差额按 0 计</li>
</ul>
</div>
<div class="receipt-method-guide__section">
<h3>匹配原因</h3>
<div class="receipt-method-guide__chips">
<span class="receipt-method-guide__chip">账单无单号</span>
<span class="receipt-method-guide__chip">单号格式异常</span>
<span class="receipt-method-guide__chip">订单表无此单</span>
<span class="receipt-method-guide__chip">订单无实付金额</span>
<span class="receipt-method-guide__chip">无收款账单</span>
<span class="receipt-method-guide__chip">1688 采销收款</span>
<span class="receipt-method-guide__chip">店铺名不一致</span>
<span class="receipt-method-guide__chip">同单多笔收款</span>
</div>
</div>
</div>
<details class="receipt-method-guide__details">
<summary>查看完整字段与差额判定规则</summary>
<p>{{ $directory['description'] }}</p>
</details>
</section>
@endif
@if (($page['slug'] ?? '') === 'performance-details' && !empty($receiptStats))
{{-- 匹配统计:账单笔数、成功/未匹配笔数、双方收入合计核对结果 --}}
<div class="receipt-match-summary" role="status" aria-label="店铺收款匹配统计">
@ -1134,15 +1296,15 @@
</div>
<div class="receipt-match-summary__group">
<div class="receipt-match-summary__title">金额构成</div>
<div class="receipt-match-summary__items">
<span class="receipt-match-summary__item"><em>账单收入合计</em><strong>{{ number_format((float) $receiptStats['income_total'], 2) }}</strong></span>
<span class="receipt-match-summary__item"><em>优惠金额合计</em><strong>{{ number_format((float) ($receiptStats['discount_total'] ?? 0), 2) }}</strong></span>
<span class="receipt-match-summary__item"><em>冲减金额合计</em><strong>{{ number_format((float) ($receiptStats['offset_total'] ?? 0), 2) }}</strong></span>
<span class="receipt-match-summary__item"><em>退款金额合计</em><strong>{{ number_format((float) ($receiptStats['refund_total'] ?? 0), 2) }}</strong></span>
<span class="receipt-match-summary__item"><em>种菜金额合计</em><strong>{{ number_format((float) ($receiptStats['planting_total'] ?? 0), 2) }}</strong></span>
<span class="receipt-match-summary__item"><em>订单商品金额合计</em><strong>{{ number_format((float) $receiptStats['goods_total'], 2) }}</strong></span>
<span class="receipt-match-summary__item"><em>订单实付金额合计</em><strong>{{ number_format((float) ($receiptStats['paid_total'] ?? 0), 2) }}</strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--lead"><em>差额合计</em><strong>{{ number_format((float) $receiptStats['difference_total'], 2) }}</strong></span>
<div class="receipt-match-summary__items receipt-match-summary__items--amounts">
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>账单收入合计</em><strong>{{ number_format((float) $receiptStats['income_total'] / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>订单商品金额合计</em><strong>{{ number_format((float) $receiptStats['goods_total'] / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>订单实付金额合计</em><strong>{{ number_format((float) ($receiptStats['paid_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>优惠金额合计</em><strong>{{ number_format((float) ($receiptStats['discount_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>冲减金额合计</em><strong>{{ number_format((float) ($receiptStats['offset_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>退款金额合计</em><strong>{{ number_format((float) ($receiptStats['refund_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>种菜金额合计</em><strong>{{ number_format((float) ($receiptStats['planting_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
<span class="receipt-match-summary__item receipt-match-summary__item--amount receipt-match-summary__item--lead"><em>差额合计</em><strong>{{ number_format((float) $receiptStats['difference_total'] / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
</div>
</div>
</div>
@ -1734,13 +1896,17 @@
}
function refresh() {
widgets.forEach(function (widget) { widget.setLoading(true); });
window.fetch(endpoint + '?' + currentParams().toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
credentials: 'same-origin'
}).then(function (response) {
return response.ok ? response.json() : null;
}).then(function (data) {
if (!data || !data.facets) { return; }
if (!data || !data.facets) {
widgets.forEach(function (widget) { widget.setLoading(false); });
return;
}
widgets.forEach(function (widget) {
var facet = data.facets[widget.root.getAttribute('data-name')];
if (!facet || !facet.values) { return; }
@ -1750,8 +1916,10 @@
});
widget.setOptions(options, facet.counts || {});
});
widgets.forEach(function (widget) { widget.setLoading(false); });
}).catch(function () {
// 联动统计失败时保留原有选项,不影响筛选本身。
widgets.forEach(function (widget) { widget.setLoading(false); });
});
}
@ -1780,7 +1948,8 @@
}
});
}
schedule();
// 首次加载立即取联动数量;只有勾选变化时才做 250ms 防抖。
refresh();
})();
</script>
<script>

View File

@ -119,6 +119,7 @@
<a href="{{ $finalRows->previousPageUrl() }}">上一页</a>
@endif
第 {{ $finalRows?->currentPage() ?? 1 }} 页 / 共 {{ $finalRows?->lastPage() ?? 1 }} 页
· 每页 {{ $finalRows?->perPage() ?? (in_array($finalDataset, ['payment-reconciliation', 'return-reconciliation'], true) ? 15 : 50) }} 条
@if ($finalRows?->hasMorePages())
<a href="{{ $finalRows->nextPageUrl() }}">下一页</a>
@endif

View File

@ -149,6 +149,7 @@
var valuesEl = root.querySelector('[data-role="values"]');
var candidates = (opts.options || []).slice();
var counts = opts.counts || {};
var countsLoading = false;
var selectedList = [];
var keyword = '';
var optsEl = null;
@ -163,7 +164,9 @@
function isSel(v) { return selectedList.indexOf(v) >= 0; }
function countText(v) {
var total = counts[v];
if (total === null || total === undefined) { return ''; }
if (total === null || total === undefined) {
return countsLoading ? '统计中…' : '';
}
return String(total).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
@ -243,8 +246,13 @@
setOptions: function (list, nextCounts) {
candidates = (list || []).slice();
if (nextCounts) { counts = nextCounts; }
countsLoading = false;
renderOptions();
sync();
},
setLoading: function (loading) {
countsLoading = Boolean(loading);
renderOptions();
}
};

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

@ -27,12 +27,18 @@
.supplier-reconciliation-page .sr-tag.is-extra { background: #FDF1E2; color: #B4650A; }
.supplier-reconciliation-page .sr-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
.supplier-reconciliation-page .sr-tag.is-missing { background: #EAF2F9; color: #2F6FA8; }
.supplier-reconciliation-page .sr-tag.is-logistics { background: #F7F0DE; color: #856632; }
.supplier-reconciliation-page .sr-tag.is-no-bookkeeping { background: #EFEAE0; color: #595959; }
.supplier-reconciliation-page tr.is-extra td,
.supplier-reconciliation-page tr.is-mismatch td { background: #FFFDF7; }
.supplier-reconciliation-page tr.is-logistics td { background: #FFFDF7; }
.supplier-reconciliation-page .sr-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
.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; }
@ -79,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>
@ -94,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>
@ -123,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"
@ -239,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>
@ -253,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>
@ -269,7 +299,7 @@
@php
$tagClass = match ($row['type']) {
'厂家多' => 'is-extra',
'金额不一致' => 'is-mismatch',
'数量/金额不一致' => 'is-mismatch',
'厂家少' => 'is-missing',
default => 'is-match',
};
@ -281,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>
@ -311,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'])
@ -327,11 +375,152 @@
</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
<div class="sr-card">
<div class="sr-toolbar">
<h2>退货扣减 · 财务与厂家同步</h2>
<span class="sr-muted" style="font-size:.75rem">
直接读取「应付款终版 → 退货对账」同一数据集;修改退货数量、备注、标题后,财务端和厂家门户同时更新。
</span>
</div>
<div class="sr-summary">
<div class="sr-summary-cell"><span>退货行数</span><b>{{ number_format($returnAccounting['count']) }}</b></div>
<div class="sr-summary-cell"><span>厂家净扣减</span><b>¥{{ number_format($returnAccounting['supplier_amount'], 2) }}</b></div>
<div class="sr-summary-cell"><span>转物流记账</span><b>{{ number_format($returnAccounting['logistics_qty'], 2) }} 件</b></div>
<div class="sr-summary-cell"><span>转物流金额</span><b>¥{{ number_format($returnAccounting['logistics_amount'], 2) }}</b></div>
<div class="sr-summary-cell"><span>不记账行数</span><b>{{ number_format($returnAccounting['no_bookkeeping_rows']) }}</b></div>
</div>
@if ($returnAccounting['logistics_rows'] > 0)
<p class="sr-hint">
已识别 {{ number_format($returnAccounting['logistics_rows']) }} 行“不扣本厂家、转物流记账”
@if (! empty($returnAccounting['logistics_companies']))
,物流公司:{{ implode('、', $returnAccounting['logistics_companies']) }}
@endif
。厂家端显示相同备注和“转物流记账”标记,厂家扣减金额按调整后的净数量/金额计算。
</p>
@endif
<div class="sr-scroll">
<table class="sr-return-table">
<thead>
<tr>
<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>
</tr>
</thead>
<tbody>
@forelse ($financeReturnPanel['rows'] as $row)
<tr @class(['is-logistics' => $row['_accounting_target'] === '物流'])>
<td>{{ $row['send_time'] ?? '—' }}</td>
<td>{{ $row['barcode'] ?? '—' }}</td>
<td>{{ $row['sku_name'] ?? '—' }}</td>
<td class="is-num">{{ $row['_accounting_target'] === '不记账' ? '—' : number_format($row['_supplier_qty'], 2) }}</td>
<td class="is-num">{{ $row['_accounting_target'] === '不记账' ? '—' : number_format($row['_supplier_amount'], 2) }}</td>
<td>
<span class="sr-tag {{ $row['_accounting_target'] === '物流' ? 'is-logistics' : ($row['_accounting_target'] === '不记账' ? 'is-no-bookkeeping' : 'is-match') }}">
{{ $row['_accounting_label'] }}
</span>
@if ($row['_accounting_note'] !== '按厂家退货扣减')
<div class="sr-muted" style="font-size:.7rem">{{ $row['_accounting_note'] }}</div>
@endif
</td>
<td class="is-num">{{ $row['_logistics_qty'] > 0 ? number_format($row['_logistics_qty'], 2) : '—' }}</td>
<td class="is-num">{{ $row['_logistics_amount'] > 0 ? number_format($row['_logistics_amount'], 2) : '—' }}</td>
<td>{{ $row['_logistics_company'] !== '' ? $row['_logistics_company'] : '—' }}</td>
<td>{{ $row['remark'] ?? '—' }}</td>
<td>{{ $row['express_no'] ?? '—' }}</td>
<td>{{ $row['reconciler'] ?? '—' }}</td>
<td>
@if ($row['status'] === 'confirmed')
<span class="sr-tag is-match">已确认</span>
@elseif ($row['status'] === 'disputed')
<span class="sr-tag is-mismatch">异议中</span>
@else
<span class="sr-muted">待确认</span>
@endif
</td>
</tr>
@empty
<tr><td colspan="13"><div class="sr-empty">当前月份和对账人筛选下没有退货数据。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
@if ($financeReturnPanel['pages'] > 1)
@php
$returnPageCount = $financeReturnPanel['pages'];
$returnPageCurrent = $financeReturnPanel['page'];
$returnPageStart = max(1, min($returnPageCurrent - 4, $returnPageCount - 8));
$returnPageEnd = min($returnPageCount, $returnPageStart + 8);
$returnPageUrl = fn (int $number): string => route('business.pages.show', array_filter([
'section' => $section['slug'],
'page' => $page['slug'],
'supplier' => $selectedSupplier,
'month' => $month,
'reconciler' => $reconciler,
'finance_return_page' => $number,
], static fn (mixed $value): bool => $value !== null && $value !== ''));
@endphp
<div class="sr-pagination">
<span>第 {{ $returnPageCurrent }} / {{ $returnPageCount }} 页(每页 {{ $financeReturnPanel['per_page'] }} 行)</span>
@if ($returnPageCurrent > 1)
<a class="sr-button is-small" href="{{ $returnPageUrl($returnPageCurrent - 1) }}">上一页</a>
@endif
@for ($number = $returnPageStart; $number <= $returnPageEnd; $number++)
<a class="sr-button is-small {{ $number === $returnPageCurrent ? 'is-primary' : '' }}"
@if ($number === $returnPageCurrent) aria-current="page" @endif
href="{{ $returnPageUrl($number) }}">{{ $number }}</a>
@endfor
@if ($returnPageCurrent < $returnPageCount)
<a class="sr-button is-small" href="{{ $returnPageUrl($returnPageCurrent + 1) }}">下一页</a>
@endif
</div>
@endif
</div>
@endif
</div>

View File

@ -1,4 +1,11 @@
<div class="supplier-reconciliation-page">
@php
$formatReturnQty = static function (mixed $value): string {
$quantity = (float) $value;
return number_format($quantity, abs($quantity - round($quantity)) < 0.00001 ? 0 : 2);
};
@endphp
<style>
.supplier-reconciliation-page { display: grid; gap: 16px; }
.supplier-reconciliation-page .sr-card { min-width: 0; max-width: 100%; padding: 16px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: var(--vip-surface, #fff); display: grid; gap: 12px; }
@ -30,14 +37,27 @@
}
.supplier-reconciliation-page .sr-return-banner .material-icons-outlined { color: var(--vip-content-accent-dark, #856632); font-size: 18px; }
.supplier-reconciliation-page .sr-return-status { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--vip-content-muted, #595959); font-size: .75rem; }
.supplier-reconciliation-page .sr-return-summary { display: grid; grid-template-columns: repeat(5, minmax(145px, 1fr)); gap: 12px; }
.supplier-reconciliation-page .sr-return-stat { min-width: 0; padding: 16px 18px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 10px; background: #fff; display: grid; gap: 8px; }
.supplier-reconciliation-page .sr-return-stat.is-total { border-color: #E4DCCB; background: var(--vip-content-accent-soft, #F7F0DE); }
.supplier-reconciliation-page .sr-return-stat > span { color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
.supplier-reconciliation-page .sr-return-stat strong { color: var(--vip-content-ink, #171717); font-size: 1.375rem; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; white-space: nowrap; }
.supplier-reconciliation-page .sr-return-stat strong small { margin-left: 3px; color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 500; }
.supplier-reconciliation-page .sr-return-stat em { color: var(--vip-content-muted, #595959); font-size: .8125rem; font-style: normal; font-variant-numeric: tabular-nums; }
.supplier-reconciliation-page .sr-return-table { min-width: 1420px; }
.supplier-reconciliation-page .sr-return-scroll { max-height: min(62vh, 560px); overflow: auto; scrollbar-gutter: stable; }
.supplier-reconciliation-page .sr-return-scroll thead th { position: sticky; top: 0; z-index: 4; box-shadow: 0 1px 0 var(--vip-border, #D8D2C7); }
.supplier-reconciliation-page .sr-filter { display: grid; grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(135px, .8fr)) minmax(150px, 1fr) minmax(125px, .75fr); gap: 10px 12px; align-items: end; padding: 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; }
.supplier-reconciliation-page .sr-filter-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; }
.supplier-reconciliation-page .sr-date-presets { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; grid-column: 1 / -1; margin-top: -2px; }
.supplier-reconciliation-page .sr-date-presets > span { margin-right: 2px; color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
@media (max-width: 960px) {
.supplier-reconciliation-page .sr-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.supplier-reconciliation-page .sr-return-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.supplier-reconciliation-page .sr-filter { grid-template-columns: minmax(0, 1fr); }
.supplier-reconciliation-page .sr-return-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.supplier-reconciliation-page .sr-type-tag {
display: inline-flex; align-items: center; height: 22px; padding: 0 8px;
@ -46,6 +66,9 @@
.supplier-reconciliation-page .sr-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 4px; font-size: .75rem; white-space: nowrap; }
.supplier-reconciliation-page .sr-tag.is-match { background: #ECF4EC; color: #3F7A46; }
.supplier-reconciliation-page .sr-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
.supplier-reconciliation-page .sr-tag.is-logistics { margin-left: 4px; background: #F7F0DE; color: #856632; }
.supplier-reconciliation-page .sr-tag.is-no-bookkeeping { margin-left: 4px; background: #EFEAE0; color: #595959; }
.supplier-reconciliation-page tr.is-logistics td { background: #FFFDF7; }
.supplier-reconciliation-page .sr-inline-form { display: inline-flex; }
.supplier-reconciliation-page .sr-return-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.supplier-reconciliation-page .sr-return-panel > .sr-return-actions { gap: 12px; }
@ -60,18 +83,31 @@
<div class="sr-card">
<h2>厂家对账单 · 上传与实时数据</h2>
<form method="GET" class="sr-toolbar" aria-label="选择厂家">
<label class="sr-field"><span>厂家</span>
<select name="supplier" onchange="this.form.submit()">
<form method="GET" class="sr-toolbar" aria-label="查询厂家">
<label class="sr-field"><span>厂家名称</span>
<input type="search"
name="supplier"
value="{{ $supplierQuery !== '' ? $supplierQuery : $selectedSupplier }}"
list="supplier-name-options"
placeholder="输入厂家名称搜索"
autocomplete="off"
required>
<datalist id="supplier-name-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>
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
</form>
@if ($supplierNotFound)
<div class="biz-alert biz-alert-error" role="alert">
未找到名为「{{ $supplierQuery }}」的厂家,请输入完整名称并从搜索建议中选择。
</div>
@endif
<p class="sr-hint">
厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号、单据编号(采购单号)可以留空;
厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号可以留空;采购单号建议填写(用于按采购单号+日期+条码核对,未填时仅在日期+条码唯一匹配时自动匹配);
单价或金额留空时按数量自动补齐(都留空按 0,未定价行可直接导出上传);其余列必填;
不符合模板或数值格式的整份失败并提示具体行号。厂家上传成功后,下面的数据与「财务端数据」的比对结果立即更新。
</p>
@ -88,28 +124,6 @@
</div>
@if ($selectedSupplier !== '')
<div class="sr-card">
<h2>{{ $selectedSupplier }} · 上传记录</h2>
<div class="sr-scroll">
<table>
<thead>
<tr><th scope="col">上传时间</th><th scope="col">文件名</th><th scope="col">行数</th></tr>
</thead>
<tbody>
@forelse ($uploads as $upload)
<tr>
<td class="sr-muted">{{ $upload['uploaded_at'] ?? '—' }}</td>
<td>{{ $upload['filename'] ?? '—' }}</td>
<td class="is-num">{{ number_format((int) ($upload['rows'] ?? 0)) }}</td>
</tr>
@empty
<tr><td colspan="3"><div class="sr-empty">该厂家还没有上传过对账单。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="sr-card">
<h2>{{ $selectedSupplier }} · 上传的对账数据(共 {{ number_format($statementTotal) }} 行)</h2>
<div class="sr-scroll">
@ -178,28 +192,56 @@
</div>
<div class="sr-card sr-return-panel">
<div class="sr-return-summary" aria-label="退货汇总">
<div class="sr-return-stat is-total">
<span>退货件数</span>
<strong>{{ $formatReturnQty($returnPanel['return_qty']) }}<small>件</small></strong>
</div>
<div class="sr-return-stat is-total">
<span>退货金额</span>
<strong>¥{{ number_format($returnPanel['total_amount'], 2) }}</strong>
</div>
<div class="sr-return-stat">
<span>已确认</span>
<strong>{{ $formatReturnQty($returnPanel['confirmed_qty']) }}<small>件</small></strong>
<em>¥{{ number_format($returnPanel['confirmed_amount'], 2) }}</em>
</div>
<div class="sr-return-stat">
<span>异议中</span>
<strong>{{ $formatReturnQty($returnPanel['disputed_qty']) }}<small>件</small></strong>
<em>¥{{ number_format($returnPanel['disputed_amount'], 2) }}</em>
</div>
<div class="sr-return-stat">
<span>待确认</span>
<strong>{{ $formatReturnQty($returnPanel['pending_qty']) }}<small>件</small></strong>
<em>¥{{ number_format($returnPanel['pending_amount'], 2) }}</em>
</div>
@if ($returnPanel['accounting_summary']['logistics_rows'] > 0)
<div class="sr-return-stat">
<span>转物流记账</span>
<strong>{{ $formatReturnQty($returnPanel['accounting_summary']['logistics_qty']) }}<small>件</small></strong>
<em>¥{{ number_format($returnPanel['accounting_summary']['logistics_amount'], 2) }}</em>
</div>
@endif
</div>
<div class="sr-return-banner" role="note">
<span class="material-icons-outlined" aria-hidden="true">info</span>
<span>
<strong>① 退货扣减确认</strong>
@if ($returnPanel['is_filtered'])
(筛选出 {{ number_format($returnPanel['total_rows']) }} 行,全部 {{ number_format($returnPanel['all_rows']) }} 行,筛选结果合计扣减 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
(当前筛选 {{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
如当前筛选结果核对无误,可点「全部确认当前筛选」;其他退货行不受影响。
@else
(共 {{ number_format($returnPanel['total_rows']) }} 行,合计扣减 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
(共 {{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
核对无误请点「全部确认无异议」;个别有问题的行,在该行单独点「异议」,复核通过前该行暂不扣减。
@endif
</span>
</div>
<div class="sr-return-status">
<span>已确认 {{ $returnPanel['confirmed_count'] }} 行(¥{{ number_format($returnPanel['confirmed_amount'], 2) }})</span>
<span>· 异议 {{ $returnPanel['disputed_count'] }} 行(暂不扣减 ¥{{ number_format($returnPanel['disputed_amount'], 2) }})</span>
<span>· 待确认 {{ max($returnPanel['total_rows'] - $returnPanel['confirmed_count'] - $returnPanel['disputed_count'], 0) }} 行</span>
</div>
<form method="GET"
action="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"
class="sr-filter"
aria-label="查询退货明细">
aria-label="查询退货明细"
data-return-filter-form>
<input type="hidden" name="supplier" value="{{ $selectedSupplier }}">
<label class="sr-field"><span>关键字</span>
<input type="text" name="return_keyword"
@ -208,10 +250,18 @@
placeholder="条形码 / 商品名称 / 供应商条码 / 配货单号 / 快递单号">
</label>
<label class="sr-field"><span>发货日期起</span>
<input type="date" name="return_date_from" value="{{ $returnPanel['filters']['date_from'] }}">
<input type="date"
id="business-return-date-from"
name="return_date_from"
value="{{ $returnPanel['filters']['date_from'] }}"
title="点击选择开始日期">
</label>
<label class="sr-field"><span>发货日期止</span>
<input type="date" name="return_date_to" value="{{ $returnPanel['filters']['date_to'] }}">
<input type="date"
id="business-return-date-to"
name="return_date_to"
value="{{ $returnPanel['filters']['date_to'] }}"
title="点击选择结束日期">
</label>
<label class="sr-field"><span>仓库名称</span>
<select name="return_warehouse">
@ -229,17 +279,24 @@
<option value="disputed" @selected($returnPanel['filters']['status'] === 'disputed')>异议中</option>
</select>
</label>
<div class="sr-date-presets" role="group" aria-label="快捷发货日期">
<span>快捷日期</span>
<button type="button" class="sr-button is-small" data-return-date-range="last-7">近 7 天</button>
<button type="button" class="sr-button is-small" data-return-date-range="this-month">本月</button>
<button type="button" class="sr-button is-small" data-return-date-range="last-month">上月</button>
<button type="button" class="sr-button is-small" data-return-date-range="all">全部日期</button>
</div>
<div class="sr-filter-actions">
<button type="submit" class="sr-button is-primary">查询</button>
<a class="sr-button"
href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug'], 'supplier' => $selectedSupplier]) }}">重置</a>
<a class="sr-button"
href="{{ route('business.supplier-reconciliation.returns.export', array_merge(['supplier' => $selectedSupplier], $returnPanel['filter_query'])) }}">
导出当前筛选结果({{ number_format($returnPanel['total_rows']) }} 行)
导出当前筛选结果
</a>
</div>
</form>
<div class="sr-scroll">
<div class="sr-scroll sr-return-scroll">
<table class="sr-return-table">
<thead>
<tr>
@ -252,7 +309,7 @@
</thead>
<tbody>
@forelse ($returnPanel['rows'] as $row)
<tr>
<tr @class(['is-logistics' => $row['_accounting_target'] === '物流'])>
<td>{{ $row['send_time'] ?? '—' }}</td>
<td>{{ $row['supplier'] ?? '—' }}</td>
<td>{{ $row['barcode'] ?? '—' }}</td>
@ -261,7 +318,15 @@
<td class="is-num">{{ $row['unit_price'] ?? '—' }}</td>
<td class="is-num">{{ $row['amount'] ?? '—' }}</td>
<td>{{ $row['supplier_barcode'] ?? '—' }}</td>
<td>{{ $row['remark'] ?? '—' }}</td>
<td>
{{ $row['remark'] ?? '—' }}
@if ($row['_accounting_target'] !== '厂家')
<span class="sr-tag {{ $row['_accounting_target'] === '物流' ? 'is-logistics' : 'is-no-bookkeeping' }}">{{ $row['_accounting_label'] }}</span>
@endif
@if ($row['_accounting_note'] !== '按厂家退货扣减')
<div class="sr-muted" style="font-size:.7rem">{{ $row['_accounting_note'] }}</div>
@endif
</td>
<td>{{ $row['title'] ?? '—' }}</td>
<td>{{ $row['wms_pick_no'] ?? '—' }}</td>
<td>{{ $row['warehouse'] ?? '—' }}</td>
@ -299,9 +364,9 @@
<p class="sr-hint">
厂家端只展示核对退货所需的字段,左右滑动查看全部。
@if ($returnPanel['is_filtered'])
当前筛选出 {{ number_format($returnPanel['total_rows']) }} 行(全部 {{ number_format($returnPanel['all_rows']) }} 行),导出包含当前查询条件下的全部数据。
当前筛选结果:{{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }},导出包含当前查询条件下的全部数据。
@else
退货共 {{ number_format($returnPanel['total_rows']) }} 行,导出包含该厂家的全部退货数据。
退货共 {{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }},导出包含该厂家的全部退货数据。
@endif
按导入顺序展示,每页 {{ $returnPanel['per_page'] }} 行(第 {{ $returnPanel['page'] }} / {{ $returnPanel['pages'] }} 页)。
</p>
@ -357,4 +422,82 @@
</div>
</div>
@endif
@if ($selectedSupplier !== '')
<div class="sr-card">
<h2>{{ $selectedSupplier }} · 上传记录</h2>
<div class="sr-scroll">
<table>
<thead>
<tr><th scope="col">上传时间</th><th scope="col">文件名</th><th scope="col">行数</th></tr>
</thead>
<tbody>
@forelse ($uploads as $upload)
<tr>
<td class="sr-muted">{{ $upload['uploaded_at'] ?? '—' }}</td>
<td>{{ $upload['filename'] ?? '—' }}</td>
<td class="is-num">{{ number_format((int) ($upload['rows'] ?? 0)) }}</td>
</tr>
@empty
<tr><td colspan="3"><div class="sr-empty">该厂家还没有上传过对账单。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endif
<script>
(() => {
const form = document.querySelector('[data-return-filter-form]');
if (!form) {
return;
}
const from = form.querySelector('#business-return-date-from');
const to = form.querySelector('#business-return-date-to');
const iso = (date) => [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
[from, to].forEach((input) => {
input?.addEventListener('click', () => {
if (typeof input.showPicker === 'function') {
try {
input.showPicker();
} catch (error) {
// 浏览器已在日期面板中打开时忽略。
}
}
});
});
form.querySelectorAll('[data-return-date-range]').forEach((button) => {
button.addEventListener('click', () => {
const today = new Date();
const kind = button.dataset.returnDateRange;
if (kind === 'all') {
from.value = '';
to.value = '';
} else if (kind === 'this-month') {
from.value = iso(new Date(today.getFullYear(), today.getMonth(), 1));
to.value = iso(today);
} else if (kind === 'last-month') {
from.value = iso(new Date(today.getFullYear(), today.getMonth() - 1, 1));
to.value = iso(new Date(today.getFullYear(), today.getMonth(), 0));
} else if (kind === 'last-7') {
const start = new Date(today);
start.setDate(start.getDate() - 6);
from.value = iso(start);
to.value = iso(today);
}
form.submit();
});
});
})();
</script>
</div>

View File

@ -3,6 +3,13 @@
<x-slot name="content">
<div class="supplier-portal-page">
@php
$formatReturnQty = static function (mixed $value): string {
$quantity = (float) $value;
return number_format($quantity, abs($quantity - round($quantity)) < 0.00001 ? 0 : 2);
};
@endphp
<style>
.supplier-portal-page { display: grid; gap: 16px; color: var(--vip-content-ink, #171717); }
.supplier-portal-page .sp-card { min-width: 0; max-width: 100%; padding: 16px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 12px; background: #fff; display: grid; gap: 12px; }
@ -28,20 +35,36 @@
.supplier-portal-page .sp-alert.is-error { border-color: #E8C4C0; background: #FBEBE9; color: #A6392F; }
.supplier-portal-page .sp-upload { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.supplier-portal-page .sp-summary { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--vip-content-muted, #595959); font-size: .75rem; }
.supplier-portal-page .sp-return-summary { display: grid; grid-template-columns: repeat(5, minmax(145px, 1fr)); gap: 12px; }
.supplier-portal-page .sp-return-stat { min-width: 0; padding: 16px 18px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 10px; background: #fff; display: grid; gap: 8px; }
.supplier-portal-page .sp-return-stat.is-total { border-color: #E4DCCB; background: var(--vip-content-accent-soft, #F7F0DE); }
.supplier-portal-page .sp-return-stat > span { color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
.supplier-portal-page .sp-return-stat strong { color: var(--vip-content-ink, #171717); font-size: 1.375rem; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; white-space: nowrap; }
.supplier-portal-page .sp-return-stat strong small { margin-left: 3px; color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 500; }
.supplier-portal-page .sp-return-stat em { color: var(--vip-content-muted, #595959); font-size: .8125rem; font-style: normal; font-variant-numeric: tabular-nums; }
.supplier-portal-page .sp-return-table { min-width: 1420px; }
.supplier-portal-page .sp-return-scroll { max-height: min(62vh, 560px); overflow: auto; scrollbar-gutter: stable; }
.supplier-portal-page .sp-return-scroll thead th { position: sticky; top: 0; z-index: 4; box-shadow: 0 1px 0 var(--vip-border, #D8D2C7); }
.supplier-portal-page .sp-filter { display: grid; grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(135px, .8fr)) minmax(150px, 1fr) minmax(125px, .75fr); gap: 10px 12px; align-items: end; padding: 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; }
.supplier-portal-page .sp-filter-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; }
.supplier-portal-page .sp-date-presets { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; grid-column: 1 / -1; margin-top: -2px; }
.supplier-portal-page .sp-date-presets > span { margin-right: 2px; color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
.supplier-portal-page .sp-pagination { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--vip-content-muted, #595959); font-size: .8125rem; }
.supplier-portal-page .sp-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 4px; background: #EFEAE0; color: #595959; font-size: .75rem; white-space: nowrap; }
.supplier-portal-page .sp-tag.is-match { background: #ECF4EC; color: #3F7A46; }
.supplier-portal-page .sp-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
.supplier-portal-page .sp-tag.is-logistics { margin-left: 4px; background: #F7F0DE; color: #856632; }
.supplier-portal-page .sp-tag.is-no-bookkeeping { margin-left: 4px; background: #EFEAE0; color: #595959; }
.supplier-portal-page tr.is-logistics td { background: #FFFDF7; }
.supplier-portal-page .sp-inline-form { display: inline-flex; }
.supplier-portal-page .sp-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
@media (max-width: 960px) {
.supplier-portal-page .sp-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.supplier-portal-page .sp-return-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.supplier-portal-page .sp-filter { grid-template-columns: minmax(0, 1fr); }
.supplier-portal-page .sp-return-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
@ -66,18 +89,13 @@
</form>
</div>
<p class="sp-hint">
请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号、采购单号可留空,
请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号可留空;
采购单号建议填写,系统会按采购单号+日期+条码与我方数据核对;
单价或金额留空时会按数量自动补齐。
</p>
<div class="sp-summary">
@if (! empty($uploads[0]))
<span>最近上传:{{ $uploads[0]['uploaded_at'] ?? '—' }}</span>
<span>文件:{{ $uploads[0]['filename'] ?? '—' }}</span>
<span>行数:{{ number_format((int) ($uploads[0]['rows'] ?? 0)) }}</span>
@else
<span>当前还没有上传过对账单。</span>
@endif
<span>退货明细:共 {{ number_format($returnPanel['total_rows']) }} 行</span>
<span>退货件数:{{ $formatReturnQty($returnPanel['return_qty']) }} 件</span>
<span>退货金额:¥{{ number_format($returnPanel['total_amount'], 2) }}</span>
</div>
</div>
@ -86,21 +104,47 @@
<p class="sp-hint">
这里展示本厂家的全部退货数据(包含待确认、已确认和异议中),按导入顺序排列。
@if ($returnPanel['is_filtered'])
当前筛选出 {{ number_format($returnPanel['total_rows']) }} 行。
当前筛选结果:{{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }}。
@endif
</p>
<div class="sp-summary">
<span>退货合计 {{ number_format($returnPanel['total_rows']) }} 行</span>
<span>扣减合计 ¥{{ number_format($returnPanel['total_amount'], 2) }}</span>
<span>已确认 {{ $returnPanel['confirmed_count'] }} 行(¥{{ number_format($returnPanel['confirmed_amount'], 2) }})</span>
<span>异议 {{ $returnPanel['disputed_count'] }} 行(暂不扣减 ¥{{ number_format($returnPanel['disputed_amount'], 2) }})</span>
<span>待确认 {{ max($returnPanel['total_rows'] - $returnPanel['confirmed_count'] - $returnPanel['disputed_count'], 0) }} 行</span>
<div class="sp-return-summary" aria-label="退货汇总">
<div class="sp-return-stat is-total">
<span>退货件数</span>
<strong>{{ $formatReturnQty($returnPanel['return_qty']) }}<small>件</small></strong>
</div>
<div class="sp-return-stat is-total">
<span>退货金额</span>
<strong>¥{{ number_format($returnPanel['total_amount'], 2) }}</strong>
</div>
<div class="sp-return-stat">
<span>已确认</span>
<strong>{{ $formatReturnQty($returnPanel['confirmed_qty']) }}<small>件</small></strong>
<em>¥{{ number_format($returnPanel['confirmed_amount'], 2) }}</em>
</div>
<div class="sp-return-stat">
<span>异议中</span>
<strong>{{ $formatReturnQty($returnPanel['disputed_qty']) }}<small>件</small></strong>
<em>¥{{ number_format($returnPanel['disputed_amount'], 2) }}</em>
</div>
<div class="sp-return-stat">
<span>待确认</span>
<strong>{{ $formatReturnQty($returnPanel['pending_qty']) }}<small>件</small></strong>
<em>¥{{ number_format($returnPanel['pending_amount'], 2) }}</em>
</div>
@if ($returnPanel['accounting_summary']['logistics_rows'] > 0)
<div class="sp-return-stat">
<span>转物流记账</span>
<strong>{{ $formatReturnQty($returnPanel['accounting_summary']['logistics_qty']) }}<small>件</small></strong>
<em>¥{{ number_format($returnPanel['accounting_summary']['logistics_amount'], 2) }}</em>
</div>
@endif
</div>
<form method="GET"
action="{{ route('portal.supplier-reconciliation.index') }}"
class="sp-filter"
aria-label="查询退货明细">
aria-label="查询退货明细"
data-return-filter-form>
<label class="sp-field"><span>关键字</span>
<input type="text" name="return_keyword"
value="{{ $returnPanel['filters']['keyword'] }}"
@ -108,10 +152,18 @@
placeholder="条形码 / 商品名称 / 供应商条码 / 配货单号 / 快递单号">
</label>
<label class="sp-field"><span>发货日期起</span>
<input type="date" name="return_date_from" value="{{ $returnPanel['filters']['date_from'] }}">
<input type="date"
id="portal-return-date-from"
name="return_date_from"
value="{{ $returnPanel['filters']['date_from'] }}"
title="点击选择开始日期">
</label>
<label class="sp-field"><span>发货日期止</span>
<input type="date" name="return_date_to" value="{{ $returnPanel['filters']['date_to'] }}">
<input type="date"
id="portal-return-date-to"
name="return_date_to"
value="{{ $returnPanel['filters']['date_to'] }}"
title="点击选择结束日期">
</label>
<label class="sp-field"><span>仓库名称</span>
<select name="return_warehouse">
@ -129,17 +181,24 @@
<option value="disputed" @selected($returnPanel['filters']['status'] === 'disputed')>异议中</option>
</select>
</label>
<div class="sp-date-presets" role="group" aria-label="快捷发货日期">
<span>快捷日期</span>
<button type="button" class="sp-button is-small" data-return-date-range="last-7">近 7 天</button>
<button type="button" class="sp-button is-small" data-return-date-range="this-month">本月</button>
<button type="button" class="sp-button is-small" data-return-date-range="last-month">上月</button>
<button type="button" class="sp-button is-small" data-return-date-range="all">全部日期</button>
</div>
<div class="sp-filter-actions">
<button type="submit" class="sp-button is-primary">查询</button>
<a class="sp-button" href="{{ route('portal.supplier-reconciliation.index') }}">重置</a>
<a class="sp-button"
href="{{ route('portal.supplier-reconciliation.returns.export', $returnPanel['filter_query']) }}">
导出当前筛选结果({{ number_format($returnPanel['total_rows']) }} 行)
导出当前筛选结果
</a>
</div>
</form>
<div class="sp-scroll">
<div class="sp-scroll sp-return-scroll">
<table class="sp-return-table">
<thead>
<tr>
@ -152,7 +211,7 @@
</thead>
<tbody>
@forelse ($returnPanel['rows'] as $row)
<tr>
<tr @class(['is-logistics' => $row['_accounting_target'] === '物流'])>
<td>{{ $row['send_time'] ?? '—' }}</td>
<td>{{ $row['supplier'] ?? '—' }}</td>
<td>{{ $row['barcode'] ?? '—' }}</td>
@ -161,7 +220,15 @@
<td class="is-num">{{ $row['unit_price'] ?? '—' }}</td>
<td class="is-num">{{ $row['amount'] ?? '—' }}</td>
<td>{{ $row['supplier_barcode'] ?? '—' }}</td>
<td>{{ $row['remark'] ?? '—' }}</td>
<td>
{{ $row['remark'] ?? '—' }}
@if ($row['_accounting_target'] !== '厂家')
<span class="sp-tag {{ $row['_accounting_target'] === '物流' ? 'is-logistics' : 'is-no-bookkeeping' }}">{{ $row['_accounting_label'] }}</span>
@endif
@if ($row['_accounting_note'] !== '按厂家退货扣减')
<div class="sp-muted" style="font-size:.7rem">{{ $row['_accounting_note'] }}</div>
@endif
</td>
<td>{{ $row['title'] ?? '—' }}</td>
<td>{{ $row['wms_pick_no'] ?? '—' }}</td>
<td>{{ $row['warehouse'] ?? '—' }}</td>
@ -294,5 +361,58 @@
@endif
</div>
</div>
<script>
(() => {
const form = document.querySelector('[data-return-filter-form]');
if (!form) {
return;
}
const from = form.querySelector('#portal-return-date-from');
const to = form.querySelector('#portal-return-date-to');
const iso = (date) => [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
[from, to].forEach((input) => {
input?.addEventListener('click', () => {
if (typeof input.showPicker === 'function') {
try {
input.showPicker();
} catch (error) {
// 浏览器已在日期面板中打开时忽略。
}
}
});
});
form.querySelectorAll('[data-return-date-range]').forEach((button) => {
button.addEventListener('click', () => {
const today = new Date();
const kind = button.dataset.returnDateRange;
if (kind === 'all') {
from.value = '';
to.value = '';
} else if (kind === 'this-month') {
from.value = iso(new Date(today.getFullYear(), today.getMonth(), 1));
to.value = iso(today);
} else if (kind === 'last-month') {
from.value = iso(new Date(today.getFullYear(), today.getMonth() - 1, 1));
to.value = iso(new Date(today.getFullYear(), today.getMonth(), 0));
} else if (kind === 'last-7') {
const start = new Date(today);
start.setDate(start.getDate() - 6);
from.value = iso(start);
to.value = iso(today);
}
form.submit();
});
});
})();
</script>
</x-slot>
</x-layouts.portal>

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

@ -17,6 +17,7 @@
# --configure-dingtalk-callback
# --allow-non-main 允许非 main 分支(测试用)
# --allow-migrations 允许执行待办数据库迁移(需先按 AGENTS.md 核对)
# --allow-rollback 允许覆盖不在本次版本里的线上提交(仅负责人决定撤回时使用)
# --skip-regctl 跳过 regctl,直接用 docker 直传镜像
# --keep-images <数量> 默认 2
# --app-version <版本> 默认 3.1.15
@ -27,6 +28,15 @@
#
set -euo pipefail
release_includes_commit() {
git merge-base --is-ancestor "$1" "$2" 2>/dev/null && return 0
# Squash 改变提交身份;仅接受历史中完整文件树一致的提交。
local previous_tree release_trees
previous_tree="$(git rev-parse "${1}^{tree}" 2>/dev/null)" || return 1
release_trees="$(git log --format=%T "$2" 2>/dev/null)" || return 1
printf '%s\n' "$release_trees" | grep -Fx -- "$previous_tree" >/dev/null
}
server='218.93.190.75'
key_path=''
password_file=''
@ -45,6 +55,7 @@ bootstrap_server=0
configure_dingtalk_callback=0
allow_non_main=0
allow_migrations=0
allow_rollback=0
skip_regctl=0
app_version='3.1.15'
# 国内网络访问 GitHub 受限时,composer 的 GitHub dist 会失败;默认走腾讯云镜像。
@ -71,6 +82,7 @@ while [ "$#" -gt 0 ]; do
--configure-dingtalk-callback) configure_dingtalk_callback=1 ;;
--allow-non-main) allow_non_main=1 ;;
--allow-migrations) allow_migrations=1 ;;
--allow-rollback) allow_rollback=1 ;;
--skip-regctl) skip_regctl=1 ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) echo "未知参数:$1" >&2; exit 2 ;;
@ -404,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
@ -445,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"
@ -500,12 +512,35 @@ 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
previous_sha_exists=1
else
previous_sha_exists=0
# 线上可能是别人刚发的热修复分支,先拉取再判断。
git fetch origin --quiet 2>/dev/null || true
if git cat-file -e "${previous_sha}^{commit}" 2>/dev/null; then
previous_sha_exists=1
else
previous_sha_exists=0
fi
fi
# 防覆盖:线上当前版本必须包含在本次要发布的版本里,否则会撤掉别人已上线的改动。
if [ "$allow_rollback" -eq 0 ]; then
if [ "$previous_sha_exists" -eq 0 ]; then
fail "线上当前版本 ${previous_sha} 在本地和 origin 都找不到(可能是未推送的分支),发布会把它覆盖。请让发布人先推送并合并进 main;确需撤回时显式添加 --allow-rollback。"
fi
if ! release_includes_commit "$previous_sha" "$deploy_sha"; then
fail "线上当前版本($(git log -1 --format='%h %an %ad %s' --date=format:'%m-%d %H:%M' "$previous_sha"))不在本次发布的版本里,继续发布会把它及之后别人上线的改动撤掉。请先 git fetch 并把最新 main(或线上热修复分支)合入当前分支后重试;确需撤回时显式添加 --allow-rollback。"
fi
fi
if [ "$previous_sha_exists" -eq 0 ] && [ "$allow_migrations" -eq 0 ]; then
@ -528,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
@ -542,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" \
@ -550,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
@ -570,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 \
@ -657,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

@ -22,6 +22,8 @@ param(
[switch]$ConfigureDingTalkCallback,
[switch]$AllowNonMain,
[switch]$AllowMigrations,
# 仅在负责人明确决定撤回线上某次发布时使用;默认禁止覆盖不在本次版本里的线上提交。
[switch]$AllowRollback,
[switch]$SkipRegctl
)
@ -48,6 +50,21 @@ $buildkitConfig = Join-Path $repoRoot 'deploy/buildkitd.toml'
$regctlVersion = 'v0.11.5'
$regctlSha256 = '9b83975d464e4ee5a9f899a3ead26072ed67e9a8ed0f582897bfad8141b45938'
function Test-CaiwuReleaseIncludesCommit {
param([string]$PreviousSha, [string]$ReleaseSha)
git merge-base --is-ancestor $PreviousSha $ReleaseSha 2>$null
if ($LASTEXITCODE -eq 0) { return $true }
# Squash 会改变提交身份;只接受发布历史中完整文件树一致的提交。
# 不按提交说明、部分文件或 patch 相似度放行。
$previousTree = (git rev-parse "${PreviousSha}^{tree}" 2>$null) -join ''
if ($LASTEXITCODE -ne 0) { return $false }
$releaseTrees = @(git log --format=%T $ReleaseSha 2>$null)
if ($LASTEXITCODE -ne 0) { return $false }
return $releaseTrees -contains $previousTree
}
function Invoke-Native {
param(
[Parameter(Mandatory = $true)]
@ -331,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"
@ -355,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.
@ -419,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) {
@ -426,6 +452,24 @@ try {
$previousShaExists = $LASTEXITCODE -eq 0
}
if ($previousSha -and -not $previousShaExists) {
# 线上可能是别人刚发的热修复分支,先拉取再判断,避免误判。
git fetch origin --quiet 2>$null
git cat-file -e "${previousSha}^{commit}" 2>$null
$previousShaExists = $LASTEXITCODE -eq 0
}
# 防覆盖:线上当前版本必须包含在本次要发布的版本里,否则发布会把别人已上线的改动撤掉。
if ($previousSha -and -not $AllowRollback) {
if (-not $previousShaExists) {
throw "线上当前版本 $previousSha 在本地和 origin 都找不到(可能是未推送的分支),发布会把它覆盖。请让发布人先推送并合并进 main;确需撤回时显式添加 -AllowRollback。服务器未发生任何变更。"
}
if (-not (Test-CaiwuReleaseIncludesCommit -PreviousSha $previousSha -ReleaseSha $deploySha)) {
$previousInfo = (git log -1 --format='%h %an %ad %s' --date=format:'%m-%d %H:%M' $previousSha) -join ''
throw "线上当前版本($previousInfo)不在本次发布的版本里,继续发布会把它及之后别人上线的改动撤掉。请先 git fetch 并把最新 main(或线上热修复分支)合入当前分支后重试;确需撤回时显式添加 -AllowRollback。服务器未发生任何变更。"
}
}
if ($previousSha -and -not $previousShaExists -and -not $AllowMigrations) {
throw "本地缺少服务器当前提交 $previousSha,无法安全比较数据库迁移。请先 git fetch 并核对全部待执行迁移,按 AGENTS.md 完成备份后使用 -AllowMigrations。"
}
@ -452,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
@ -463,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 `
@ -471,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) {
@ -497,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 无法复用。
@ -618,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

@ -8,16 +8,20 @@
| --- | --- | --- | --- |
| `toutiao` | 头条放心购 → 资金流水 | `business-mirror:import-douyin-authoritative` | 账单流水数据 › 店铺收款账户账单 › **抖音账户** |
| `jd` | 京东商城 → 京东日账单 | `business-mirror:import-jd-daily` | **京东账单 + 京喜账单**(一次导入按店铺名拆两边,两边合计恒等于导出行数) |
| `jd-wallet` | 京东商城 → 京东钱包 | `business-mirror:import-jd-wallet` | **京东网银钱包账户** |
## 用法
```powershell
# 两个平台都跑:区间「月初 → 昨日」
# 三个平台都跑:区间「月初 → 昨日」
pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1
# 只跑京东,补历史月份
pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform jd -Through 2026-07-31
# 只跑京东钱包,补历史月份
pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform jd-wallet -Through 2026-07-31
# 只导出不导入(排查)
pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform toutiao -Through 2026-10-05 -SkipImport
```
@ -26,9 +30,19 @@ pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform toutiao -Through
- **无差异跳过**:该平台该月已导入且笔数/收入/支出完全一致时跳过导入(`-ForceImport` 可强制重刷);状态记在 `runtime/private/erp-exports/imported-state.json`。
- 超过胜算同步导出上限(响应 `超出同步导出允许最大数据量`)时,脚本自动轮询「异步导出下载管理」取回本次任务的全部分片,逐片转 CSV 后合并成一份再导入;仍可用 `-Start/-Through` 分段导出。
- 导入前用页面笔数(共 N 条)与导出行数核对,不一致就跳过导入(`-ForceImport` 可强制),避免只导入一部分。
- 稳定性:导出失败自动重试一次(胜算页面偶发日历面板/授权弹窗异常);上传与"导入+回验"失败自动重试 3 次(含重新上传 CSV),用于吸收服务器 SSH 偶发断连。
- 稳定性:导出最多重试 3 次(胜算页面偶发日历面板/授权弹窗异常);上传与"导入+回验"失败自动重试 3 次(含重新上传 CSV),用于吸收服务器 SSH 偶发断连。
- 失败即失败:导出/转 CSV/笔数核对/上传/导入回验任一步失败,都会**不写本地状态、不归档**,并在结束时以退出码 1 结束(下次运行会重新导入),不会把失败当成功记入 `imported-state.json`。
## 覆盖校验
导入后可核对三个数据集在指定区间内是否逐月覆盖到截止日:
```powershell
docker exec -u www-data caiwu_app php artisan business-mirror:verify-erp-daily-coverage --from=2026-07-01 --to=2026-10-19
```
命令会逐个月份检查期间起止范围、有效版本行数和登记行数;缺月、只导到月中或行数不一致都会返回失败。
## 组成
| 文件 | 作用 |

View File

@ -3,6 +3,7 @@
*
* --platform=toutiao 头条放心购 → 资金流水 (导入 import-douyin-authoritative → 抖音账户)
* --platform=jd 京东商城 → 京东日账单 (导入 import-jd-daily → 京东账单 + 京喜账单,按店铺名拆分)
* --platform=jd-wallet 京东商城 → 京东钱包 (导入 import-jd-wallet → 京东网银钱包账户)
*
* 用法:
* node export-daily.mjs --platform=toutiao # 本月月初 → 昨日
@ -24,6 +25,7 @@ const BASE = path.dirname(fileURLToPath(import.meta.url));
const PLATFORMS = {
toutiao: { label: '头条放心购', subTab: '资金流水', filePrefix: '头条放心购_资金流水', exportMainName: '头条放心购头条资金流水' },
jd: { label: '京东商城', subTab: '京东日账单', filePrefix: '京东日账单', exportMainName: '京东商城京东日账单' },
'jd-wallet': { label: '京东商城', subTab: '京东钱包', filePrefix: '京东钱包', exportMainName: '京东商城京东钱包' },
};
const arg = (name, fallback = null) => {
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
@ -41,7 +43,7 @@ const BILL_URL = 'https://ss.erp321.com/process/platform-bill-new';
const platformKey = arg('platform', 'toutiao');
const platform = PLATFORMS[platformKey];
if (!platform) {
throw new Error('未知平台:' + platformKey + '(可选 toutiao / jd)');
throw new Error('未知平台:' + platformKey + '(可选 toutiao / jd / jd-wallet)');
}
fs.mkdirSync(OUT_DIR, { recursive: true });

View File

@ -1,19 +1,21 @@
<#
聚胜算每日账单 → 财务系统(统一入口)
平台:toutiao = 头条放心购→资金流水(落 抖音账户)
jd = 京东商城→京东日账单(一次导入按店铺名拆 京东账单 + 京喜账单)
平台:toutiao = 头条放心购→资金流水(落 抖音账户)
jd = 京东商城→京东日账单(一次导入按店铺名拆 京东账单 + 京喜账单)
jd-wallet = 京东商城→京东钱包(落 京东网银钱包账户)
流程:导出 → xlsx 转 CSV → 与已导入期间比对(无差异则跳过)→ 上传 → 受控导入 → 页面回验 → 归档 → 写日志
用法:
pwsh -File run-erp-daily.ps1 # 两个平台,区间「月初 → 昨日」
pwsh -File run-erp-daily.ps1 # 三个平台,区间「月初 → 昨日」
pwsh -File run-erp-daily.ps1 -Platform jd -Through 2026-07-31
pwsh -File run-erp-daily.ps1 -Platform jd-wallet -Through 2026-07-31
pwsh -File run-erp-daily.ps1 -Platform toutiao -SkipImport
#>
[CmdletBinding()]
param(
[ValidateSet('toutiao', 'jd', 'both')][string]$Platform = 'both',
[ValidateSet('toutiao', 'jd', 'jd-wallet', 'both')][string]$Platform = 'both',
[string]$Through,
[string]$Start,
[switch]$SkipImport,
@ -45,9 +47,10 @@ function Write-Log {
$specs = @{
toutiao = @{ label = '头条放心购'; artifact = 'douyin'; importCmd = 'business-mirror:import-douyin-authoritative'; verifyTabs = @('douyin'); split = $false }
jd = @{ label = '京东日账单'; artifact = 'jd'; importCmd = 'business-mirror:import-jd-daily'; verifyTabs = @('jd-transactions', 'jingxi'); split = $true }
'jd-wallet' = @{ label = '京东钱包'; artifact = 'jd-wallet'; importCmd = 'business-mirror:import-jd-wallet'; verifyTabs = @('jd-wallet'); split = $false }
}
$targets = if ($Platform -eq 'both') { @('toutiao', 'jd') } else { @($Platform) }
$targets = if ($Platform -eq 'both') { @('toutiao', 'jd', 'jd-wallet') } else { @($Platform) }
$state = if (Test-Path -LiteralPath $stateFile) { (Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json) } else { New-Object psobject }
$auth = Start-CaiwuDeploymentAuth -Server '218.93.190.75' -SshUser 'root'
@ -144,7 +147,7 @@ try {
# 2) 无差异跳过
$key = $target + ':' + $month
$prev = $state.$key
$same = $prev -and ([int]$prev.rows -eq $rows) -and ([math]::Abs([double]$prev.income - $income) -lt 0.005) -and ([math]::Abs([double]$prev.expense - $expense) -lt 0.005)
$same = $prev -and ([string]$prev.through -eq [string]$summary.through) -and ([int]$prev.rows -eq $rows) -and ([math]::Abs([double]$prev.income - $income) -lt 0.005) -and ([math]::Abs([double]$prev.expense - $expense) -lt 0.005)
if ($same -and -not $ForceImport) {
Write-Log ('该月已导入且无差异({0} 笔 / {1} / {2}),跳过导入' -f $rows, $income, $expense)
continue
@ -170,9 +173,11 @@ require '/var/www/html/vendor/autoload.php';
`$repo = app(Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository::class);
`$tabs = ['__TABS__'];
foreach (`$tabs as `$tab) {
`$r = `$tab === 'douyin'
? `$repo->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1)
: app(Modules\EcommerceFinance\Repositories\JdDailyBillRepository::class)->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1, `$tab);
`$r = match (`$tab) {
'douyin' => `$repo->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1),
'jd-wallet' => app(Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository::class)->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1),
default => app(Modules\EcommerceFinance\Repositories\JdDailyBillRepository::class)->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1, `$tab),
};
echo 'PAGE ' . `$tab . ' total=' . `$r['total'] . ' ' . json_encode(`$r['summary'], JSON_UNESCAPED_UNICODE) . PHP_EOL;
}
PHP
@ -197,12 +202,14 @@ rm -f /tmp/daily_bills.csv $remoteFile
}
# 4) 记录状态 + 归档
$state | Add-Member -NotePropertyName $key -NotePropertyValue ([pscustomobject]@{ rows = $rows; income = $income; expense = $expense; file = $csvFile; at = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') }) -Force
$state | Add-Member -NotePropertyName $key -NotePropertyValue ([pscustomobject]@{ rows = $rows; income = $income; expense = $expense; through = $summary.through; file = $csvFile; at = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') }) -Force
$state | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $stateFile -Encoding UTF8
$shareDir = 'Z:\高祝兰\财务系统\9月'
$monthNumber = [int]$month.Substring(5, 2)
$shareDir = Join-Path 'Z:\高祝兰\财务系统' ('{0}月' -f $monthNumber)
if (Test-Path -LiteralPath 'Z:\高祝兰\财务系统') {
try {
New-Item -ItemType Directory -Path $shareDir -Force | Out-Null
foreach ($f in $rawFiles) {
Copy-Item -LiteralPath $f -Destination (Join-Path $shareDir ([IO.Path]::GetFileName($f))) -Force
}

View File

@ -0,0 +1,71 @@
param([string]$BashPath='bash')
$ErrorActionPreference='Stop'
$repoRoot=Split-Path $PSScriptRoot -Parent
$psSource=[IO.File]::ReadAllText((Join-Path $repoRoot 'scripts/deploy-from-workstation.ps1'))
$parseErrors=$null; $tokens=$null
$ast=[Management.Automation.Language.Parser]::ParseInput($psSource,[ref]$tokens,[ref]$parseErrors)
if($parseErrors.Count){throw 'Deployment PowerShell syntax invalid'}
$function=$ast.Find({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Test-CaiwuReleaseIncludesCommit'},$true)
if(-not $function){throw 'PowerShell inclusion guard missing'}
. ([ScriptBlock]::Create($function.Extent.Text))
$bashSource=[IO.File]::ReadAllText((Join-Path $repoRoot 'scripts/deploy-from-mac.sh')).Replace("`r`n","`n")
$bashFunction=[regex]::Match($bashSource,'(?ms)^release_includes_commit\(\) \{\n.*?^\}').Value
if(-not $bashFunction){throw 'Bash inclusion guard missing'}
$fixture=Join-Path ([IO.Path]::GetTempPath()) ('caiwu-release-inclusion-'+[guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $fixture | Out-Null
$expectedFixture=[IO.Path]::GetFullPath($fixture)
function Invoke-FixtureGit {
param([Parameter(ValueFromRemainingArguments=$true)][string[]]$GitArgs)
$output=@(git @GitArgs 2>&1)
if($LASTEXITCODE -ne 0){throw "Fixture Git failed: $($GitArgs[0])"}
return $output
}
function New-FixtureCommit {
param([string]$Value,[string]$Message)
[IO.File]::WriteAllText((Join-Path $fixture 'value.txt'),$Value)
Invoke-FixtureGit add value.txt | Out-Null
Invoke-FixtureGit commit -m $Message | Out-Null
return (Invoke-FixtureGit rev-parse HEAD) -join ''
}
Push-Location $fixture
try {
Invoke-FixtureGit init -b main | Out-Null
Invoke-FixtureGit config user.name 'Release guard fixture' | Out-Null
Invoke-FixtureGit config user.email 'release-guard@example.invalid' | Out-Null
$base=New-FixtureCommit 'base' 'base'
Invoke-FixtureGit switch -c hotfix | Out-Null
$hotfix=New-FixtureCommit 'hotfix content' 'hotfix'
Invoke-FixtureGit switch main | Out-Null
$squashed=New-FixtureCommit 'hotfix content' 'squashed identity'
$release=New-FixtureCommit 'next authorized change' 'new release'
Invoke-FixtureGit switch -c unrelated $base | Out-Null
$unrelated=New-FixtureCommit 'different hotfix content' 'hotfix'
$cases=@(
@{name='direct ancestor';previous=$base;release=$release;expected=$true},
@{name='same commit';previous=$hotfix;release=$hotfix;expected=$true},
@{name='identical squash tree at head';previous=$hotfix;release=$squashed;expected=$true},
@{name='identical squash tree in release ancestry';previous=$hotfix;release=$release;expected=$true},
@{name='real unmerged change';previous=$unrelated;release=$release;expected=$false},
@{name='rollback to before hotfix';previous=$hotfix;release=$base;expected=$false},
@{name='missing deployed commit';previous=('0'*40);release=$release;expected=$false},
@{name='missing release commit';previous=$hotfix;release=('0'*40);expected=$false}
)
foreach($case in $cases){
$actual=Test-CaiwuReleaseIncludesCommit -PreviousSha $case.previous -ReleaseSha $case.release
if($actual -ne $case.expected){throw "PowerShell failed: $($case.name)"}
$code=$bashFunction+"`nrelease_includes_commit "+'"$1" "$2"'
& $BashPath --noprofile --norc -c $code guard $case.previous $case.release
$bashActual=$LASTEXITCODE -eq 0
if($bashActual -ne $case.expected){throw "Bash failed: $($case.name)"}
}
"PASS: $($cases.Count) real Git cases for both PowerShell and Bash guards"
} finally {
Pop-Location
$resolved=(Resolve-Path -LiteralPath $fixture).Path
if([IO.Path]::GetFullPath($resolved) -ne $expectedFixture -or (Get-Item -LiteralPath $resolved).Attributes.HasFlag([IO.FileAttributes]::ReparsePoint)){
throw 'Refusing cleanup outside the exact generated fixture directory'
}
Remove-Item -LiteralPath $resolved -Recurse -Force
}
exit 0

View File

@ -31,6 +31,9 @@ class AccountMappingPageTest extends FeatureTestCase
// 只影响 storage_path(),视图编译目录已在启动时解析,不受影响
$this->app->useStoragePath($this->storageRoot);
// Rules now live in the in-memory test database; seed it independently of local files.
app(AccountMappingStore::class)->reset();
\Illuminate\Support\Facades\DB::table(AccountMappingStore::EVENTS_TABLE)->delete();
}
protected function tearDown(): void

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

@ -254,7 +254,7 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
return count($pairs) === 2
&& $pairs[0]['waybill'] === 'DPK317138931411'
&& $pairs[0]['surcharge_type'] === '退回费'
&& $pairs[0]['courier_name'] === '曹县德邦小件'
&& $pairs[0]['courier_name'] === ''
&& $pairs[0]['amount'] === 10.0
&& $pairs[1]['waybill'] === 'DPK1'
&& $pairs[1]['surcharge_type'] === '超长费'
@ -288,8 +288,8 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
$spreadsheet = new Spreadsheet();
$spreadsheet->getActiveSheet()->fromArray([
['单号', '附加费类型', '快递名称', '金额'],
['DPK317138931411', '退回费', '曹县德邦小件', 10],
['单号', '附加费类型', '金额'],
['DPK317138931411', '退回费', 10],
['DPK1', '超长费'],
]);
$importPath = tempnam(sys_get_temp_dir(), 'surcharge_import_');
@ -356,12 +356,11 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
try {
$this->assertSame('单号', $plain($sheet->getCell('A1')->getValue()));
$this->assertSame('附加费类型', $plain($sheet->getCell('B1')->getValue()));
$this->assertSame('快递名称', $plain($sheet->getCell('C1')->getValue()));
$this->assertSame('金额(可选)', $plain($sheet->getCell('D1')->getValue()));
$this->assertSame('金额(可选)', $plain($sheet->getCell('C1')->getValue()));
$this->assertNull($plain($sheet->getCell('D1')->getValue()));
$this->assertSame('DPK317138931411', $plain($sheet->getCell('A2')->getValue()));
$this->assertSame('退回费', $plain($sheet->getCell('B2')->getValue()));
$this->assertSame('曹县德邦小件', $plain($sheet->getCell('C2')->getValue()));
$this->assertEquals(20, $plain($sheet->getCell('D2')->getValue()));
$this->assertEquals(20, $plain($sheet->getCell('C2')->getValue()));
} finally {
@unlink($path);
}

View File

@ -107,6 +107,17 @@ class PlantingWorkOrderPagesTest extends FeatureTestCase
$this->assertStringContainsString("GD-EXEMPT,'3316392626048007769,拍A发B", $csv);
$this->assertStringNotContainsString('GD565967381', $csv);
// 发运营:按种菜报销字段导出,忽略损耗页的列设置。
$operations = $this->loginAs()->get(route('business.planting-data.planting-loss.export', [
'company_id' => $this->company->id, 'month' => '2026-09', 'result' => Calculator::RESULT_EXEMPT, 'format' => 'reimbursement',
]));
$operations->assertOk();
$opsCsv = $operations->streamedContent();
$this->assertStringContainsString(implode(',', array_values(PlantingReimbursementPageData::COLUMNS)), $opsCsv);
$this->assertStringContainsString('GD-EXEMPT', $opsCsv);
$this->assertStringNotContainsString('GD565967381', $opsCsv);
$this->assertStringNotContainsString('损耗判定', $opsCsv);
$reimbursements = $this->loginAs()->get(route('business.planting-data.planting-reimbursements.export', [
'company_id' => $this->company->id, 'month' => '2026-09',
]));

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

@ -8,6 +8,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\PayablesFinalImporter;
use Modules\EcommerceFinance\Services\PayablesFinalRegistry;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Tests\Feature\FeatureTestCase;
class SupplierPayableFinalPagesTest extends FeatureTestCase
@ -203,6 +204,97 @@ class SupplierPayableFinalPagesTest extends FeatureTestCase
$this->assertStringNotContainsString('快递名称', $export->streamedContent());
}
public function test_final_return_reconciliation_page_paginates_fifteen_rows(): void
{
$records = [];
for ($index = 1; $index <= 20; $index++) {
$records[sprintf('return:%02d', $index)] = [
'summary_month' => '2026-09',
'send_time' => '2026-09-30',
'supplier' => '退货供应商',
'barcode' => sprintf('RC-%02d', $index),
'sku_name' => '退货商品',
'qty' => '1',
'unit_price' => '10',
'amount' => '10',
];
}
app(SupplierPayableOverrideStore::class)->saveBatch(
'final:supplier:return-reconciliation',
$records,
(int) $this->company->id,
(int) $this->user->id,
);
$baseUrl = route('business.pages.show', [
'company_id' => (int) $this->company->id,
'section' => 'supplier-payables-final',
'page' => 'return-reconciliation',
]);
$url = fn (int $page): string => $baseUrl . '?page=' . $page;
$this->loginAs()->get($url(1))
->assertOk()
->assertSeeText('每页 15 条')
->assertSeeText('第 1 页 / 共 2 页')
->assertSeeText('RC-01')
->assertDontSeeText('RC-16');
$this->loginAs()->get($url(2))
->assertOk()
->assertSeeText('第 2 页 / 共 2 页')
->assertSeeText('RC-16')
->assertSeeText('RC-20')
->assertDontSeeText('RC-01');
}
public function test_final_payment_reconciliation_page_paginates_fifteen_rows(): void
{
$records = [];
for ($index = 1; $index <= 20; $index++) {
$records[sprintf('payment:%02d', $index)] = [
'summary_month' => '2026-09',
'stockNo' => sprintf('RK-%02d', $index),
'purchaseNo' => sprintf('CG-%02d', $index),
'date' => '2026-09-30',
'supplier' => '货款供应商',
'skuCode' => sprintf('PM-%02d', $index),
'productCode' => sprintf('P-%02d', $index),
'skuName' => '货款商品',
'qty' => '1',
'unitPrice' => '10',
'amount' => '10',
];
}
app(SupplierPayableOverrideStore::class)->saveBatch(
'final:supplier:payment-reconciliation',
$records,
(int) $this->company->id,
(int) $this->user->id,
);
$baseUrl = route('business.pages.show', [
'company_id' => (int) $this->company->id,
'section' => 'supplier-payables-final',
'page' => 'payment-reconciliation',
]);
$url = fn (int $page): string => $baseUrl . '?page=' . $page;
$this->loginAs()->get($url(1))
->assertOk()
->assertSeeText('每页 15 条')
->assertSeeText('第 1 页 / 共 2 页')
->assertSeeText('PM-01')
->assertDontSeeText('PM-16');
$this->loginAs()->get($url(2))
->assertOk()
->assertSeeText('第 2 页 / 共 2 页')
->assertSeeText('PM-16')
->assertSeeText('PM-20')
->assertDontSeeText('PM-01');
}
public function test_final_import_reads_files_larger_than_one_chunk(): void
{
$lines = ['月份,入库单号,采购单号,日期,供应商,货号,商品编码,规格名称,数量,单价,金额,预估单价,预估金额,供应商商品编码,备注,仓库名称,快递名称,快递单号,未发出,对账人'];

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('晨丰', [
@ -132,7 +288,7 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertSeeText('厂家对账单 · 上传与实时数据')
->assertSeeText('上传记录')
->assertSeeText('退货扣减确认')
->assertSeeText('合计扣减')
->assertSeeText('退货金额')
->assertSeeText('全部确认无异议')
->assertSeeText('待确认')
->assertSeeText('条形码')
@ -142,7 +298,15 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertSeeText('查询')
->assertSeeText('导出当前筛选结果');
$response->assertSee('sr-return-scroll', false);
$html = (string) $response->getContent();
$uploadHistoryPosition = strpos($html, ' · 上传记录');
$returnPanelPosition = strpos($html, '退货扣减确认');
$this->assertNotFalse($uploadHistoryPosition, '内部页面应保留上传记录。');
$this->assertNotFalse($returnPanelPosition, '内部页面应正常渲染退货面板。');
$this->assertGreaterThan($returnPanelPosition, $uploadHistoryPosition, '上传记录应移动到页面最下面。');
$tableStart = strpos($html, '<table class="sr-return-table">');
$tableEnd = $tableStart === false ? false : strpos($html, '</table>', $tableStart);
$this->assertNotFalse($tableStart, '厂家退货表应正常渲染。');
@ -155,10 +319,48 @@ class SupplierReconciliationTest extends FeatureTestCase
}
}
public function test_supplier_page_uses_a_searchable_exact_supplier_picker(): void
{
$this->seedStatement('晨丰', [
$this->statementRow('ZSJ-1', '128.00', '2', '256.00'),
]);
$this->seedStatement('鸣飞家具', [
array_merge($this->statementRow('SJ-1', '96.00', '1', '96.00'), ['supplier' => '鸣飞家具']),
]);
$pageUrl = route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
]);
$this->loginAs()->get($pageUrl)
->assertOk()
->assertSee('name="supplier"', false)
->assertSee('list="supplier-name-options"', false)
->assertSeeText('查询厂家')
->assertSee('value="晨丰"', false)
->assertSee('value="鸣飞家具"', false);
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('鸣飞家具'))
->assertOk()
->assertSeeText('鸣飞家具 · 上传记录')
->assertDontSeeText('晨丰 · 上传记录');
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('不存在的厂家'))
->assertOk()
->assertSeeText('未找到名为「不存在的厂家」的厂家')
->assertDontSeeText('晨丰 · 上传记录')
->assertDontSeeText('鸣飞家具 · 上传记录');
}
public function test_vendor_portal_shows_only_all_returns_for_the_logged_in_supplier(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$this->seedStatement('鸣飞家具', [
array_merge($this->statementRow('OTHER-SUPPLIER', '100.00', '1', '100.00'), ['supplier' => '鸣飞家具']),
]);
for ($i = 1; $i <= 20; $i++) {
$store->save('final:supplier:return-reconciliation', sprintf('portal:%02d', $i), [
'summary_month' => '2026-09',
@ -198,6 +400,12 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertSeeText('PORTAL-01')
->assertDontSeeText('PORTAL-20')
->assertDontSeeText('OTHER-SUPPLIER-RETURN')
->assertDontSeeText('鸣飞家具')
->assertDontSeeText('最近上传')
->assertDontSee('supplier-name-options', false)
->assertSee('sp-return-scroll', false)
->assertSee('id="portal-return-date-from"', false)
->assertSee('data-return-date-range="last-month"', false)
->assertDontSeeText('预估单价');
$this->loginAs($vendor)->get($portalUrl . '?return_page=2')
@ -283,6 +491,103 @@ class SupplierReconciliationTest extends FeatureTestCase
$this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', (int) $this->company->id));
}
public function test_vendor_portal_rejects_a_statement_for_another_supplier(): void
{
$this->withExceptionHandling();
$vendor = $this->createVendorPortalUser('晨丰');
$file = UploadedFile::fake()->createWithContent(
'statement.csv',
"\xEF\xBB\xBF" . self::HEADER . "\n"
. ",,PORTAL-WRONG-SUPPLIER,2026-09-30,鸣飞家具,ZSJ-1,规格甲,2,128.00,256.00\n",
);
$this->loginAs($vendor)
->post(route('portal.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'import' => $file,
])
->assertSessionHasErrors([
'import' => '第 2 行:供应商必须填写「晨丰」,不能提交其他厂家的数据。',
]);
$this->assertCount(0, app(SupplierStatementStore::class)->rows('晨丰', (int) $this->company->id));
$this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', (int) $this->company->id));
}
public function test_return_bookkeeping_note_is_identical_on_finance_and_supplier_views(): void
{
$companyId = (int) $this->company->id;
app(SupplierPayableOverrideStore::class)->save(
'final:supplier:payment-reconciliation',
'mine:logistics:1',
[
'summary_month' => '2026-09',
'purchaseNo' => 'PO-LOGISTICS-1',
'date' => '2026-09-27',
'supplier' => '曹县宇诺',
'reconciler' => '陈亲园',
'skuCode' => 'CZ008407',
'qty' => '1',
'unitPrice' => '259',
'amount' => '259',
],
$companyId,
null,
);
app(SupplierPayableOverrideStore::class)->save(
'final:supplier:return-reconciliation',
'return:logistics:1',
[
'summary_month' => '2026-09',
'send_time' => '2026-09-27',
'supplier' => '曹县宇诺',
'barcode' => 'CZ008407N1178V01',
'sku_name' => '款式二 160/80/75 黑胡桃色',
'qty' => '1',
'unit_price' => '259',
'amount' => '259',
'remark' => '原数量2件,CZ008407N1178V01*1记账物流',
'title' => '常州松宇货运',
'express_no' => '202609270079',
'reconciler' => '陈亲园',
],
$companyId,
null,
);
$finance = $this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'supplier' => '曹县宇诺',
'month' => '2026-09',
'reconciler' => '陈亲园',
]));
$finance->assertOk()
->assertSeeText('退货扣减 · 财务与厂家同步')
->assertSeeText('转物流记账')
->assertSeeText('常州松宇货运')
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流')
->assertSeeText('259.00');
$supplier = $this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
'supplier' => '曹县宇诺',
]));
$supplier->assertOk()
->assertSeeText('转物流记账')
->assertSeeText('不扣本厂家,转常州松宇货运记账')
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流');
$vendor = $this->createVendorPortalUser('曹县宇诺');
$this->loginAs($vendor)->get(route('portal.supplier-reconciliation.index', [
'company_id' => $companyId,
]))
->assertOk()
->assertSeeText('转物流记账')
->assertSeeText('不扣本厂家,转常州松宇货运记账')
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流');
}
public function test_statement_rows_are_paginated_fifteen_per_page(): void
{
$rows = [];
@ -332,15 +637,19 @@ class SupplierReconciliationTest extends FeatureTestCase
$this->loginAs()->get($pageUrl(1))
->assertOk()
->assertSeeText('每页 15 行')
->assertSeeText('每页 10 行')
->assertSeeText('第 1 / 2 页')
->assertSeeText('RT-01')
->assertSeeText('RT-10')
->assertDontSeeText('RT-11')
->assertDontSeeText('RT-20');
$this->loginAs()->get($pageUrl(2))
->assertOk()
->assertSeeText('第 2 / 2 页')
->assertSeeText('RT-11')
->assertSeeText('RT-20')
->assertDontSeeText('RT-10')
->assertDontSeeText('RT-01');
}
@ -390,11 +699,14 @@ class SupplierReconciliationTest extends FeatureTestCase
$this->loginAs()->get(route('business.pages.show', $filters))
->assertOk()
->assertSeeText('筛选出 19 行,全部 21 行')
->assertSeeText('当前筛选 19 件,退货金额 ¥1,900.00')
->assertSee('value="2026-09-20"', false)
->assertSee('value="2026-09-21"', false)
->assertSee('data-return-date-range="last-month"', false)
->assertSeeText('MATCH-01')
->assertDontSeeText('MATCH-20')
->assertDontSeeText('OTHER-01')
->assertSeeText('导出当前筛选结果(19 行)');
->assertSeeText('导出当前筛选结果');
$this->loginAs()->get(route('business.pages.show', $filters + ['return_page' => 2]))
->assertOk()
@ -404,6 +716,66 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertSee('return_status=pending', false);
}
public function test_return_summary_uses_quantity_and_amount_without_row_counts(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$rows = [
'summary:1' => ['qty' => '2', 'amount' => '200'],
'summary:2' => ['qty' => '3', 'amount' => '300'],
'summary:3' => ['qty' => '1', 'amount' => '50'],
'summary:4' => ['qty' => '4', 'amount' => '400'],
];
foreach ($rows as $key => $values) {
$store->save('final:supplier:return-reconciliation', $key, $values + [
'summary_month' => '2026-09',
'send_time' => '2026-09-20',
'supplier' => '晨丰',
'barcode' => strtoupper($key),
'unit_price' => '100',
], $companyId, null);
}
$confirmations = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class);
$confirmations->mark('晨丰', 'summary:1', 'confirmed', $companyId, null);
$confirmations->mark('晨丰', 'summary:3', 'disputed', $companyId, null);
$vendor = $this->createVendorPortalUser('晨丰');
$portal = $this->loginAs($vendor)->get(route('portal.supplier-reconciliation.index', [
'company_id' => $companyId,
]));
$portal->assertOk()
->assertSeeText('退货件数')
->assertSeeText('退货金额')
->assertSee('10<small>件</small>', false)
->assertSeeText('¥950.00')
->assertSee('2<small>件</small>', false)
->assertSeeText('¥200.00')
->assertSee('1<small>件</small>', false)
->assertSeeText('¥50.00')
->assertSee('7<small>件</small>', false)
->assertSeeText('¥700.00')
->assertDontSeeText('退货合计')
->assertDontSeeText('扣减合计')
->assertDontSeeText('已确认 2 行')
->assertDontSeeText('异议 1 行')
->assertDontSeeText('待确认 3 行');
$this->loginAs()->get(route('business.pages.show', [
'company_id' => $companyId,
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
'supplier' => '晨丰',
]))
->assertOk()
->assertSeeText('退货件数')
->assertSeeText('退货金额')
->assertSee('10<small>件</small>', false)
->assertSeeText('¥950.00')
->assertDontSeeText('退货合计')
->assertDontSeeText('扣减合计');
}
public function test_confirm_all_only_applies_to_the_current_return_filter(): void
{
$store = app(SupplierPayableOverrideStore::class);
@ -520,7 +892,7 @@ class SupplierReconciliationTest extends FeatureTestCase
'supplier' => '晨丰',
]))->assertOk();
$page()->assertSeeText('合计扣减 ¥2,653.00');
$page()->assertSeeText('退货金额')->assertSeeText('¥2,653.00');
$this->loginAs()
->post(route('business.supplier-reconciliation.return.confirm'), ['supplier' => '晨丰', 'key' => 'return:1'])
@ -621,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);
@ -638,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);
@ -669,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

@ -35,16 +35,39 @@ class FinanceBatchIsolationTest extends TestCase
private function runBatch(string $directory, array $arguments): Process
{
file_put_contents($directory . '/docker', '#!/bin/sh' . "\n"
. 'if [ "$1" = inspect ]; then echo caiwu_akaunting:123456789abc; exit 0; fi' . "\n"
. 'if [ "$1" = inspect ]; then case "$4" in *Mounts*) echo "$CAIWU_STORAGE_ROOT" ;; *) echo caiwu_akaunting:123456789abc ;; esac; exit 0; fi' . "\n"
. 'printf "%s\n" "image=$IMAGE_TAG" "$@" > "$CAIWU_CAPTURE"' . "\n");
chmod($directory . '/docker', 0700);
$process = new Process(array_merge(['sh', dirname(__DIR__, 3) . '/deploy/run-finance-batch.sh'], $arguments), $directory, [
'PATH' => $directory . ':' . getenv('PATH'), 'CAIWU_REMOTE_DIR' => $directory,
'CAIWU_UPSTREAM_LOCK' => $directory . '/sync.lock', 'CAIWU_BATCH_LOCK_WAIT' => '0.1',
'CAIWU_CAPTURE' => $directory . '/called',
'CAIWU_STORAGE_ROOT' => $directory . '/storage',
]);
$process->run();
return $process;
}
public function test_exports_skip_idle_containers_and_can_read_while_upstream_writer_holds_lock(): void
{
$directory = sys_get_temp_dir() . '/caiwu-export-batch-' . bin2hex(random_bytes(6));
mkdir($directory . '/storage/app/account-bills-exports/1', 0777, true);
$lock = fopen($directory . '/sync.lock', 'c');
flock($lock, LOCK_EX);
$task = $directory . '/storage/app/account-bills-exports/1/task.json';
try {
$this->assertTrue($this->runBatch($directory, ['account-bills:exports:run'])->isSuccessful());
$this->assertFileDoesNotExist($directory . '/called');
file_put_contents($task, json_encode(['status' => 'queued']));
$this->assertTrue($this->runBatch($directory, ['account-bills:exports:run'])->isSuccessful());
$this->assertFileExists($directory . '/called');
} finally {
fclose($lock);
if (is_file($task)) unlink($task);
foreach (['/storage/app/account-bills-exports/1', '/storage/app/account-bills-exports', '/storage/app', '/storage'] as $path) rmdir($directory . $path);
foreach (glob($directory . '/*') as $path) unlink($path);
rmdir($directory);
}
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\AccountBillsExportTasks;
use Tests\TestCase;
class AccountBillsExportQueueTest extends TestCase
{
public function test_contention_retains_queued_tasks_and_one_run_only_consumes_one(): void
{
config(['cache.stores.upstream_shared' => ['driver' => 'array'], 'upstream_pipeline.batch_worker_enabled' => true]);
$directory = sys_get_temp_dir() . '/caiwu-export-queue-' . bin2hex(random_bytes(6));
$tasks = new class($directory) extends AccountBillsExportTasks {
public function __construct(private string $directory) {}
public function rootDir(): string { return $this->directory; }
};
$this->app->instance(AccountBillsExportTasks::class, $tasks);
$a = $tasks->create(11, 'test', '测试', [], [['field' => 'test', 'label' => '测试']], 0);
$b = $tasks->create(11, 'test', '测试', [], [['field' => 'test', 'label' => '测试']], 0);
$a = $tasks->patch($a, ['planned_at' => '2026-10-01 00:00:00', 'cancel' => true]);
$b = $tasks->patch($b, ['planned_at' => '2026-10-02 00:00:00', 'cancel' => true]);
$lock = Cache::store('upstream_shared')->lock('account-bills:export-worker', 10800);
try {
$tasks->dispatch($a);
$this->assertSame('queued', $tasks->find(11, $a['token'])['status']);
$this->assertTrue($lock->get());
$this->artisan('account-bills:exports:run')->assertExitCode(0);
$this->assertSame('queued', $tasks->find(11, $a['token'])['status']);
$this->assertSame('queued', $tasks->find(11, $b['token'])['status']);
$lock->release();
$this->artisan('account-bills:exports:run')->assertExitCode(0);
$this->assertSame('cancelled', $tasks->find(11, $a['token'])['status']);
$this->assertSame('queued', $tasks->find(11, $b['token'])['status']);
$this->artisan('account-bills:exports:run')->assertExitCode(0);
$this->assertSame('cancelled', $tasks->find(11, $b['token'])['status']);
$this->assertNull($tasks->nextQueued());
} finally {
$lock->release();
foreach (glob($directory . '/11/*') ?: [] as $file) unlink($file);
rmdir($directory . '/11');
rmdir($directory);
}
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use App\Console\Commands\RunAccountMappingResync;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
use Tests\TestCase;
class AccountMappingResyncBudgetTest extends TestCase
{
public function test_default_budget_yields_after_one_job_and_retains_edits_received_during_sync(): void
{
config(['cache.stores.upstream_shared' => ['driver' => 'array']]);
$table = AccountMappingResyncDispatcher::JOBS_TABLE;
Schema::dropIfExists($table);
Schema::create($table, function (Blueprint $table): void {
$table->increments('id');
$table->string('platform');
$table->string('month');
$table->string('status');
$table->unsignedInteger('attempts')->default(0);
$table->text('message')->nullable();
$table->timestamps();
});
foreach (['2026-09', '2026-10'] as $month) {
DB::table($table)->insert(['platform' => 'jingxi', 'month' => $month, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
}
$command = new class extends RunAccountMappingResync {
public bool $edited = false;
protected function runSync(object $job): array
{
if ($this->edited) {
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)->where('id', $job->id)->update(['status' => 'pending']);
}
return [true, 'OK'];
}
};
$this->app->make(Kernel::class)->registerCommand($command);
$this->artisan('store-receipt:auto-resync')->assertSuccessful();
$this->assertSame(['done', 'pending'], DB::table($table)->orderBy('id')->pluck('status')->all());
$command->edited = true;
$this->artisan('store-receipt:auto-resync')->assertSuccessful();
$this->assertSame(['done', 'pending'], DB::table($table)->orderBy('id')->pluck('status')->all());
$this->assertSame([1, 1], DB::table($table)->orderBy('id')->pluck('attempts')->all());
}
}

View File

@ -3,11 +3,75 @@
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use ReflectionClass;
use Tests\TestCase;
class AccountMappingResyncDispatcherTest extends TestCase
{
public function test_identical_and_display_only_changes_do_not_require_receipt_rebuilds(): void
{
$rule = ['platform' => '京东', 'item' => '测试货款', 'memo' => '收入', 'basis' => '收入', 'feeMemo' => '说明'];
$this->assertFalse(AccountMappingStore::requiresReceiptResync($rule, $rule));
$this->assertFalse(AccountMappingStore::requiresReceiptResync($rule, array_replace($rule, ['feeMemo' => '更新说明'])));
$this->assertTrue(AccountMappingStore::requiresReceiptResync($rule, array_replace($rule, ['memo' => '退款'])));
$this->assertTrue(AccountMappingStore::requiresReceiptResync($rule, array_replace($rule, ['platform' => '抖音'])));
}
public function test_only_existing_platform_month_pairs_are_enqueued(): void
{
config(['upstream_pipeline.batch_worker_enabled' => true]);
$schema = Schema::connection('business_mirror');
$schema->dropIfExists('store_receipt_order_match_rows');
$schema->create('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->string('platform');
$table->date('report_month');
});
if (! Schema::hasTable(AccountMappingResyncDispatcher::JOBS_TABLE)) {
Schema::create(AccountMappingResyncDispatcher::JOBS_TABLE, function (Blueprint $table): void {
$table->increments('id');
$table->string('platform');
$table->string('month');
$table->string('status');
$table->text('message')->nullable();
$table->timestamps();
});
}
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)->delete();
DB::connection('business_mirror')->table('store_receipt_order_match_rows')->insert([
['platform' => '淘系支付宝', 'report_month' => '2026-09-01'],
['platform' => '淘系微信', 'report_month' => '2026-10-01'],
]);
$this->assertSame(['jobs' => 2], app(AccountMappingResyncDispatcher::class)->dispatchFor(['淘宝']));
$this->assertSame(['2026-09'], DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)->where('platform', 'taobao-alipay')->pluck('month')->all());
$this->assertSame(['2026-10'], DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)->where('platform', 'taobao-wechat')->pluck('month')->all());
}
public function test_reimporting_unchanged_rules_does_not_invalidate_history(): void
{
$originalStorage = $this->app->storagePath();
$directory = sys_get_temp_dir() . '/mapping-resync-' . bin2hex(random_bytes(6));
\Illuminate\Support\Facades\File::makeDirectory($directory . '/app', 0777, true);
$this->app->useStoragePath($directory);
try {
$store = app(AccountMappingStore::class);
$store->reset();
$rows = [['platform' => '京东', 'item' => '测试资源优化项目', 'memo' => '收入']];
$this->assertSame(['京东'], $store->importRows($rows, true)['resync_platforms']);
$same = $store->importRows($rows, true);
$this->assertSame(0, $same['updated']);
$this->assertSame([], $same['resync_platforms']);
$this->assertSame([], $store->importRows([array_replace($rows[0], ['feeMemo' => '更新说明'])], true)['resync_platforms']);
$this->assertSame(['京东'], $store->importRows([array_replace($rows[0], ['basis' => AccountMappingStore::BASIS_IN])], true)['resync_platforms']);
} finally {
$this->app->useStoragePath($originalStorage);
\Illuminate\Support\Facades\File::deleteDirectory($directory);
(new \ReflectionProperty(AccountMappingStore::class, 'allMemo'))->setValue(null);
}
}
public function test_production_leaves_queue_processing_to_the_limited_batch_worker(): void
{
config(['upstream_pipeline.batch_worker_enabled' => true]);

View File

@ -4,6 +4,7 @@ namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
@ -145,6 +146,37 @@ class BusinessMirrorSynchronizerTest extends TestCase
}
}
public function test_key_first_incremental_read_preserves_filters_projection_and_resume_keys(): void
{
$this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => '旧值']]];
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
config(['upstream_mirrors.table_sync.key_first_datasets' => ['test.dbo.records']]);
$this->query->calls = [];
$this->query->batches = [
[['Id' => 1, 'updateTime' => '2026-08-15 10:00:00', 'displayName' => '更新']],
[['Id' => 2, 'updateTime' => '2026-08-15 10:01:00', 'displayName' => '新增']],
[],
];
$this->assertSame('running', $this->synchronizer->sync('test.dbo.records', false, 1, 1)['status']);
$this->assertSame('succeeded', $this->synchronizer->sync('test.dbo.records', false, 1, 3)['status']);
$sql = $this->query->calls[0]['sql'];
$this->assertStringContainsString('FROM (SELECT TOP (1) [Id] FROM [dbo].[records] WHERE ([updateTime] >= ? AND [updateTime] < ?) ORDER BY [Id]) AS [mirror_keys]', $sql);
$this->assertStringContainsString('INNER LOOP JOIN [dbo].[records] AS [mirror_rows] ON [mirror_rows].[Id] = [mirror_keys].[Id]', $sql);
$this->assertStringContainsString('CONVERT(varchar(27), [mirror_rows].[updateTime], 126) AS [updateTime]', $sql);
$this->assertStringContainsString('ORDER BY [mirror_keys].[Id]', $sql);
$this->assertCount(2, $this->query->calls[0]['bindings']);
$this->assertSame(1, $this->query->calls[1]['bindings'][2]);
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
$this->assertSame(2, DB::connection('business_mirror')->table('test_records')->count());
$this->assertSame('更新', DB::connection('business_mirror')->table('test_records')->where('Id', 1)->value('displayName'));
$this->query->calls = [];
$this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-15 10:00:00', 'displayName' => '更新']]];
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
$this->assertStringNotContainsString('LOOP JOIN', $this->query->calls[0]['sql']);
}
public function test_fixed_filters_are_validated_and_bound_to_every_source_batch(): void
{
DB::table('upstream_schema_columns')->insert($this->column('channel', 5, 'nvarchar', 50));
@ -507,6 +539,71 @@ class BusinessMirrorSynchronizerTest extends TestCase
);
}
public function test_upstream_timeout_keeps_previous_data_and_logs_only_safe_diagnostics(): void
{
$this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => '保留']]];
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
$previousSuccess = DB::connection('business_mirror')->table('business_mirror_table_states')->value('last_succeeded_at');
$exception = new \PDOException('secret DSN and financial SQL must never be logged');
$exception->errorInfo = ['HY000', 20003, 'secret native message'];
$this->query->batches = [$exception];
Log::spy();
try {
$this->synchronizer->sync('test.dbo.records', false, 10, 1);
$this->fail('Expected the upstream failure to propagate.');
} catch (\PDOException $caught) {
$this->assertSame($exception, $caught);
}
Log::shouldHaveReceived('warning')->once()->withArgs(function (string $message, array $context): bool {
$this->assertSame('read_upstream', $context['stage']);
$this->assertSame('HY000', $context['sql_state']);
$this->assertSame(20003, $context['driver_code']);
$this->assertStringNotContainsString('secret', json_encode($context));
$this->assertArrayNotHasKey('message', $context);
$this->assertArrayNotHasKey('sql', $context);
return true;
});
$state = DB::connection('business_mirror')->table('business_mirror_table_states')->first();
$this->assertSame('failed', $state->status);
$this->assertSame($previousSuccess, $state->last_succeeded_at);
$this->assertSame('保留', DB::connection('business_mirror')->table('test_records')->value('displayName'));
}
public function test_local_pdo_failure_is_identified_as_a_mirror_write(): void
{
$this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => '保留']]];
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
$exception = new \PDOException('private local database error');
$exception->errorInfo = ['40001', 1213, 'private message'];
$mirror = \Mockery::mock(GenericBusinessMirrorRepository::class, [app('db')])->makePartial();
$mirror->shouldReceive('storeBatch')->once()->andThrow($exception);
$factory = new class($this->query) extends UpstreamReadOnlyQueryFactory {
public function __construct(private readonly UpstreamReadOnlyQuery $query) {}
public function forSource(string $source): UpstreamReadOnlyQuery { return $this->query; }
};
$synchronizer = new BusinessMirrorSynchronizer(new BusinessMirrorDatasetRegistry(), new BusinessMirrorSchemaManager(), $mirror, $factory);
$this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-15 10:00:00', 'displayName' => '未写入']]];
Log::spy();
try {
$synchronizer->sync('test.dbo.records', false, 10, 1);
$this->fail('Expected the local failure to propagate.');
} catch (\PDOException $caught) {
$this->assertSame($exception, $caught);
}
Log::shouldHaveReceived('warning')->once()->withArgs(function (string $message, array $context): bool {
$this->assertSame('write_mirror', $context['stage']);
$this->assertSame('40001', $context['sql_state']);
$this->assertSame(1213, $context['driver_code']);
$this->assertStringNotContainsString('private', json_encode($context));
return true;
});
$this->assertSame('保留', DB::connection('business_mirror')->table('test_records')->value('displayName'));
}
public function test_project_dataset_registry_has_unique_safe_targets(): void
{
$configured = require base_path('config/upstream_mirrors.php');
@ -561,7 +658,7 @@ class BusinessMirrorSynchronizerTest extends TestCase
class FakeBusinessMirrorQuery extends UpstreamReadOnlyQuery
{
/** @var list<list<array<string,mixed>>> */
/** @var list<list<array<string,mixed>>|\Throwable> */
public array $batches = [];
/** @var list<array{sql:string,bindings:array}> */
@ -576,6 +673,11 @@ class FakeBusinessMirrorQuery extends UpstreamReadOnlyQuery
{
$this->calls[] = compact('sql', 'bindings');
return array_shift($this->batches) ?? [];
$batch = array_shift($this->batches) ?? [];
if ($batch instanceof \Throwable) {
throw $batch;
}
return $batch;
}
}

View File

@ -5,7 +5,10 @@ namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Mockery;
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Tests\TestCase;
class CourierSurchargeSettlementMirrorRepositoryTest extends TestCase
@ -165,6 +168,220 @@ class CourierSurchargeSettlementMirrorRepositoryTest extends TestCase
$this->assertStringContainsString('不一致', $result[4]['reason']);
}
public function test_import_derives_courier_and_amount_from_overlength_quote(): void
{
$this->createTable();
$this->seedSettlementPlaceholder();
$this->createSupportTables();
$this->seedQuotes([
['id' => 6, 'courier' => '曹县德邦', 'types' => ['超长费'], 'mode' => '按尺寸', 'condition' => '单边超160cm', 'standard' => '8元/单'],
]);
DB::connection('business_mirror')->table('ops_jst_sku')->insert([
['sku_id' => 'SKU-1', 'l' => 194, 'w' => 83, 'h' => 12.5, 'weight' => 2],
]);
$this->mockUpstream(static function (string $sql): array {
if (str_contains($sql, 'jst_salesOutboundInfo')) {
return [[
'Id' => 1, 'io_id' => 9001, 'o_id' => 7001, 'l_id' => 'DPK9', 'so_id' => 'SO-9',
'shop_name' => '拼多多-测试店', 'io_date' => '2026-10-10 10:00:00', 'logistics_company' => '曹县德邦',
]];
}
if (str_contains($sql, 'jst_salesOutboundItem')) {
return [['io_id' => 9001, 'sku_id' => 'SKU-1', 'i_id' => 'I-1', 'qty' => 1]];
}
if (str_contains($sql, 'Jst_SendDataDetail_View')) {
return [['io_id' => 9001, 'sku_id' => 'SKU-1', 'shop_i_id' => '993815011271']];
}
return [];
});
$result = $this->repository->decideImportRows([
['row_number' => 2, 'waybill' => 'DPK9', 'surcharge_type' => '超长费', 'courier_name' => ''],
]);
$this->assertSame('正常结', $result[0]['decision']);
$this->assertSame('曹县德邦', $result[0]['courier_name']);
$this->assertSame(8.0, (float) $result[0]['settlement_amount']);
$this->assertSame('已计算', $result[0]['settlement_status']);
$this->assertSame(194.0, (float) $result[0]['length']);
$this->assertSame('993815011271', $result[0]['platform_product_id']);
$this->assertSame('拼多多-测试店', $result[0]['shop_name']);
$this->assertStringContainsString('160', (string) $result[0]['evidence']);
}
public function test_import_derives_courier_and_amount_from_deduct_quotes(): void
{
$this->createTable();
$this->seedSettlementPlaceholder();
$this->createSupportTables();
$this->seedQuotes([
['id' => 20, 'courier' => '曹县德邦小件', 'types' => ['退回费'], 'mode' => '固定单价', 'condition' => '查件扣单并且买家原因已退回', 'standard' => '30元/单'],
['id' => 21, 'courier' => '曹县德邦小件', 'types' => ['拦截费'], 'mode' => '固定单价', 'condition' => '查件扣单', 'standard' => '20元/单'],
]);
DB::connection('business_mirror')->table('ops_jst_sku')->insert([
['sku_id' => 'SKU-2', 'l' => 0, 'w' => 0, 'h' => 0, 'weight' => 2.4],
]);
DB::connection('business_mirror')->table('js_jst_work_order_info')->insert([
'Id' => 1,
'title' => '查件',
'businessFieldList' => json_encode([
['fieldId' => 'lIdField', 'value' => 'DPK10'],
['fieldId' => 'logisticsField', 'value' => '曹县德邦小件'],
['fieldId' => 'o_id', 'value' => 7001],
['fieldId' => 'orderField', 'value' => '260710-1'],
['fieldId' => 'shopField', 'valueLabel' => '拼多多-测试店'],
['fieldId' => 'orderSendTime', 'value' => '2026-10-10 09:00:00'],
['fieldId' => 'SELECT_CASCADEField1413856620250813172526', 'value' => '扣单/买家-不想要'],
['fieldId' => 'itemInfo', 'childFieldList' => [[
['fieldId' => 'skuId', 'value' => 'SKU-2'],
['fieldId' => 'qty', 'value' => 1],
['fieldId' => 'platformItemId', 'value' => 'P-2'],
['fieldId' => 'iid', 'value' => 'I-2'],
]]],
], JSON_UNESCAPED_UNICODE),
]);
$this->mockUpstream(static function (string $sql): array {
if (str_contains($sql, 'jst_returnAndRefundInfo')) {
return [[
'o_id' => 7001, 'l_id' => 'DPK10', 'sku_id' => 'SKU-2', 'qty' => 1, 'r_qty' => 1,
'receive_date' => '2026-10-12 09:00:00',
]];
}
return [];
});
$result = $this->repository->decideImportRows([
['row_number' => 2, 'waybill' => 'DPK10', 'surcharge_type' => '退回费', 'courier_name' => ''],
['row_number' => 3, 'waybill' => 'DPK10', 'surcharge_type' => '拦截费', 'courier_name' => ''],
]);
$this->assertSame('正常结', $result[0]['decision']);
$this->assertSame('曹县德邦小件', $result[0]['courier_name']);
$this->assertSame(30.0, (float) $result[0]['settlement_amount']);
$this->assertSame(2.4, (float) $result[0]['settlement_weight']);
$this->assertSame('扣单', $result[0]['inspection_type']);
$this->assertSame('DPK10', $result[0]['return_waybill_no']);
$this->assertSame('正常结', $result[1]['decision']);
$this->assertSame(20.0, (float) $result[1]['settlement_amount']);
}
private function seedSettlementPlaceholder(): void
{
DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([
'rule_id' => 1, 'surcharge_type' => '超长费', 'courier_name' => '占位快递',
'waybill' => 'PLACEHOLDER', 'settlement_amount' => 0, 'settlement_status' => '已计算',
'shipped_at' => '2026-10-01 00:00:00', 'pulled_at' => '2026-10-01 00:00:00',
'created_at' => '2026-10-01 00:00:00', 'updated_at' => '2026-10-01 00:00:00',
]);
}
private function createSupportTables(): void
{
$defaultSchema = Schema::connection((string) config('database.default'));
if (! $defaultSchema->hasTable('business_courier_surcharge_quotes')) {
$defaultSchema->create('business_courier_surcharge_quotes', function (Blueprint $table): void {
$table->increments('id');
$table->string('courier');
$table->string('types');
$table->string('mode');
$table->string('condition');
$table->string('standard');
$table->string('status');
});
}
$schema = Schema::connection('business_mirror');
$schema->create('ops_jst_sku', function (Blueprint $table): void {
$table->string('sku_id');
$table->decimal('l', 12, 2)->default(0);
$table->decimal('w', 12, 2)->default(0);
$table->decimal('h', 12, 2)->default(0);
$table->decimal('weight', 12, 2)->default(0);
});
$schema->create('js_jst_work_order_info', function (Blueprint $table): void {
$table->bigIncrements('Id');
$table->string('title')->nullable();
$table->text('businessFieldList')->nullable();
});
}
/** @param list<array<string,mixed>> $rules */
private function seedQuotes(array $rules): void
{
$table = DB::connection((string) config('database.default'))->table('business_courier_surcharge_quotes');
$table->delete();
$table->insert(
array_map(static fn (array $rule): array => [
'id' => $rule['id'],
'company_id' => 1,
'courier' => $rule['courier'],
'scope' => '快递应付',
'reconciler' => '测试',
'types' => json_encode($rule['types'], JSON_UNESCAPED_UNICODE),
'mode' => $rule['mode'],
'condition' => $rule['condition'],
'standard' => $rule['standard'],
'effective_at' => '2026-01-01',
'status' => 'active',
], $rules),
);
}
/** @param callable(string,array):list<array<string,mixed>> $handler */
private function mockUpstream(callable $handler): void
{
$query = Mockery::mock(UpstreamReadOnlyQuery::class);
$query->shouldReceive('select')->andReturnUsing(
static fn (string $sql, array $bindings = []): array => $handler($sql, $bindings),
);
$factory = Mockery::mock(UpstreamReadOnlyQueryFactory::class);
$factory->shouldReceive('forSource')->andReturn($query);
$this->app->instance(UpstreamReadOnlyQueryFactory::class, $factory);
}
public function test_completed_waybill_index_uses_keyed_lookup_and_preserves_work_order_context(): void
{
$this->createTable();
DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([
'rule_id' => 1, 'surcharge_type' => '退回费', 'courier_name' => '测试快递', 'waybill' => 'UNRELATED',
'settlement_amount' => 0, 'settlement_status' => '已计算', 'pulled_at' => now(), 'created_at' => now(), 'updated_at' => now(),
]);
$schema = Schema::connection('business_mirror');
$schema->create('js_jst_work_order_info', function (Blueprint $table): void {
$table->char('_mirror_key', 64)->primary();
$table->unsignedInteger('Id');
$table->string('title');
$table->text('businessFieldList');
});
config(['system_database.mode' => 'shared']);
(require base_path('database/migrations/2026_10_19_170000_create_work_order_waybill_lookup.php'))->up();
$fields = [
['fieldId' => 'lIdField', 'value' => '001234'],
['fieldId' => 'SELECT_CASCADEField1413856620250813172526', 'value' => '催件/未到'],
['fieldId' => 'soIdField', 'value' => 'SO-TEST'],
];
$db = DB::connection('business_mirror');
$db->table('js_jst_work_order_info')->insert([
'_mirror_key' => str_repeat('a', 64), 'Id' => 10, 'title' => '查件', 'businessFieldList' => json_encode($fields),
]);
app(\Modules\EcommerceFinance\Services\WorkOrderWaybillLookup::class)->backfill(500, 1, 0);
$db->enableQueryLog();
$results = $this->repository->decideImportRows([
['waybill' => '001234', 'surcharge_type' => '退回费'],
]);
$this->assertSame('催件', $results[0]['inspection_type']);
$this->assertStringContainsString('查件', $results[0]['reason']);
$queries = collect($db->getQueryLog())->pluck('query')->implode("\n");
$this->assertStringContainsString('work_order_waybill_lookup', $queries);
$this->assertStringNotContainsString('businessFieldList" like', $queries);
}
private function createTable(): void
{
Schema::connection('business_mirror')->create(

View File

@ -20,6 +20,8 @@ class JdDailyBillTest extends TestCase
config(['database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]);
DB::purge('business_mirror');
(require database_path('migrations/2026_09_18_140000_create_jd_daily_bill_mirror_tables.php'))->up();
config(['system_database.mode' => 'shared']);
(require database_path('migrations/2026_10_19_181000_cover_jd_daily_bill_summary_filters.php'))->up();
}
protected function tearDown(): void
@ -90,6 +92,23 @@ class JdDailyBillTest extends TestCase
app(JdDailyBillRepository::class)->paginate([], 'occurred_at', 'asc', 1, 10);
}
public function test_paging_reuses_summary_and_options_but_reimport_invalidates_them(): void
{
$this->import([$this->row('123', '10.00'), $this->row('124', '2.00')], 2, '12.00', '0.00');
$repo = app(JdDailyBillRepository::class);
$repo->paginate([], 'occurred_at', 'asc', 1, 1);
$repo->filterOptions();
$db = DB::connection('business_mirror');
$db->enableQueryLog();
$this->assertSame(12.0, $repo->paginate([], 'occurred_at', 'asc', 2, 1)['summary']['income']);
$repo->filterOptions();
$queries = strtolower(collect($db->getQueryLog())->pluck('query')->implode("\n"));
$this->assertStringNotContainsString('sum(income)', $queries);
$this->assertStringNotContainsString('select distinct', $queries);
$this->import([$this->row('123', '25.00')], 1, '25.00', '0.00');
$this->assertSame(25.0, $repo->paginate([], 'occurred_at', 'asc', 1, 1)['summary']['income']);
}
public function test_cutoff_validates_rows_and_refuses_to_shorten_existing_coverage(): void
{
$row = array_replace($this->row('123', '10.00'), ['费用结算时间' => '2026-08-21 23:59:59']);

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

@ -136,6 +136,21 @@ class OrderDataMirrorRepositoryTest extends TestCase
$none = $repository->afterSales(array_replace($base, ['shop_names' => ['没有这个店铺']]), 1, 25);
$this->assertSame([], $none['rows']);
\Illuminate\Support\Facades\Cache::put(OrderDataMirrorRepository::SHOP_NAMES_CACHE_KEY, ['拼多多-甲店', '京东-乙店', '抖音-丙店'], 300);
$db->enableQueryLog();
$selected = $repository->afterSales(array_replace($base, ['shop_names' => ['拼多多-甲店'], 'shop_names_exact' => true]), 1, 25);
$this->assertSame(['拼多多-甲店'], $this->sortedShops($selected['rows']));
$this->assertDoesNotMatchRegularExpression('/shop_name\s+LIKE/i', collect($db->getQueryLog())->pluck('query')->implode("\n"));
Cache::forget(OrderDataMirrorRepository::SHOP_NAMES_CACHE_KEY);
$db->table('ops_jst_shop_info')->insert(['shop_id' => 1, 'shop_name' => '拼多多-甲店']);
$db->flushQueryLog();
$selected = $repository->afterSales(array_replace($base, ['shop_names' => ['拼多多-甲店'], 'shop_names_exact' => true]), 1, 25);
$this->assertSame(['拼多多-甲店'], $this->sortedShops($selected['rows']));
$this->assertDoesNotMatchRegularExpression('/shop_name\s+LIKE/i', collect($db->getQueryLog())->pluck('query')->implode("\n"));
// Array inputs containing partial names still retain the original contains semantics.
$partial = $repository->afterSales(array_replace($base, ['shop_names' => ['甲店'], 'shop_names_exact' => true]), 1, 25);
$this->assertSame(['拼多多-甲店'], $this->sortedShops($partial['rows']));
}
public function test_shop_names_lists_distinct_shops_from_the_order_mirror(): void

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

@ -148,6 +148,11 @@ class PlantingLossCalculatorTest extends TestCase
$this->assertTrue(Calculator::isFreeGoods('XN001', '信誉订单虚拟条码'));
$this->assertTrue(Calculator::isFreeGoods('GG003-SZ', '透明挂钩3个'));
$this->assertFalse(Calculator::isFreeGoods('DNZ-01', '儿童学习椅'));
$this->assertTrue(Calculator::isFreeGoods('tcxj', '书柜 晒图有奖励(无实物)'));
$this->assertTrue(Calculator::isVirtualOrderItem('X00006', ''));
$this->assertTrue(Calculator::isVirtualOrderItem('GG003-SZ', '透明挂钩3个'));
$this->assertTrue(Calculator::isVirtualOrderItem('GG-005', '挂钩'));
$this->assertFalse(Calculator::isVirtualOrderItem('DNZ-01', '儿童学习椅'));
$this->assertSame(10050, Calculator::deductionCents('100.5', 1));
$this->assertSame(20100, Calculator::deductionCents('100.5', 2));
}
@ -167,10 +172,14 @@ class PlantingLossCalculatorTest extends TestCase
$this->workOrder(10, 'GD-VIRTUAL', '5555', '2026-09-20 10:00:00');
$this->workOrder(11, 'GD-NOCOST', '6666', '2026-09-21 10:00:00');
$this->workOrder(12, 'GD-MERGED', '7777', '2026-09-22 10:00:00');
$this->workOrder(13, 'GD-HOOK', '8888', '2026-09-23 10:00:00');
// 未匹配到发货但备注拍A发B:同样列入不扣款(发运营)。
$this->workOrder(14, 'GD-PAB-NOSHIP', '9090', '2026-09-24 10:00:00', [], '拍A发B');
// 一单两个包裹:学习椅 ×2 + 透明挂钩(挂钩不扣)。
// 一单两个包裹:学习椅 ×2 + 无实物商品(不计货值)。
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT001', 40.6, 'DNZ-01', '儿童学习椅', 2);
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT002', 10, 'GG003-SZ', '透明挂钩3个');
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT002', 10, 'XS-01', '晒图有奖励(无实物)');
$this->ship('3604408018648198', '2026-09-30 23:59:59');
$this->ship('260901-000000000000001', '2026-09-01 08:00:00');
$this->ship('9999', '2026-07-31 23:59:59'); // 早于上月 1 日
@ -178,22 +187,35 @@ class PlantingLossCalculatorTest extends TestCase
$this->ship('5555', '2026-09-10 08:00:00', 'YT555', 0, 'XN-001', '信誉订单虚拟条码');
$this->ship('5555', '2026-09-10 08:00:00', 'YT555', 0, 'GG003-SZ', '透明挂钩3个');
$this->ship('6666', '2026-09-10 08:00:00', 'YT666', 0, 'ZERO', '无成本商品');
// 合并发货:同一交易单号下有信誉订单虚拟条码 + 别的订单的实物,整单不扣。
$this->ship('7777', '2026-09-11 08:00:00', 'YT777', 0, 'X00006', '信誉订单虚拟条码');
$this->ship('7777', '2026-09-11 08:00:00', 'YT777', 0, 'DNZ-01', '儿童学习椅');
// 实物 + 透明挂钩:整单不扣。
$this->ship('8888', '2026-09-12 08:00:00', 'YT888', 0, 'DNZ-01', '儿童学习椅');
$this->ship('8888', '2026-09-12 08:00:00', 'YT888', 0, 'GG003-SZ', '透明挂钩3个');
$this->ship('6666', '2026-09-10 08:00:00', 'YT666', 0, 'DNZ-02', '组合商品');
$rows = collect($this->app->make(Calculator::class)->rows('2026-09'))->keyBy('work_order_no');
$this->assertSame(['GD-DEDUCT', 'GD-EXEMPT', 'GD-NOCOST', 'GD-SELLER', 'GD-UNMATCHED', 'GD-VIRTUAL'], $rows->keys()->sort()->values()->all());
$this->assertSame(['GD-DEDUCT', 'GD-EXEMPT', 'GD-HOOK', 'GD-MERGED', 'GD-NOCOST', 'GD-PAB-NOSHIP', 'GD-SELLER', 'GD-UNMATCHED', 'GD-VIRTUAL'], $rows->keys()->sort()->values()->all());
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-DEDUCT']['loss_result']);
$this->assertSame("YT001\nYT002", $rows['GD-DEDUCT']['express_no']);
$this->assertSame('50.60', $rows['GD-DEDUCT']['shipped_amount']);
$this->assertSame('3', $rows['GD-DEDUCT']['shipped_qty']);
// 取最新一条普通商品成本价 100.5:100.5 × 2 = 201.00,挂钩不计货值;加该单运费 3.20 × 2 个包裹 = 6.40。
// 取最新一条普通商品成本价 100.5:100.5 × 2 = 201.00,无实物不计货值;加该单运费 3.20 × 2 个包裹 = 6.40。
$this->assertSame('207.40', $rows['GD-DEDUCT']['deduction_amount']);
$this->assertSame("DNZ-01 × 2 × 100.5 = 201.00\n运费 6.40", $rows['GD-DEDUCT']['deduction_detail']);
$this->assertFalse($rows['GD-DEDUCT']['cost_missing']);
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-VIRTUAL']['loss_result']);
$this->assertNull($rows['GD-VIRTUAL']['deduction_amount']);
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-MERGED']['loss_result']);
$this->assertNull($rows['GD-MERGED']['deduction_amount']);
$this->assertStringContainsString('信誉订单虚拟条码', $rows['GD-MERGED']['loss_basis']);
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-HOOK']['loss_result']);
$this->assertNull($rows['GD-HOOK']['deduction_amount']);
$this->assertSame(Calculator::RESULT_EXEMPT, $rows['GD-PAB-NOSHIP']['loss_result']);
$this->assertSame('备注含“拍A发B”(未匹配到发货)', $rows['GD-PAB-NOSHIP']['loss_basis']);
// 成本价为 0、或只有组合装资料的条码视为成本价缺失。
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-NOCOST']['loss_result']);

View File

@ -0,0 +1,226 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use App\Console\Commands\SyncStoreReceiptOrderMatches;
use Illuminate\Database\Connectors\ConnectionFactory;
use Illuminate\Database\MySqlConnection;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use ReflectionMethod;
use Tests\TestCase;
class StoreReceiptMemoryTest extends TestCase
{
private function bill(string $item, string $order, float $income, float $expense = 0, array $extra = []): object
{
return (object) array_merge([
'store_name' => '支付宝-测试账户', 'online_real_store' => '天猫_测试店',
'bill_item' => $item, 'online_order_no' => $order,
'occurred_at' => '2026-08-01 10:00:00', 'income_amount' => $income, 'expense_amount' => $expense,
], $extra);
}
private function aggregate(iterable $rows, array $planting = [], ?AccountLedgerRemarkMap $map = null): array
{
$map ??= new class extends AccountLedgerRemarkMap {
public function remark(?string $storeName, ?string $tabPlatform, ?string $billItem, array $businessRemarks = [], string $goodsName = ''): ?string
{
return $billItem;
}
};
return (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'aggregateBills'))->invoke(
new SyncStoreReceiptOrderMatches(), $rows, ['label' => '淘系支付宝'], 'online_order_no', 'occurred_at',
'income_amount', 'expense_amount', 'online_real_store', $planting, [], [], $map, '淘宝',
);
}
public function test_one_pass_keeps_payments_promotions_refunds_and_first_bill_metadata(): void
{
$bills = [
$this->bill('优惠收入', '260630-10001', 0, 3),
$this->bill('订单收入', '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),
$this->bill('冲减收入', '', 3),
$this->bill('退款', '', 0, 2),
$this->bill('订单收入', '30003', 0),
$this->bill('不计入', '40004', 999),
];
// A generator cannot be rewound. Promotions precede their eventual payment.
$result = $this->aggregate((function () use ($bills) { yield from $bills; })(), ['10001' => true]);
$key = SyncStoreReceiptOrderMatches::normalizeShop('天猫_测试店');
$this->assertCount(2, $result['groups']);
$paid = $result['groups'][$key . '|260630-10001'];
$this->assertSame(2, $paid['payment_count']);
$this->assertSame(125.0, $paid['income_amount']);
$this->assertSame(-3.0, $paid['discount_amount']);
$this->assertSame(-2.0, $paid['offset_amount']);
$this->assertSame(-7.0, $paid['refund_amount']);
$this->assertTrue($paid['planting_seen']);
$this->assertSame('260630-10001', $paid['online_order_no']);
$this->assertSame('2026-08-01', $paid['occurred_date']);
$this->assertSame('天猫_测试店', $paid['store_name']);
$this->assertSame(0.0, $result['groups'][$key . '|30003']['income_amount']);
$this->assertSame(3, $result['paymentTotal']);
$this->assertSame(12, $result['billCount']);
$this->assertSame(8, $result['moneyRemarkRows']);
$this->assertSame(125.0, $result['incomeTotal']);
$this->assertSame(1.0, $result['discountTotal']);
$this->assertSame(-7.0, $result['offsetTotal']);
$this->assertSame(-10.0, $result['refundTotal']);
$this->assertSame(-3.0, $result['attachedDiscount']);
$this->assertSame(-2.0, $result['attachedOffset']);
$this->assertSame(3, $result['unattachedRows']);
$this->assertSame([$key . '|20002', $key . '||冲减收入', $key . '||退款'], array_keys($result['promoOnlyGroups']));
$this->assertSame(4.0, $result['promoOnlyGroups'][$key . '|20002']['discount_amount']);
$this->assertSame(-1.0, $result['promoOnlyGroups'][$key . '|20002']['refund_amount']);
$this->assertSame(-5.0, $result['promoOnlyGroups'][$key . '||冲减收入']['offset_amount']);
$this->assertSame(-2.0, $result['promoOnlyGroups'][$key . '||退款']['refund_amount']);
}
public function test_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([
$this->bill('订单收入', '', 12),
$this->bill('优惠收入', '', 2, 0, ['occurred_at' => '2026-08-03 10:00:00']),
$this->bill('优惠收入', '50005', 3),
$this->bill('优惠收入', '50005', 4, 0, ['online_real_store' => '另一家店']),
]);
$this->assertCount(1, $result['groups']);
$this->assertCount(3, $result['promoOnlyGroups']);
$first = reset($result['promoOnlyGroups']);
$this->assertSame('2026-08-03', $first['occurred_date']);
$this->assertSame(2.0, $first['discount_amount']);
}
public function test_business_remark_and_goods_name_are_forwarded_once_per_bill(): void
{
$map = $this->createMock(AccountLedgerRemarkMap::class);
$map->expects($this->once())->method('remark')->with(
'支付宝-测试账户', '淘宝', '动态项目', ['活动优惠'], '商品关键词',
)->willReturn('优惠收入');
$result = $this->aggregate([$this->bill('动态项目', '', 1.5, 0, [
'custom_remark' => '活动优惠', 'second_remark' => '活动优惠', 'goods_name' => '商品关键词',
])], [], $map);
$this->assertSame(1.5, $result['discountTotal']);
$this->assertSame([], $result['groups']);
$this->assertCount(1, $result['promoOnlyGroups']);
$this->assertSame(0, $this->aggregate([])['billCount']);
}
public function test_stream_uses_bound_filter_and_sql_limit_in_sqlite(): void
{
$db = DB::connection('business_mirror');
$db->getSchemaBuilder()->create('receipt_memory_test', function (\Illuminate\Database\Schema\Blueprint $table): void {
$table->integer('id');
$table->string('name');
});
$db->table('receipt_memory_test')->insert([['id' => 1, 'name' => 'a'], ['id' => 2, 'name' => 'b'], ['id' => 3, 'name' => 'c']]);
$query = $db->table('receipt_memory_test')->where('id', '>', 1)->orderBy('id')->limit(1);
$rows = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'streamBillRows'))->invoke(new SyncStoreReceiptOrderMatches(), $query);
$this->assertSame([2], array_map(static fn (object $row) => $row->id, iterator_to_array($rows)));
}
public function test_other_source_columns_and_missing_optional_columns_keep_their_amounts(): void
{
$map = $this->createMock(AccountLedgerRemarkMap::class);
$map->expects($this->once())->method('remark')->with('京东-测试店', '京东', '货款(收)', [], '')->willReturn('订单收入');
$result = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'aggregateBills'))->invoke(
new SyncStoreReceiptOrderMatches(), [(object) ['store_name' => '京东-测试店', 'order_no' => '70007',
'bill_date' => '2026-08-09', 'income' => 12.5, 'bill_item' => '货款(收)']],
['label' => '京东'], 'order_no', 'bill_date', 'income', '', '', [], [], [], $map, '京东',
);
$group = reset($result['groups']);
$this->assertSame(12.5, $result['incomeTotal']);
$this->assertSame('2026-08-09', $group['occurred_date']);
$this->assertSame('京东-测试店', $group['store_name']);
$this->assertSame([], $result['promoOnlyGroups']);
}
public function test_mysql_stream_uses_separate_connection_and_closes_it_after_consumer_error(): void
{
$normal = $this->createMock(MySqlConnection::class);
$normal->method('getConfig')->willReturn(['driver' => 'mysql', 'options' => [\PDO::ATTR_TIMEOUT => 3]]);
$query = new \Illuminate\Database\Query\Builder($normal,
new \Illuminate\Database\Query\Grammars\MySqlGrammar(), new \Illuminate\Database\Query\Processors\MySqlProcessor());
$query->from('receipt_memory_test')->where('id', '>', 1)->limit(1);
$stream = $this->createMock(MySqlConnection::class);
$stream->expects($this->once())->method('cursor')->with($query->toSql(), [1])->willReturnCallback(function () {
yield (object) ['id' => 2];
});
$stream->expects($this->once())->method('disconnect');
$normal->expects($this->never())->method('setReadPdo');
$normal->expects($this->never())->method('disconnect');
$factory = $this->createMock(ConnectionFactory::class);
$factory->expects($this->once())->method('make')->with($this->callback(function (array $config): bool {
return $config['options'][\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY] === false && $config['options'][\PDO::ATTR_TIMEOUT] === 3;
}), 'receipt_bill_stream')->willReturn($stream);
$this->app->instance('db.factory', $factory);
$rows = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'streamBillRows'))->invoke(new SyncStoreReceiptOrderMatches(), $query);
try {
foreach ($rows as $row) {
$this->assertSame(2, $row->id);
throw new \RuntimeException('consumer aborted');
}
} catch (\RuntimeException $exception) {
$this->assertSame('consumer aborted', $exception->getMessage());
} finally {
unset($rows);
}
}
}

View File

@ -0,0 +1,125 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\StoreReportUpstreamRepository;
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
use Modules\EcommerceFinance\Services\StoreReportPaidSalesChanges;
use Tests\TestCase;
class StoreReportPaidSalesIncrementalTest extends TestCase
{
private function setupSummary(): StoreReportMirrorSynchronizer
{
config(['database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]);
DB::purge('business_mirror');
config(['cache.stores.upstream_shared' => ['driver' => 'array'], 'system_database.mode' => 'shared']);
$schema = Schema::connection('business_mirror');
foreach (['store_report_import_states', 'store_report_paid_sales_rows', 'store_receipt_order_match_rows', StoreReportPaidSalesChanges::TABLE] as $table) $schema->dropIfExists($table);
(require base_path('database/migrations/2026_09_01_000000_create_store_report_mirror_tables.php'))->up();
$schema->table('store_report_import_states', function (Blueprint $table): void {
$table->string('run_mode')->nullable();
$table->timestamp('initial_completed_at')->nullable();
});
(require base_path('database/migrations/2026_10_16_234000_create_store_report_paid_sales_rows.php'))->up();
$schema->create('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->increments('id');
$table->string('platform');
$table->date('report_month');
$table->date('occurred_date');
$table->string('store_name');
foreach (['income_amount', 'discount_amount', 'offset_amount', 'refund_amount'] as $column) $table->decimal($column, 20, 4)->nullable();
});
(require base_path('database/migrations/2026_10_19_171000_track_paid_sales_dirty_dates.php'))->up();
DB::connection('business_mirror')->table('store_receipt_order_match_rows')->insert([
['platform' => 'A', 'report_month' => '2026-10-01', 'occurred_date' => '2026-10-01', 'store_name' => '测试店', 'income_amount' => 100, 'discount_amount' => -5, 'offset_amount' => 0, 'refund_amount' => -10],
['platform' => 'B', 'report_month' => '2026-10-01', 'occurred_date' => '2026-10-01', 'store_name' => '测试店', 'income_amount' => 20, 'discount_amount' => 0, 'offset_amount' => 0, 'refund_amount' => 0],
['platform' => 'A', 'report_month' => '2026-09-01', 'occurred_date' => '2026-09-30', 'store_name' => '测试店', 'income_amount' => 40, 'discount_amount' => 0, 'offset_amount' => 0, 'refund_amount' => 0],
]);
return new StoreReportMirrorSynchronizer(\Mockery::mock(StoreReportUpstreamRepository::class), app('db'));
}
public function test_amount_change_without_row_count_change_only_rebuilds_affected_days(): void
{
$sync = $this->setupSummary();
$db = DB::connection('business_mirror');
$this->assertSame('succeeded', $sync->syncPaidSales()['status']);
$untouchedId = $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-09-30')->value('id');
$changes = app(StoreReportPaidSalesChanges::class);
$changes->markPeriod('2026-10', 'A');
$db->table('store_receipt_order_match_rows')->where('id', 1)->update(['income_amount' => 120]);
$this->assertTrue($sync->syncPaidSales()['changed']);
$this->assertSame(125.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount'));
$this->assertSame($untouchedId, $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-09-30')->value('id'));
$db->enableQueryLog();
$this->assertFalse($sync->syncPaidSales()['changed']);
$this->assertSame([], collect($db->getQueryLog())->filter(fn ($query) => str_contains($query['query'], 'store_receipt_order_match_rows'))->all());
}
public function test_date_moves_and_deletions_clear_old_totals_and_include_other_platforms(): void
{
$sync = $this->setupSummary();
$db = DB::connection('business_mirror');
$sync->syncPaidSales();
$changes = app(StoreReportPaidSalesChanges::class);
$changes->markPeriod('2026-10', 'A');
$db->table('store_receipt_order_match_rows')->where('id', 1)->update(['occurred_date' => '2026-10-03']);
$changes->markPeriod('2026-10', 'A');
$sync->syncPaidSales();
$this->assertSame(20.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount'));
$this->assertSame(85.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-03')->value('paid_amount'));
$changes->markPeriod('2026-10', 'B');
$db->table('store_receipt_order_match_rows')->where('platform', 'B')->delete();
$sync->syncPaidSales();
$this->assertSame(0, $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->count());
$this->assertSame(125.0, (float) $db->table('store_report_paid_sales_rows')->sum('paid_amount'));
}
public function test_a_new_revision_is_not_lost_when_an_older_snapshot_is_acknowledged(): void
{
$this->setupSummary();
$changes = app(StoreReportPaidSalesChanges::class);
$changes->markDates(['2026-10-01']);
$old = $changes->pending();
$changes->markDates(['2026-10-01']);
$changes->acknowledge($old);
$this->assertCount(1, $changes->pending());
}
public function test_active_writer_defers_summary_and_other_writers_without_consuming_changes(): void
{
$sync = $this->setupSummary();
$sync->syncPaidSales();
$changes = app(StoreReportPaidSalesChanges::class);
$changes->markDates(['2026-10-01']);
$lock = Cache::store('upstream_shared')->lock('store-report:paid-sales', 3600);
$this->assertTrue($lock->get());
try {
$this->assertFalse($sync->syncPaidSales()['changed']);
$this->assertCount(1, $changes->pending());
$this->artisan('store-receipt:order-match:sync', ['month' => '2026-10'])->assertExitCode(75);
} finally {
$lock->release();
}
$this->assertTrue($sync->syncPaidSales()['changed']);
$this->assertSame([], $changes->pending());
}
public function test_aggregation_failure_keeps_previous_totals_and_dirty_dates(): void
{
$sync = $this->setupSummary();
$db = DB::connection('business_mirror');
$sync->syncPaidSales();
app(StoreReportPaidSalesChanges::class)->markDates(['2026-10-01']);
$db->listen(function ($query): void {
if (str_starts_with($query->sql, 'INSERT INTO store_report_paid_sales_rows')) throw new \RuntimeException('test_aggregate_failure');
});
$this->assertSame('failed', $sync->syncPaidSales()['status']);
$this->assertSame(105.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount'));
$this->assertCount(1, app(StoreReportPaidSalesChanges::class)->pending());
}
}

Some files were not shown because too many files have changed in this diff Show More