Compare commits
15 Commits
fix/codex-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5633ed2430 | |||
| d68548646d | |||
|
|
0ec6f703fe | ||
| a317ab9dea | |||
| db08b2c264 | |||
|
|
32cd0d0a15 | ||
| 8162afa969 | |||
|
|
bcb29aac1c | ||
| d1b751c79c | |||
|
|
fce31f0c09 | ||
| acad1f4577 | |||
| ad8da1264d | |||
| 6470346392 | |||
| f08c600961 | |||
|
|
5fe92bd05c |
280
app/Console/Commands/ImportCourierQuoteBillingRules.php
Normal file
280
app/Console/Commands/ImportCourierQuoteBillingRules.php
Normal 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')));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -23,9 +23,9 @@ use Throwable;
|
||||
*
|
||||
* 口径(已与业务确认):
|
||||
* - 账单侧:按「总账备注」筛选(与店铺收款账户账单页同一口径),
|
||||
* 再按「店铺 + 清洗后订单号」汇总收入金额与收款笔数
|
||||
* 再按「店铺 + 完整线上单号」汇总收入金额与收款笔数
|
||||
* - 订单侧:销售全量按「店铺 + 线上订单号」先聚合求和(一单多品/多子订单在此合并),金额取 goods_count > 0 的有效行
|
||||
* - 单号清洗:去除日期前缀(如 260630-)、空格、横杠后按核心号匹配
|
||||
* - 单号匹配:优先完整线上单号精确匹配;仅当核心号唯一时允许兼容回退,多个完整单号共用核心号时禁止合并
|
||||
* - 店铺:账单用「线上真实店铺」优先,订单侧用销售全量店铺名,统一把下划线归一为横杠
|
||||
* - 同一店铺同一订单号多笔收款 → 标注「同单多笔收款」,商品金额只挂一次
|
||||
*/
|
||||
@ -448,15 +448,17 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(商品金额求和) */
|
||||
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 完整线上单号」聚合的商品金额(商品金额求和) */
|
||||
$orderAmount = [];
|
||||
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 核心单号」(含 0 元行) */
|
||||
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 完整线上单号」(含 0 元行) */
|
||||
$orderSeen = [];
|
||||
/** @var array<string,array<string,true>> $coresByCore 核心单号 → 出现过的店铺集合 */
|
||||
$coresByCore = [];
|
||||
/** @var array<string,array<string,array<string,true>>> $identitiesByCore 核心单号 → 店铺 → 完整线上单号 */
|
||||
$identitiesByCore = [];
|
||||
/** @var array<string,true> $coreSeen 订单侧出现过的核心单号(含店铺名为空的行) */
|
||||
$coreSeen = [];
|
||||
/** @var array<string,float> $orderVirtualPaid 虚拟条码(X0000x)明细行的实付金额合计(店铺 + 核心单号) */
|
||||
/** @var array<string,float> $orderVirtualPaid 虚拟条码(X0000x)明细行的实付金额合计(店铺 + 完整线上单号) */
|
||||
$orderVirtualPaid = [];
|
||||
// 订单侧改用「订单原始数据」(企微客户订单 + 聚水潭订单),按线上订单号取商品金额合计。
|
||||
// 两个来源数据量大(企微 500 万单 / 690 万行),先用临时表收敛候选单号,
|
||||
@ -575,19 +577,21 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
$this->info('订单侧命中(店铺 × 订单)行数:' . count($orderRows) . ',实付行数:' . count($paidRows));
|
||||
|
||||
/** @var array<string,float> $orderPaid 订单侧实付金额(店铺 + 核心单号) */
|
||||
/** @var array<string,float> $orderPaid 订单侧实付金额(店铺 + 完整线上单号) */
|
||||
$orderPaid = [];
|
||||
/** @var array<string,float> $orderPaidAlt 订单侧「卖家实收」金额(双口径兜底用) */
|
||||
$orderPaidAlt = [];
|
||||
/** @var array<string,float> $orderPaidPay 订单侧「应付」金额(实收缺失时的退化口径) */
|
||||
$orderPaidPay = [];
|
||||
foreach ($paidRows as $row) {
|
||||
$core = self::cleanOrderNo((string) $row['so_id']);
|
||||
$rawNo = (string) $row['so_id'];
|
||||
$core = self::cleanOrderNo($rawNo);
|
||||
$identity = self::orderIdentity($rawNo);
|
||||
if ($core === '') {
|
||||
continue;
|
||||
}
|
||||
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
|
||||
$key = $shopKey . '|' . $core;
|
||||
$key = $shopKey . '|' . $identity;
|
||||
$orderPaid[$key] = ($orderPaid[$key] ?? 0.0) + (float) $row['paid'];
|
||||
$orderPaidAlt[$key] = ($orderPaidAlt[$key] ?? 0.0) + (float) ($row['paid_alt'] ?? 0.0);
|
||||
$orderPaidPay[$key] = ($orderPaidPay[$key] ?? 0.0) + (float) ($row['paid_pay'] ?? 0.0);
|
||||
@ -596,6 +600,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
// 商品金额数组仍只由商品明细构建:无明细时商品金额保持为空、按口径标「订单无商品金额」。
|
||||
$orderSeen[$key] = true;
|
||||
$coreSeen[$core] = true;
|
||||
$identitiesByCore[$core][$shopKey][$identity] = true;
|
||||
if ($shopKey !== '') {
|
||||
$coresByCore[$core][$shopKey] = true;
|
||||
}
|
||||
@ -603,14 +608,17 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
|
||||
unset($paidRows, $row);
|
||||
foreach ($orderRows as $row) {
|
||||
$core = self::cleanOrderNo((string) $row['so_id']);
|
||||
$rawNo = (string) $row['so_id'];
|
||||
$core = self::cleanOrderNo($rawNo);
|
||||
$identity = self::orderIdentity($rawNo);
|
||||
if ($core === '') {
|
||||
continue;
|
||||
}
|
||||
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
|
||||
$key = $shopKey . '|' . $core;
|
||||
$key = $shopKey . '|' . $identity;
|
||||
$orderSeen[$key] = true;
|
||||
$coreSeen[$core] = true;
|
||||
$identitiesByCore[$core][$shopKey][$identity] = true;
|
||||
if ($shopKey !== '') {
|
||||
$coresByCore[$core][$shopKey] = true;
|
||||
}
|
||||
@ -665,21 +673,25 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
// 两个口径各跑一遍同一条取数链(同店同号 → 店铺名不一致 → 同号多条 → 空店铺兜底):
|
||||
// 商品金额用于「商品金额」列,订单实付用于「订单实付金额」列与比对基准。
|
||||
[$productStatus, $productAmount, $productRemark] = $this->matchOrderSide(
|
||||
(string) $group['online_order_no'],
|
||||
$core,
|
||||
(string) $group['shop_key'],
|
||||
$orderAmount,
|
||||
$orderSeen,
|
||||
$coreSeen,
|
||||
$coresByCore,
|
||||
$identitiesByCore,
|
||||
self::REMARK_NO_AMOUNT,
|
||||
);
|
||||
[$paidStatus, $paidAmount, $paidRemark] = $this->matchOrderSide(
|
||||
(string) $group['online_order_no'],
|
||||
$core,
|
||||
(string) $group['shop_key'],
|
||||
$orderPaidNet,
|
||||
$orderSeen,
|
||||
$coreSeen,
|
||||
$coresByCore,
|
||||
$identitiesByCore,
|
||||
self::REMARK_NO_PAID,
|
||||
);
|
||||
|
||||
@ -706,12 +718,14 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
// 「先备注规则、后口径兜底」的顺序判定),避免把有明确原因的行悄悄抹平。
|
||||
{
|
||||
[$altStatus, $altAmount] = $this->matchOrderSide(
|
||||
(string) $group['online_order_no'],
|
||||
$core,
|
||||
(string) $group['shop_key'],
|
||||
$orderPaidAlt,
|
||||
$orderSeen,
|
||||
$coreSeen,
|
||||
$coresByCore,
|
||||
$identitiesByCore,
|
||||
self::REMARK_NO_PAID,
|
||||
);
|
||||
$altMatched = $altStatus === self::STATUS_MATCHED && $altAmount !== null;
|
||||
@ -722,12 +736,14 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
// 或主单没写实收而子单写了值的情况都覆盖到)。
|
||||
if (! ($altMatched && abs($income + $promoAmount - $altValue) <= 0.02)) {
|
||||
[$payStatus, $payValue] = $this->matchOrderSide(
|
||||
(string) $group['online_order_no'],
|
||||
$core,
|
||||
(string) $group['shop_key'],
|
||||
$orderPaidPay,
|
||||
$orderSeen,
|
||||
$coreSeen,
|
||||
$coresByCore,
|
||||
$identitiesByCore,
|
||||
self::REMARK_NO_PAID,
|
||||
);
|
||||
if ($payStatus === self::STATUS_MATCHED && $payValue !== null) {
|
||||
@ -764,7 +780,12 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
$buffer[] = [
|
||||
'report_month' => $reportMonth,
|
||||
'platform' => $group['platform'],
|
||||
'row_key' => md5($reportMonth . '|' . $group['platform'] . '|' . $group['shop_key'] . '|' . $core),
|
||||
'row_key' => self::groupRowKey(
|
||||
$reportMonth,
|
||||
(string) $group['platform'],
|
||||
(string) $group['shop_key'],
|
||||
(string) $group['order_identity']
|
||||
),
|
||||
'occurred_date' => $group['occurred_date'],
|
||||
'store_name' => $group['store_name'],
|
||||
'online_order_no' => $group['online_order_no'] !== '' ? $group['online_order_no'] : null,
|
||||
@ -1021,7 +1042,12 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowKey = md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|' . $group['order_core_no']);
|
||||
$rowKey = self::groupRowKey(
|
||||
$reportMonth,
|
||||
$platform,
|
||||
(string) $group['shop_key'],
|
||||
(string) $group['order_identity']
|
||||
);
|
||||
if (! isset($existing[$rowKey])) {
|
||||
continue;
|
||||
}
|
||||
@ -1204,9 +1230,10 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
|
||||
$rawNo = trim((string) ($bill->{$orderColumn} ?? ''));
|
||||
$core = self::cleanOrderNo($rawNo);
|
||||
$identity = self::orderIdentity($rawNo);
|
||||
$realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : '';
|
||||
$shopKey = self::normalizeShop($realStore !== '' ? $realStore : (string) $bill->store_name);
|
||||
$key = $shopKey . '|' . $core;
|
||||
$key = $shopKey . '|' . $identity;
|
||||
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
@ -1216,6 +1243,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
'shop_key' => $shopKey,
|
||||
'online_order_no' => $rawNo,
|
||||
'order_core_no' => $core,
|
||||
'order_identity' => $identity,
|
||||
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
|
||||
'payment_count' => 0,
|
||||
'income_amount' => 0.0,
|
||||
@ -1235,13 +1263,14 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
// 同一遍读取同时累计补行,避免把整月原始账单留到第二遍。
|
||||
// 首笔元数据、无单号按账单项目拆分、收入行随后出现的语义与原有补行一致。
|
||||
if ($bucket !== 'income') {
|
||||
$promoKey = $core === '' ? $shopKey . '||' . $billItem : $key;
|
||||
$promoKey = $identity === '' ? $shopKey . '||' . $billItem : $key;
|
||||
if (! isset($promoOnlyGroups[$promoKey])) {
|
||||
$promoOnlyGroups[$promoKey] = [
|
||||
'shop_key' => $shopKey,
|
||||
'store_name' => $realStore !== '' ? $realStore : $storeName,
|
||||
'online_order_no' => $rawNo,
|
||||
'order_core_no' => $core,
|
||||
'order_identity' => $identity,
|
||||
'bill_item' => $billItem,
|
||||
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
|
||||
'discount_amount' => 0.0,
|
||||
@ -1308,7 +1337,7 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
// A 方案:挂不到订单行的优惠/冲减也补成行(收入/商品/差额留空),金额不丢。
|
||||
foreach ($promoOnlyGroups as $key => $group) {
|
||||
if ($group['order_core_no'] !== '' && isset($groups[$group['shop_key'] . '|' . $group['order_core_no']])) {
|
||||
if ($group['order_identity'] !== '' && isset($groups[$group['shop_key'] . '|' . $group['order_identity']])) {
|
||||
unset($promoOnlyGroups[$key]);
|
||||
}
|
||||
}
|
||||
@ -1327,9 +1356,10 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
*/
|
||||
private static function promoOnlyRow(string $reportMonth, string $platform, array $group, mixed $now): array
|
||||
{
|
||||
$identity = (string) ($group['order_identity'] ?? '');
|
||||
$core = (string) $group['order_core_no'];
|
||||
$rowKey = $core !== ''
|
||||
? md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|' . $core)
|
||||
$rowKey = $identity !== ''
|
||||
? self::groupRowKey($reportMonth, $platform, (string) $group['shop_key'], $identity)
|
||||
: md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|promo|' . $group['bill_item']);
|
||||
|
||||
return [
|
||||
@ -1403,6 +1433,31 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
return self::orderCoreNo($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整线上单号身份:保留日期前缀,只统一空白、横杠形态和大小写。
|
||||
*
|
||||
* 拼多多的「260627-...」「260713-...」是两个不同订单,不能按去掉
|
||||
* 日期前缀后的核心号合并;完整单号用于账单分组与订单侧精确匹配,
|
||||
* 核心号只在唯一候选时作为兼容回退。
|
||||
*/
|
||||
private static function orderIdentity(string $value): string
|
||||
{
|
||||
$value = str_replace("\u{3000}", ' ', trim($value));
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = preg_replace('/[\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', '-', $value) ?? $value;
|
||||
$value = preg_replace('/\s+/u', '', $value) ?? '';
|
||||
|
||||
return strtoupper($value);
|
||||
}
|
||||
|
||||
private static function groupRowKey(string $reportMonth, string $platform, string $shopKey, string $identity): string
|
||||
{
|
||||
return md5($reportMonth . '|' . $platform . '|' . $shopKey . '|' . $identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 种菜订单号集合:种菜明细表的订单号 ∪ 聚水潭订单标签明细里「是否种菜订单=是」的订单号。
|
||||
*
|
||||
@ -1558,11 +1613,13 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* 按「店铺 + 订单号」在订单侧取金额,返回 [匹配状态, 订单侧金额, 原因标注]。
|
||||
* 按「店铺 + 完整线上单号」在订单侧取金额,返回 [匹配状态, 订单侧金额, 原因标注]。
|
||||
*
|
||||
* 两个比对基准共用这一条取数链:
|
||||
* 同店同号 → 店铺名不一致(订单号唯一)→ 同号多条(多店铺合计)→ 空店铺兜底 → 订单表无此单。
|
||||
* 同店完整单号 → 同店核心号唯一时回退 → 跨店唯一时回退 → 空店铺唯一时回退。
|
||||
* 核心号一旦对应多个完整线上单号就视为歧义,不再合并金额,避免不同订单串单。
|
||||
*
|
||||
* @param array<string,array<string,array<string,true>>> $identitiesByCore 核心单号 → 店铺 → 完整线上单号
|
||||
* @param array<string,float> $amounts 订单侧金额集合(商品金额或订单实付)
|
||||
* @param array<string,bool> $orderSeen 订单侧出现过的「店铺|单号」
|
||||
* @param array<string,bool> $coreSeen 订单侧出现过的单号
|
||||
@ -1570,12 +1627,14 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
* @return array{0:string,1:?float,2:string}
|
||||
*/
|
||||
private function matchOrderSide(
|
||||
string $onlineOrderNo,
|
||||
string $core,
|
||||
string $shopKey,
|
||||
array $amounts,
|
||||
array $orderSeen,
|
||||
array $coreSeen,
|
||||
array $coresByCore,
|
||||
array $identitiesByCore,
|
||||
string $noAmountRemark,
|
||||
): array {
|
||||
if ($core === '') {
|
||||
@ -1586,8 +1645,9 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
return [self::STATUS_UNMATCHED, null, self::REMARK_BAD_FORMAT];
|
||||
}
|
||||
|
||||
$exactKey = $shopKey . '|' . $core;
|
||||
if (isset($orderSeen[$exactKey])) {
|
||||
$identity = self::orderIdentity($onlineOrderNo);
|
||||
$exactKey = $shopKey . '|' . $identity;
|
||||
if ($identity !== '' && isset($orderSeen[$exactKey])) {
|
||||
if (isset($amounts[$exactKey])) {
|
||||
$remark = count($coresByCore[$core] ?? []) > 1 ? self::REMARK_DUP_ORDER : '';
|
||||
|
||||
@ -1598,41 +1658,43 @@ class SyncStoreReceiptOrderMatches extends Command
|
||||
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
|
||||
}
|
||||
|
||||
if (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) === 1) {
|
||||
// 单号一致但店铺名称对不上:仍然带上金额,标注「店铺不一致」便于复核。
|
||||
$otherKey = array_key_first($coresByCore[$core]) . '|' . $core;
|
||||
if (isset($amounts[$otherKey])) {
|
||||
return [self::STATUS_MATCHED, round((float) $amounts[$otherKey], 2), self::REMARK_SHOP_DIFF];
|
||||
if (isset($coreSeen[$core])) {
|
||||
$sameShopIdentities = array_keys($identitiesByCore[$core][$shopKey] ?? []);
|
||||
if (count($sameShopIdentities) > 1) {
|
||||
return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER];
|
||||
}
|
||||
if (count($sameShopIdentities) === 1) {
|
||||
$fallbackKey = $shopKey . '|' . $sameShopIdentities[0];
|
||||
if (isset($amounts[$fallbackKey])) {
|
||||
$remark = count($coresByCore[$core] ?? []) > 1 ? self::REMARK_DUP_ORDER : '';
|
||||
|
||||
return [self::STATUS_MATCHED, round((float) $amounts[$fallbackKey], 2), $remark];
|
||||
}
|
||||
|
||||
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
|
||||
}
|
||||
|
||||
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
|
||||
}
|
||||
$shops = array_keys($identitiesByCore[$core] ?? []);
|
||||
if (count($shops) === 1) {
|
||||
$otherShop = (string) $shops[0];
|
||||
$otherIdentities = array_keys($identitiesByCore[$core][$otherShop] ?? []);
|
||||
if (count($otherIdentities) > 1) {
|
||||
return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER];
|
||||
}
|
||||
if (count($otherIdentities) === 1) {
|
||||
$otherKey = $otherShop . '|' . $otherIdentities[0];
|
||||
if (isset($amounts[$otherKey])) {
|
||||
$remark = $otherShop === '' ? '' : self::REMARK_SHOP_DIFF;
|
||||
|
||||
if (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) > 1) {
|
||||
$sum = 0.0;
|
||||
$hasAmount = false;
|
||||
foreach (array_keys($coresByCore[$core]) as $otherShop) {
|
||||
$key2 = $otherShop . '|' . $core;
|
||||
if (isset($amounts[$key2])) {
|
||||
$sum += (float) $amounts[$key2];
|
||||
$hasAmount = true;
|
||||
return [self::STATUS_MATCHED, round((float) $amounts[$otherKey], 2), $remark];
|
||||
}
|
||||
|
||||
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
|
||||
}
|
||||
}
|
||||
if ($hasAmount) {
|
||||
return [self::STATUS_MATCHED, round($sum, 2), self::REMARK_DUP_ORDER];
|
||||
}
|
||||
|
||||
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
|
||||
}
|
||||
|
||||
if (isset($coreSeen[$core])) {
|
||||
// 订单侧只有店铺名为空的记录:按订单号兜底匹配。
|
||||
$blankKey = '|' . $core;
|
||||
if (isset($amounts[$blankKey])) {
|
||||
return [self::STATUS_MATCHED, round((float) $amounts[$blankKey], 2), ''];
|
||||
}
|
||||
|
||||
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
|
||||
// 同一核心号跨多个店铺或同一店铺多个完整单号:禁止把金额相加。
|
||||
return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER];
|
||||
}
|
||||
|
||||
return [self::STATUS_UNMATCHED, null, self::REMARK_NO_ORDER];
|
||||
|
||||
@ -34,6 +34,9 @@ class SyncStoreReceiptProductCost extends Command
|
||||
/** 虚拟条码(信誉订单/虚拟订单)不是真实商品,不计入商品成本。 */
|
||||
private const VIRTUAL_SKU_PREFIX = 'X0000';
|
||||
|
||||
/** 套餐 / 服务占位编码:不是实物商品,商品成本不取数。 */
|
||||
private const EXCLUDED_SKUS = ['TCXJ', 'FUWU'];
|
||||
|
||||
private const REMARK_NO_WAREHOUSE = '未匹配仓库简称';
|
||||
|
||||
private const REMARK_NO_PRICE = '未匹配商品单价';
|
||||
@ -117,10 +120,15 @@ class SyncStoreReceiptProductCost extends Command
|
||||
// 账单侧取数:业绩明细里本月(可按平台限定)的账单行,一行 = 一个店铺的一个订单。
|
||||
// 只取「收入金额有数据且不为 0」的账单行:收入为 0 的行是补出来的优惠/冲减/无收款账单行,
|
||||
// 没有对应的收款商品,不参与商品成本。
|
||||
// 「种菜金额」有数据(>0)的账单行整条排除,不参与匹配。
|
||||
$billQuery = $mirror->table(self::SOURCE_TABLE)
|
||||
->where('report_month', $reportMonth)
|
||||
->whereNotNull('income_amount')
|
||||
->where('income_amount', '!=', 0);
|
||||
->where('income_amount', '!=', 0)
|
||||
->where(static function ($query): void {
|
||||
$query->whereNull('planting_amount')
|
||||
->orWhere('planting_amount', '<=', 0);
|
||||
});
|
||||
if ($platform !== '') {
|
||||
$billQuery->where('platform', $platform);
|
||||
}
|
||||
@ -171,6 +179,7 @@ class SyncStoreReceiptProductCost extends Command
|
||||
|
||||
/** @var array<string,list<array{sku:string,qty:float,amount:float,warehouse:string,is_main:bool,row_key:string}>> $lines */
|
||||
$lines = [];
|
||||
$excludedSkuSql = "'" . implode("','", self::EXCLUDED_SKUS) . "'";
|
||||
foreach (self::SOURCES as $source) {
|
||||
try {
|
||||
$rows = $mirror->select(
|
||||
@ -184,7 +193,8 @@ class SyncStoreReceiptProductCost extends Command
|
||||
STRAIGHT_JOIN {$source['orders']} o ON o.so_id = t.no
|
||||
STRAIGHT_JOIN {$source['items']} i ON i.o_id = o.o_id
|
||||
LEFT JOIN ops_jst_warehouse_info wh ON wh.wms_co_id = o.wms_co_id
|
||||
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'"
|
||||
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'
|
||||
AND UPPER(TRIM(COALESCE(i.sku_id, ''))) NOT IN ({$excludedSkuSql})"
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('订单明细查询失败(' . $source['items'] . '):' . $exception->getMessage());
|
||||
@ -265,9 +275,7 @@ class SyncStoreReceiptProductCost extends Command
|
||||
|
||||
foreach ($billByCore as $core => $bill) {
|
||||
foreach ($this->costLines($lines[$core] ?? []) as $line) {
|
||||
$short = $shortNames['by_warehouse'][$line['warehouse']]
|
||||
?? $shortNames['by_provider'][$line['warehouse']]
|
||||
?? '';
|
||||
$short = $shortNames['by_provider'][$line['warehouse']] ?? '';
|
||||
$price = $short !== '' ? ($prices[$short . "\0" . $line['sku']] ?? null) : null;
|
||||
$priceSource = $price === null ? null : self::PRICE_SOURCE_WEIGHTED;
|
||||
// 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。
|
||||
@ -459,10 +467,14 @@ class SyncStoreReceiptProductCost extends Command
|
||||
|
||||
$expanded = [];
|
||||
foreach ($components as [$code, $multiplier]) {
|
||||
// 组合装里如果拆出套餐/服务占位编码,同样不取数。
|
||||
if (in_array(mb_strtoupper(trim($code)), self::EXCLUDED_SKUS, true)) {
|
||||
continue;
|
||||
}
|
||||
$expanded[] = [$code, $quantity * $multiplier, true];
|
||||
}
|
||||
|
||||
return $expanded;
|
||||
return $expanded === [] ? [] : $expanded;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -476,7 +488,6 @@ class SyncStoreReceiptProductCost extends Command
|
||||
private function warehouseShortNames(): array
|
||||
{
|
||||
$byProvider = [];
|
||||
$byWarehouse = [];
|
||||
foreach (DB::table(self::WAREHOUSE_MAPPING_TABLE)
|
||||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']) as $mapping) {
|
||||
$provider = trim((string) $mapping->warehouse_provider);
|
||||
@ -485,19 +496,30 @@ class SyncStoreReceiptProductCost extends Command
|
||||
if ($short === '') {
|
||||
continue;
|
||||
}
|
||||
if ($warehouse !== '' && ! isset($byWarehouse[$warehouse])) {
|
||||
$byWarehouse[$warehouse] = $short;
|
||||
}
|
||||
if ($provider === '') {
|
||||
continue;
|
||||
}
|
||||
// 只认「仓储方 + 仓库含主仓」的那条:发货仓库是仓储方级别的名称,
|
||||
// 次品仓 / 销退仓 / 客退仓这些行的简称(多为「-不看」)不能套到主仓发货上。
|
||||
$isMain = str_contains($warehouse, '主仓');
|
||||
if ($isMain || ! isset($byProvider[$provider])) {
|
||||
if ($isMain) {
|
||||
$byProvider[$provider] = $short;
|
||||
continue;
|
||||
}
|
||||
// 映射表没有主仓行时,仅接受「仓库名 = 仓储方名」这种同名仓库(如重庆渝北仓),
|
||||
// 其余(次品仓、配件单仓等)不再兜底,避免把主仓订单误判成不看仓。
|
||||
if ($warehouse === $provider && ! isset($byProvider[$provider])) {
|
||||
$byProvider[$provider] = $short;
|
||||
continue;
|
||||
}
|
||||
// 业务确认:常州简尚家居有限公司的发货仓对应映射表里的「配件单仓」行
|
||||
// (仓储方=常州简尚家居有限公司 / 仓库=配件单仓 → 配件单仓-不看)。
|
||||
if (str_contains($warehouse, '配件单仓') && ! isset($byProvider[$provider])) {
|
||||
$byProvider[$provider] = $short;
|
||||
}
|
||||
}
|
||||
|
||||
return ['by_provider' => $byProvider, 'by_warehouse' => $byWarehouse];
|
||||
return ['by_provider' => $byProvider];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -75,8 +75,8 @@ class VerifyErpDailyBillCoverage extends Command
|
||||
$month = $from->copy()->startOfMonth();
|
||||
while ($month->lte($to)) {
|
||||
$monthEnd = $month->copy()->endOfMonth();
|
||||
$requiredStart = $month->lt($from) ? $from->copy() : $month->copy();
|
||||
$requiredThrough = $monthEnd->gt($to) ? $to->copy() : $monthEnd->copy();
|
||||
$requiredStart = ($month->lt($from) ? $from->copy() : $month->copy())->startOfDay();
|
||||
$requiredThrough = ($monthEnd->gt($to) ? $to->copy() : $monthEnd->copy())->startOfDay();
|
||||
$requiredEndExclusive = $requiredThrough->copy()->addDay();
|
||||
$period = $db->table($target['periods'])
|
||||
->where('period_start', $month->toDateString())
|
||||
|
||||
@ -372,6 +372,10 @@ class Pages extends Controller
|
||||
$jdSofiaGuobu = '(' . $jdSofiaPay . ' - COALESCE(income_amount, 0)) > 0.005';
|
||||
$rules['差额=订单实付包含商家承担优惠和以旧换新国补'] = $jdSofiaGuard . ' AND ' . $jdSofiaGuobu;
|
||||
$rules['差额=订单实付包含商家承担优惠'] = $jdSofiaGuard . ' AND NOT (' . $jdSofiaGuobu . ')';
|
||||
// 拼多多全额退款:先于通用「未减优惠/冲减」判定,避免抵减项把退款单误标成其他原因。
|
||||
$pddFullRefundHit = "platform = '拼多多'"
|
||||
. ' AND ABS(COALESCE(income_amount, 0) + COALESCE(refund_amount, 0)) <= 0.02';
|
||||
$rules['订单实付含已退款部分'] = $pddFullRefundHit;
|
||||
// C 方案:这三条是"通用等式档"——口径(卖家实收 / 应付已付)能对上账单的行让位给口径兜底
|
||||
// (归零 + 口径标识),只有口径也对不上时才显示这三档,避免把口径差标成一堆"未减优惠/冲减"。
|
||||
$notBasisMarked = self::receiptReasonNotBasisMarkedSql();
|
||||
@ -410,7 +414,18 @@ class Pages extends Controller
|
||||
. ' OR ' . $refundGapHit($refundEvidence['cancelled_sum'])
|
||||
. ' OR ' . $refundGapNetHit($refundEvidence['refund_sum'])
|
||||
. ' OR ' . $refundFullHit($refundEvidence['refund_sum'])
|
||||
. ') ELSE 0 END = 1) OR ' . $taoRefundHit;
|
||||
. ') ELSE 0 END = 1) OR ' . $taoRefundHit . ' OR ' . $pddFullRefundHit;
|
||||
|
||||
// 拼多多部分退款:账单收入高于聚水潭买家实付,且扣除优惠/冲减后仍有正向小额差额,
|
||||
// 与「多多支付立减」等平台支付类补贴一致;只标注原因,不改差额和收入。
|
||||
$pddPaymentDiscount = '(' . $effective . ' - COALESCE(discount_amount, 0) - COALESCE(offset_amount, 0))';
|
||||
$rules['差额=平台支付立减'] = "platform = '拼多多'"
|
||||
. " AND match_status = '已匹配'"
|
||||
. ' AND ' . $effective . ' > 0.005'
|
||||
. ' AND COALESCE(refund_amount, 0) < -0.005'
|
||||
. ' AND ' . $pddPaymentDiscount . ' > 0.005'
|
||||
. ' AND ' . $pddPaymentDiscount . ' <= 20.005'
|
||||
. ' AND ' . $notBasisMarked;
|
||||
|
||||
// 1688 先采后付订单的订单实付包含平台红包补贴,但支付宝实际到账是扣除补贴后的现金;
|
||||
// 仅对已匹配、负差额、无优惠/冲减/退款且当前没有口径标识的行标注,不改差额金额。
|
||||
@ -601,6 +616,11 @@ class Pages extends Controller
|
||||
if (abs($product) < 0.005) {
|
||||
return ($row->planting_amount ?? null) !== null ? $label . '取数为0(种菜)' : $label . '取数为0';
|
||||
}
|
||||
// 拼多多全额退款优先于其它抵减等式:退款后实际净收款为 0,不应再落成优惠/冲减异常。
|
||||
if (($row->platform ?? '') === '拼多多'
|
||||
&& abs($income + (float) ($row->refund_amount ?? 0)) <= 0.02) {
|
||||
return '订单实付含已退款部分';
|
||||
}
|
||||
// 得物负差额:差额与联合营销费未让利尾数吻合(营销费支出 + 差额恰为整数)→「订单实付未减联合营销费」。
|
||||
// 放在通用「未减冲减/优惠」等式之前:得物这类行的差额常恰好等于冲减(营销费)金额,需优先给出具体项备注。
|
||||
if (($row->platform ?? '') === '得物' && $difference < -0.005) {
|
||||
@ -707,6 +727,17 @@ class Pages extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 拼多多部分退款:差额扣除优惠/冲减后,剩余部分与平台支付立减一致。
|
||||
$pddPaymentDiscount = $difference - $discount - $offset;
|
||||
if (($row->platform ?? '') === '拼多多'
|
||||
&& ! $basisMarked
|
||||
&& $difference > 0.005
|
||||
&& (float) ($row->refund_amount ?? 0) < -0.005
|
||||
&& $pddPaymentDiscount > 0.005
|
||||
&& $pddPaymentDiscount <= 20.005) {
|
||||
return '差额=平台支付立减';
|
||||
}
|
||||
|
||||
// 原因优先:上面的规则都没命中时,如果这行的口径(卖家实收 / 应付已付)能对上账单,
|
||||
// 就按口径兜底——差额按 0 展示、口径标识在「匹配原因」列,不再当异常。
|
||||
$rawRemark = trim((string) ($row->remark ?? ''));
|
||||
@ -1661,8 +1692,8 @@ class Pages extends Controller
|
||||
// 联动统计要跑几次全表聚合,短时间内重复请求(页面加载 + 下拉刷新)直接吃缓存
|
||||
$cacheKey = 'performance-details-facets:' . md5(serialize([$month, $filters, $orderNo]));
|
||||
|
||||
// 数据按天同步,缓存 10 分钟:大幅降低大平台(拼多多等)的联动等待。
|
||||
return response()->json(cache()->remember($cacheKey, now()->addSeconds(600), function () use ($month, $filters, $orderNo) {
|
||||
// 数据按天同步;联动统计会扫全月,缓存 1 小时,避免相同筛选反复等待。
|
||||
return response()->json(cache()->remember($cacheKey, now()->addHour(), function () use ($month, $filters, $orderNo) {
|
||||
return [
|
||||
'month' => substr($month, 0, 7),
|
||||
'facets' => $this->receiptOrderMatchFacets($month, $filters, $orderNo),
|
||||
@ -2178,8 +2209,6 @@ class Pages extends Controller
|
||||
'online_order_no' => trim((string) request('online_order_no', '')),
|
||||
'product_code' => trim((string) request('product_code', '')),
|
||||
'warehouse_short_name' => trim((string) request('warehouse_short_name', '')),
|
||||
'missing_price' => request()->boolean('missing_price') ? '1' : '',
|
||||
'include_excluded' => request()->boolean('include_excluded') ? '1' : '',
|
||||
];
|
||||
|
||||
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
|
||||
@ -2199,7 +2228,9 @@ class Pages extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
$applyFilters = static function ($query, bool $excludedWarehouseOnly = false) use ($filters): void {
|
||||
// 页面不再提供「只看未匹配单价 / 包含-不看仓」两个开关:默认全部数据展示,
|
||||
// 已匹配与未匹配单价、所有仓库(含「-不看」仓)都纳入统计与列表。
|
||||
$applyFilters = static function ($query) use ($filters): void {
|
||||
$query->where('report_month', $filters['month'] . '-01');
|
||||
if ($filters['platform'] !== '') {
|
||||
$query->where('platform', $filters['platform']);
|
||||
@ -2212,32 +2243,12 @@ class Pages extends Controller
|
||||
if ($filters['warehouse_short_name'] !== '') {
|
||||
$query->where('warehouse_short_name', $filters['warehouse_short_name']);
|
||||
}
|
||||
if ($filters['missing_price'] === '1') {
|
||||
$query->whereNull('unit_price');
|
||||
}
|
||||
// 仓库映射表里标「不看」的仓(次品 / 销退 / 虚拟 / 信誉 / 京东云仓等)默认不展示:
|
||||
// 这些仓在商品月加权平均单价里本来就没有价,展示出来只会让「未匹配单价」虚高。
|
||||
if ($excludedWarehouseOnly) {
|
||||
$query->where('warehouse_short_name', 'like', '%-不看%');
|
||||
|
||||
return;
|
||||
}
|
||||
if ($filters['include_excluded'] !== '1') {
|
||||
$query->where(static function ($inner): void {
|
||||
$inner->whereNull('warehouse_short_name')
|
||||
->orWhere('warehouse_short_name', 'not like', '%-不看%');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$countQuery = $base();
|
||||
$applyFilters($countQuery);
|
||||
$total = (int) $countQuery->count();
|
||||
|
||||
$excludedQuery = $base();
|
||||
$applyFilters($excludedQuery, true);
|
||||
$excludedRows = (int) $excludedQuery->count();
|
||||
|
||||
$aggQuery = $base();
|
||||
$applyFilters($aggQuery);
|
||||
$agg = $aggQuery->selectRaw('
|
||||
@ -2291,7 +2302,6 @@ class Pages extends Controller
|
||||
'missing_price_rows' => (int) ($agg->missing_price_rows ?? 0),
|
||||
'missing_price_quantity' => round((float) ($agg->missing_price_quantity ?? 0), 2),
|
||||
'missing_warehouse_rows' => (int) ($agg->missing_warehouse_rows ?? 0),
|
||||
'excluded_rows' => $excludedRows,
|
||||
'fallback_price_rows' => (int) ($agg->fallback_price_rows ?? 0),
|
||||
'catalog_price_rows' => (int) ($agg->catalog_price_rows ?? 0),
|
||||
'unit_mismatch_rows' => (int) ($agg->unit_mismatch_rows ?? 0),
|
||||
@ -2573,15 +2583,7 @@ class Pages extends Controller
|
||||
if ($warehouse !== '') {
|
||||
$query->where('warehouse_short_name', $warehouse);
|
||||
}
|
||||
if ($request->boolean('missing_price')) {
|
||||
$query->whereNull('unit_price');
|
||||
}
|
||||
if (! $request->boolean('include_excluded')) {
|
||||
$query->where(static function ($inner): void {
|
||||
$inner->whereNull('warehouse_short_name')
|
||||
->orWhere('warehouse_short_name', 'not like', '%-不看%');
|
||||
});
|
||||
}
|
||||
// 导出与页面一致:全部数据(含未匹配单价与「-不看」仓)。
|
||||
|
||||
$filename = '商品成本_' . ($month === null ? 'all' : substr($month, 0, 7)) . '.csv';
|
||||
|
||||
|
||||
@ -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, '.', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
157
app/Support/CourierQuoteFreightCalculator.php
Normal file
157
app/Support/CourierQuoteFreightCalculator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
43
deploy/install-release-protection.sh
Normal file
43
deploy/install-release-protection.sh
Normal 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
72
deploy/release-guard.sh
Normal 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
|
||||
}
|
||||
4
deploy/retired-activation.sh
Normal file
4
deploy/retired-activation.sh
Normal 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
|
||||
@ -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`
|
||||
|
||||
@ -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` 启动即报“拒绝访问”。
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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[] = '';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ use Modules\EcommerceFinance\Services\SupplierStatementStore;
|
||||
|
||||
/**
|
||||
* 「供应商对账」页面数据:
|
||||
* - 财务端数据:按供应商汇总 + 行级差异(条码 + 单价 匹配,四类结果带结算口径)
|
||||
* - 财务端数据:按供应商汇总 + 行级差异(采购单号 + 日期 + 条码匹配,四类结果带结算口径)
|
||||
* - 厂家端数据:厂家上传的对账单(实时)与只读的退货明细
|
||||
*/
|
||||
class SupplierReconciliationPageData
|
||||
@ -23,6 +23,9 @@ class SupplierReconciliationPageData
|
||||
/** 退货明细行较高:每页 10 行,并在页面内固定表头滚动查看。 */
|
||||
private const RETURN_PER_PAGE = 10;
|
||||
|
||||
/** 财务端比对明细:每页 15 条。 */
|
||||
private const COMPARISON_PER_PAGE = 15;
|
||||
|
||||
public function __construct(
|
||||
private readonly SupplierPayableOverrideStore $store,
|
||||
private readonly SupplierStatementStore $statements,
|
||||
@ -109,17 +112,31 @@ class SupplierReconciliationPageData
|
||||
}
|
||||
|
||||
// 选中厂家时页面切换成「比对明细」视图(列表不再渲染,避免 100+ 家时往下翻)。
|
||||
$selected = trim((string) request()->query('supplier', ''));
|
||||
$supplierQuery = trim((string) request()->query('supplier', ''));
|
||||
$selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : '';
|
||||
$supplierNotFound = $supplierQuery !== '' && $selected === '';
|
||||
$detail = null;
|
||||
if ($selected !== '' && in_array($selected, $supplierNames, true)) {
|
||||
if ($selected !== '') {
|
||||
$detail = $this->detail($mine, $selected, $companyId);
|
||||
} else {
|
||||
$selected = '';
|
||||
$totalRows = count($detail['rows']);
|
||||
$pages = max((int) ceil($totalRows / self::COMPARISON_PER_PAGE), 1);
|
||||
$currentPage = min(max((int) request()->query('compare_page', 1), 1), $pages);
|
||||
$detail['total_rows'] = $totalRows;
|
||||
$detail['page'] = $currentPage;
|
||||
$detail['pages'] = $pages;
|
||||
$detail['per_page'] = self::COMPARISON_PER_PAGE;
|
||||
$detail['rows'] = array_slice(
|
||||
$detail['rows'],
|
||||
($currentPage - 1) * self::COMPARISON_PER_PAGE,
|
||||
self::COMPARISON_PER_PAGE,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.supplier-reconciliation.finance-data',
|
||||
'supplierNames' => $supplierNames,
|
||||
'supplierQuery' => $supplierQuery,
|
||||
'supplierNotFound' => $supplierNotFound,
|
||||
'uploads' => $uploads,
|
||||
'months' => $months,
|
||||
'month' => $month,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -92,14 +92,6 @@
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<label class="product-cost-check">
|
||||
<input type="checkbox" name="missing_price" value="1" @checked(($filters['missing_price'] ?? '') === '1')>
|
||||
只看未匹配商品单价
|
||||
</label>
|
||||
<label class="product-cost-check">
|
||||
<input type="checkbox" name="include_excluded" value="1" @checked(($filters['include_excluded'] ?? '') === '1')>
|
||||
包含「-不看」仓
|
||||
</label>
|
||||
<div class="product-cost-actions">
|
||||
<button class="product-cost-button primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
|
||||
<a class="product-cost-button" href="{{ route('business.financial-operations.product-costs.export', array_merge(['company_id' => company_id()], request()->except('page'))) }}" aria-label="导出当前筛选结果" title="导出当前筛选结果"><span class="material-icons-outlined" aria-hidden="true">download</span>导出</a>
|
||||
@ -113,14 +105,18 @@
|
||||
|
||||
<div class="product-cost-note">
|
||||
口径:以「业绩明细」的账单行为基数(发生时间 / 店铺名称 / 线上单号),按「店铺 + 线上订单号」展开订单侧商品行,
|
||||
只取收入金额有数据且不为 0 的账单行,一行 = 一个发货仓库的一个商品;发货仓库取订单的聚水潭仓库名称,仓库简称查仓库映射表,
|
||||
只取收入金额有数据且不为 0 的账单行,「种菜金额」有数据(>0)的账单行整条排除、不参与匹配;
|
||||
一行 = 一个发货仓库的一个商品;发货仓库取订单的聚水潭仓库名称,
|
||||
仓库简称按仓库映射表用「仓储方」匹配「仓库名含主仓」的那条取简称(没有主仓行时认同名仓库,
|
||||
常州简尚家居有限公司对应映射表里的「配件单仓」行),
|
||||
商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价;匹配不到时退回「线上代发价格」
|
||||
(采购单价优先、其次钉钉代发单价),仍匹配不到时用「普通商品资料」里的商品成本价兜底;商品成本 = 商品数量 × 商品单价。
|
||||
拆单子单重复列出的同一批商品只算一次,同一单分多仓发货的按子单实际仓库分行;虚拟条码(X0000x)不计入。
|
||||
拆单子单重复列出的同一批商品只算一次,同一单分多仓发货的按子单实际仓库分行;虚拟条码(X0000x)不计入;
|
||||
套餐/服务占位编码 TCXJ、FUWU 不取数(组合装里拆出的这两个编码同样剔除)。
|
||||
定制配件这类明细行,聚水潭把配件件数写进了「商品数量」(例如数量 9524、金额 2000.04,实际单价 0.21),
|
||||
乘成品的月加权/代发单价会放大几百倍;成本单价高于该行明细单价 3 倍时判为「数量与单价口径不一致」,成本留空、不计入合计。
|
||||
仓库映射表里标记为「-不看」的仓库(次品 / 销退 / 虚拟 / 信誉订单 / 京东云仓等)默认不展示,
|
||||
需要核对时可以勾选「包含『-不看』仓」。
|
||||
页面默认展示全部数据:已匹配/未匹配商品单价的行、仓库映射表里标记为「-不看」的仓
|
||||
(次品 / 销退 / 虚拟 / 信誉订单 / 京东云仓等)都纳入列表与统计。
|
||||
</div>
|
||||
|
||||
<div class="product-cost-summary">
|
||||
@ -133,7 +129,6 @@
|
||||
<div class="item"><span>其中用商品资料成本价兜底</span><b>{{ number_format((int) ($summary['catalog_price_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>数量与单价口径不一致(不计成本)</span><b>{{ number_format((int) ($summary['unit_mismatch_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item {{ ($summary['missing_warehouse_rows'] ?? 0) > 0 ? 'is-warn' : '' }}"><span>未匹配仓库简称行数</span><b>{{ number_format((int) ($summary['missing_warehouse_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>已隐藏的「-不看」仓行数</span><b>{{ number_format((int) ($summary['excluded_rows'] ?? 0)) }}</b></div>
|
||||
</div>
|
||||
|
||||
<div class="product-cost-table-wrap">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -36,6 +36,9 @@
|
||||
.supplier-reconciliation-page .sr-summary-cell { padding: 10px 12px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; display: grid; gap: 4px; }
|
||||
.supplier-reconciliation-page .sr-summary-cell b { font-size: 1.125rem; font-weight: 600; }
|
||||
.supplier-reconciliation-page .sr-summary-cell span { color: var(--vip-content-muted, #595959); font-size: .75rem; }
|
||||
.supplier-reconciliation-page .sr-comparison-scroll { max-height: min(62vh, 560px); overflow: auto; scrollbar-gutter: stable; }
|
||||
.supplier-reconciliation-page .sr-comparison-scroll thead th { position: sticky; top: 0; z-index: 4; box-shadow: 0 1px 0 var(--vip-border, #D8D2C7); }
|
||||
.supplier-reconciliation-page .sr-pagination { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--vip-content-muted, #595959); font-size: .8125rem; }
|
||||
.supplier-reconciliation-page .sr-robot-config { padding: 10px 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; }
|
||||
.supplier-reconciliation-page .sr-robot-config summary { cursor: pointer; color: #5B5343; font-size: .8125rem; font-weight: 600; }
|
||||
.supplier-reconciliation-page .sr-robot-form { display: grid; grid-template-columns: minmax(260px, 2fr) minmax(180px, 1fr) auto; gap: 10px; align-items: end; margin-top: 12px; }
|
||||
@ -82,12 +85,17 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="sr-field"><span>供应商</span>
|
||||
<select name="supplier" onchange="this.form.submit()">
|
||||
<option value="">请选择厂家</option>
|
||||
<input type="search"
|
||||
name="supplier"
|
||||
value="{{ $supplierQuery !== '' ? $supplierQuery : $selectedSupplier }}"
|
||||
list="finance-supplier-options"
|
||||
placeholder="输入厂家名称搜索"
|
||||
autocomplete="off">
|
||||
<datalist id="finance-supplier-options">
|
||||
@foreach ($supplierNames as $supplier)
|
||||
<option value="{{ $supplier }}" @selected($selectedSupplier === $supplier)>{{ $supplier }}</option>
|
||||
<option value="{{ $supplier }}"></option>
|
||||
@endforeach
|
||||
</select>
|
||||
</datalist>
|
||||
</label>
|
||||
<button type="submit" class="sr-button is-primary">查询</button>
|
||||
</div>
|
||||
@ -97,10 +105,16 @@
|
||||
<a href="{{ route('vendors.index') }}" class="sr-button">厂家登录账号管理</a>
|
||||
@endcanany
|
||||
</form>
|
||||
@if ($supplierNotFound)
|
||||
<div class="biz-alert biz-alert-error" role="alert">
|
||||
未找到名为「{{ $supplierQuery }}」的供应商,请输入完整名称并从搜索建议中选择。
|
||||
</div>
|
||||
@endif
|
||||
<p class="sr-hint">
|
||||
比对口径:按「条码 + 单价」逐行核对(同一把钥匙的多行先合并数量与金额)。一致 → 正常结算;
|
||||
厂家多(我方没有)→ 不结算、退回厂家修改账单;金额/数量不一致 → 取金额低的一方结算;
|
||||
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家上传的对账单在「厂家端数据」实时可查。<br>
|
||||
比对口径:按「采购单号 + 日期 + 商品编码」核对,以我方货款对账的“商品编码”匹配厂家条码;同一采购单号、日期和商品编码的多行先合并数量与金额。
|
||||
数量、金额一致 → 正常结算;厂家多(我方没有)→ 不结算、退回修改;
|
||||
数量或金额不一致 → 明确列出双方数量、单价、金额及厂家多算部分,取金额低的一方结算;
|
||||
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家未填采购单号时,仅在同日期+商品编码能唯一匹配我方一条数据时兜底。<br>
|
||||
厂家登录:在「厂家登录账号管理」新增供应商并勾选可登录,供应商名称必须与对账数据中的厂家名称完全一致;
|
||||
厂家登录后只能查看自己的对账单上传记录、全部退货明细及导出数据。
|
||||
</p>
|
||||
@ -126,6 +140,12 @@
|
||||
</span>
|
||||
<span style="flex:1 1 auto"></span>
|
||||
<a href="{{ $listUrl }}" class="sr-button">← 返回供应商列表</a>
|
||||
<a href="{{ route('business.supplier-reconciliation.comparison.export', array_filter([
|
||||
'supplier' => $selectedSupplier,
|
||||
'month' => $month,
|
||||
'reconciler' => $reconciler,
|
||||
], static fn (mixed $value): bool => $value !== '' && $value !== null)) }}"
|
||||
class="sr-button">导出比对汇总</a>
|
||||
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
|
||||
@if ($differenceCount > 0)
|
||||
<form method="POST"
|
||||
@ -242,13 +262,15 @@
|
||||
<div class="sr-summary">
|
||||
<div class="sr-summary-cell"><span>双方一致</span><b>{{ $detail['summary']['match'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家多(退回修改)</span><b>{{ $detail['summary']['supplierExtra'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>金额不一致(按低结算)</span><b>{{ $detail['summary']['mismatch'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>数量/金额不一致(按低结算)</span><b>{{ $detail['summary']['mismatch'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家少(按厂家结算)</span><b>{{ $detail['summary']['supplierMissing'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>我方金额合计</span><b>{{ number_format($detail['summary']['mineAmount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家金额合计</span><b>{{ number_format($detail['summary']['theirsAmount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家多算金额</span><b>{{ number_format($detail['summary']['supplierOverAmount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>按口径结算合计</span><b>{{ number_format($detail['summary']['settleAmount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>差异金额</span><b>{{ number_format($detail['summary']['differenceAmount'], 2) }}</b></div>
|
||||
</div>
|
||||
<div class="sr-scroll">
|
||||
<div class="sr-scroll sr-comparison-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -256,12 +278,17 @@
|
||||
<input id="select-all-differences" type="checkbox" aria-label="选择全部差异">
|
||||
</th>
|
||||
<th scope="col">比对结果</th>
|
||||
<th scope="col">条码</th>
|
||||
<th scope="col">单价</th>
|
||||
<th scope="col">采购单号</th>
|
||||
<th scope="col">日期</th>
|
||||
<th scope="col">我方商品编码 / 厂家条码</th>
|
||||
<th scope="col">我方数量</th>
|
||||
<th scope="col">厂家数量</th>
|
||||
<th scope="col">我方单价</th>
|
||||
<th scope="col">我方金额</th>
|
||||
<th scope="col">厂家数量</th>
|
||||
<th scope="col">厂家单价</th>
|
||||
<th scope="col">厂家金额</th>
|
||||
<th scope="col">数量差异(厂家-我方)</th>
|
||||
<th scope="col">金额差异(厂家-我方)</th>
|
||||
<th scope="col">结算口径</th>
|
||||
<th scope="col">结算金额</th>
|
||||
<th scope="col">推送</th>
|
||||
@ -272,7 +299,7 @@
|
||||
@php
|
||||
$tagClass = match ($row['type']) {
|
||||
'厂家多' => 'is-extra',
|
||||
'金额不一致' => 'is-mismatch',
|
||||
'数量/金额不一致' => 'is-mismatch',
|
||||
'厂家少' => 'is-missing',
|
||||
default => 'is-match',
|
||||
};
|
||||
@ -284,24 +311,41 @@
|
||||
form="push-selected-form"
|
||||
type="checkbox"
|
||||
name="selected_rows[]"
|
||||
value="{{ json_encode(['barcode' => $row['barcode'], 'type' => $row['type'], 'unitPrice' => $row['unitPrice']], JSON_UNESCAPED_UNICODE) }}"
|
||||
value="{{ json_encode(['purchaseNo' => $row['purchaseNo'], 'date' => $row['date'], 'barcode' => $row['barcode'], 'type' => $row['type']], JSON_UNESCAPED_UNICODE) }}"
|
||||
aria-label="选择差异 {{ $row['barcode'] }}">
|
||||
@else
|
||||
<span class="sr-muted">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td><span class="sr-tag {{ $tagClass }}">{{ $row['type'] }}</span></td>
|
||||
<td>{{ $row['purchaseNo'] !== '' ? $row['purchaseNo'] : '—' }}</td>
|
||||
<td>{{ $row['date'] !== '' ? $row['date'] : '—' }}</td>
|
||||
<td>
|
||||
{{ $row['barcode'] }}
|
||||
@if (! empty($row['supplierBarcode']))
|
||||
<span class="sr-muted" style="font-size:.7rem">(厂家:{{ $row['supplierBarcode'] }})</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-num">{{ number_format($row['unitPrice'], 2) }}</td>
|
||||
<td class="is-num">{{ $row['mineQty'] !== 0.0 ? number_format($row['mineQty'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['mineUnitPrice'] !== 0.0 ? number_format($row['mineUnitPrice'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['mineAmount'] !== 0.0 ? number_format($row['mineAmount'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['theirsUnitPrice'] !== 0.0 ? number_format($row['theirsUnitPrice'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['theirsAmount'] !== 0.0 ? number_format($row['theirsAmount'], 2) : '—' }}</td>
|
||||
<td class="is-num">
|
||||
@if (abs($row['qtyDifference']) < 0.005)
|
||||
一致
|
||||
@else
|
||||
厂家{{ $row['qtyDifference'] > 0 ? '多' : '少' }} {{ number_format(abs($row['qtyDifference']), 2) }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-num">
|
||||
@if (abs($row['amountDifference']) < 0.005)
|
||||
一致
|
||||
@else
|
||||
厂家{{ $row['amountDifference'] > 0 ? '多' : '少' }} ¥{{ number_format(abs($row['amountDifference']), 2) }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="sr-muted">{{ $row['note'] }}</td>
|
||||
<td class="is-num">{{ number_format($row['settleAmount'], 2) }}</td>
|
||||
<td>
|
||||
@ -314,9 +358,10 @@
|
||||
<input type="hidden" name="month" value="{{ $month }}">
|
||||
<input type="hidden" name="reconciler" value="{{ $reconciler }}">
|
||||
<input type="hidden" name="scope" value="row">
|
||||
<input type="hidden" name="purchase_no" value="{{ $row['purchaseNo'] }}">
|
||||
<input type="hidden" name="date" value="{{ $row['date'] }}">
|
||||
<input type="hidden" name="barcode" value="{{ $row['barcode'] }}">
|
||||
<input type="hidden" name="difference_type" value="{{ $row['type'] }}">
|
||||
<input type="hidden" name="unit_price" value="{{ number_format($row['unitPrice'], 4, '.', '') }}">
|
||||
<button type="submit"
|
||||
class="sr-button is-small {{ $robot['configured'] ? 'is-primary' : 'is-disabled' }}"
|
||||
@disabled(! $robot['configured'])
|
||||
@ -330,11 +375,48 @@
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="11"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
|
||||
<tr><td colspan="16"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="sr-pagination">
|
||||
@php
|
||||
$comparisonPageCount = max((int) ($detail['pages'] ?? 1), 1);
|
||||
$comparisonPageCurrent = min(max((int) ($detail['page'] ?? 1), 1), $comparisonPageCount);
|
||||
$comparisonPageStart = max(1, min($comparisonPageCurrent - 4, $comparisonPageCount - 8));
|
||||
$comparisonPageEnd = min($comparisonPageCount, $comparisonPageStart + 8);
|
||||
$comparisonPageUrl = fn (int $number): string => route('business.pages.show', array_merge(request()->query(), [
|
||||
'section' => $section['slug'],
|
||||
'page' => $page['slug'],
|
||||
'compare_page' => $number,
|
||||
]));
|
||||
@endphp
|
||||
<span>
|
||||
共 {{ number_format((int) ($detail['total_rows'] ?? 0)) }} 条
|
||||
· 每页 {{ number_format((int) ($detail['per_page'] ?? 15)) }} 条
|
||||
· 第 {{ $comparisonPageCurrent }} / {{ $comparisonPageCount }} 页
|
||||
</span>
|
||||
@if ($comparisonPageCurrent > 1)
|
||||
<a class="sr-button is-small" href="{{ $comparisonPageUrl($comparisonPageCurrent - 1) }}">上一页</a>
|
||||
@endif
|
||||
@if ($comparisonPageStart > 1)
|
||||
<a class="sr-button is-small" href="{{ $comparisonPageUrl(1) }}">1</a>
|
||||
@if ($comparisonPageStart > 2) <span>…</span> @endif
|
||||
@endif
|
||||
@for ($number = $comparisonPageStart; $number <= $comparisonPageEnd; $number++)
|
||||
<a class="sr-button is-small {{ $number === $comparisonPageCurrent ? 'is-primary' : '' }}"
|
||||
@if ($number === $comparisonPageCurrent) aria-current="page" @endif
|
||||
href="{{ $comparisonPageUrl($number) }}">{{ $number }}</a>
|
||||
@endfor
|
||||
@if ($comparisonPageEnd < $comparisonPageCount)
|
||||
@if ($comparisonPageEnd < $comparisonPageCount - 1) <span>…</span> @endif
|
||||
<a class="sr-button is-small" href="{{ $comparisonPageUrl($comparisonPageCount) }}">{{ $comparisonPageCount }}</a>
|
||||
@endif
|
||||
@if ($comparisonPageCurrent < $comparisonPageCount)
|
||||
<a class="sr-button is-small" href="{{ $comparisonPageUrl($comparisonPageCurrent + 1) }}">下一页</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php $returnAccounting = $financeReturnPanel['accounting_summary']; @endphp
|
||||
|
||||
@ -107,7 +107,7 @@
|
||||
</div>
|
||||
@endif
|
||||
<p class="sr-hint">
|
||||
厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号、单据编号(采购单号)可以留空;
|
||||
厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号可以留空;采购单号建议填写(用于按采购单号+日期+条码核对,未填时仅在日期+条码唯一匹配时自动匹配);
|
||||
单价或金额留空时按数量自动补齐(都留空按 0,未定价行可直接导出上传);其余列必填;
|
||||
不符合模板或数值格式的整份失败并提示具体行号。厂家上传成功后,下面的数据与「财务端数据」的比对结果立即更新。
|
||||
</p>
|
||||
|
||||
@ -89,7 +89,8 @@
|
||||
</form>
|
||||
</div>
|
||||
<p class="sp-hint">
|
||||
请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号、采购单号可留空,
|
||||
请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号可留空;
|
||||
采购单号建议填写,系统会按采购单号+日期+条码与我方数据核对;
|
||||
单价或金额留空时会按数量自动补齐。
|
||||
</p>
|
||||
<div class="sp-summary">
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -416,7 +416,8 @@ if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||
fail '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
|
||||
fi
|
||||
|
||||
if [ "$branch" = 'main' ] && git rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
|
||||
git fetch origin main --quiet || fail '无法更新远端 main,已停止发布。'
|
||||
if [ "$branch" = 'main' ]; then
|
||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
|
||||
fail '本地 main 与 origin/main 不一致,请先执行 git pull --ff-only 再发布。'
|
||||
fi
|
||||
@ -457,7 +458,6 @@ ssh_control_path="/tmp/caiwu-ssh-$$-$RANDOM.sock"
|
||||
ssh_options+=(-o ControlMaster=auto -o ControlPersist=600 -o "ControlPath=$ssh_control_path")
|
||||
tunnel_ssh_options=("${ssh_options[@]}" -o ControlPath=none)
|
||||
|
||||
remote_archive="$remote_path/image-$image_tag.tar.gz"
|
||||
temp_root="$(mktemp -d "${TMPDIR:-/tmp}/caiwu-deploy-XXXXXX")"
|
||||
local_tar="$temp_root/image.tar"
|
||||
local_archive="$temp_root/image.tar.gz"
|
||||
@ -512,6 +512,13 @@ for pair in \
|
||||
done
|
||||
|
||||
previous_sha="$(ssh "${ssh_options[@]}" "$ssh_target" "cat '$remote_path/.deployed-sha' 2>/dev/null || true" | tr -d '[:space:]')"
|
||||
if [ -n "$previous_sha" ] && ! printf '%s\n' "$previous_sha" | grep -Eq '^[0-9a-f]{40}$'; then
|
||||
fail '线上版本标记格式无效,已停止发布。'
|
||||
fi
|
||||
expected_previous_sha="${previous_sha:-none}"
|
||||
deploy_actor="$(printf '%s@%s' "$(git config user.name)" "$(hostname)" | tr -c 'A-Za-z0-9_.@:-' '_')"
|
||||
remote_release_path="$remote_path/releases/$image_tag-$(uuidgen | tr 'A-Z' 'a-z')"
|
||||
remote_archive="$remote_release_path/image-$image_tag.tar.gz"
|
||||
|
||||
if [ -n "$previous_sha" ]; then
|
||||
if git cat-file -e "${previous_sha}^{commit}" 2>/dev/null; then
|
||||
@ -556,7 +563,7 @@ if [ "$bootstrap_server" -eq 1 ]; then
|
||||
bootstrap_remote=''
|
||||
fi
|
||||
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$remote_path'" \
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$remote_path' '$remote_release_path'" \
|
||||
|| fail '服务器 Docker 未就绪;首次发布请添加 --bootstrap-server。'
|
||||
|
||||
ensure_qiniu_storage_runtime
|
||||
@ -570,6 +577,9 @@ scp_to_remote \
|
||||
"$repo_root/compose.jiangling.yml" \
|
||||
"$repo_root/deploy/activate-release.sh" \
|
||||
"$repo_root/deploy/activate-incremental-release.sh" \
|
||||
"$repo_root/deploy/release-guard.sh" \
|
||||
"$repo_root/deploy/retired-activation.sh" \
|
||||
"$repo_root/deploy/install-release-protection.sh" \
|
||||
"$repo_root/deploy/config-db-init.sh" \
|
||||
"$repo_root/deploy/run-finance-batch.sh" \
|
||||
"$repo_root/deploy/clickhouse-resource-policy.xml" \
|
||||
@ -578,10 +588,7 @@ scp_to_remote \
|
||||
"$repo_root/deploy/host-nginx-8442-dingtalk.conf" \
|
||||
"$repo_root/deploy/setup-incremental-registry.sh" \
|
||||
"$repo_root/deploy/registry-config.yml" \
|
||||
"${ssh_target}:$remote_path/" || fail '上传运行配置失败。'
|
||||
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/update-runtime-url.sh' && '$remote_path/update-runtime-url.sh' '$app_url' '$remote_path'" \
|
||||
|| fail '更新服务器访问地址失败。'
|
||||
"${ssh_target}:$remote_release_path/" || fail '上传运行配置失败。'
|
||||
|
||||
if [ "$bootstrap_server" -eq 1 ]; then
|
||||
if ! docker image inspect "$database_image" >/dev/null 2>&1; then
|
||||
@ -598,11 +605,11 @@ if [ "$bootstrap_server" -eq 1 ]; then
|
||||
log "首次初始化全量包:${archive_size} MiB"
|
||||
|
||||
scp_to_remote "$local_archive" "${ssh_target}:$remote_archive" || fail '上传镜像失败。'
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/'*.sh && chmod 644 '$remote_path/config-db-init.sh' && '$remote_path/activate-release.sh' '$remote_archive' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' && '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml'" \
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_release_path/'*.sh && '$remote_release_path/activate-release.sh' '$remote_archive' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' '$expected_previous_sha' '$deploy_actor' '$app_url' && '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml'" \
|
||||
|| fail '服务器激活、健康检查或增量仓库初始化失败。'
|
||||
else
|
||||
ssh "${ssh_options[@]}" -o ConnectTimeout=10 -o ConnectionAttempts=1 "$ssh_target" \
|
||||
"chmod 700 '$remote_path/'*.sh && chmod 644 '$remote_path/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml')" \
|
||||
"chmod 700 '$remote_release_path/'*.sh && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remote_release_path/setup-incremental-registry.sh' '$remote_path' '$remote_release_path/registry-config.yml')" \
|
||||
|| fail '服务器增量镜像仓库初始化失败。'
|
||||
|
||||
docker buildx build \
|
||||
@ -685,7 +692,7 @@ else
|
||||
incremental_mib="$(awk -v a="$after_bytes" -v b="$before_bytes" 'BEGIN { v = (a - b) / 1048576; if (v < 0) v = 0; printf "%.2f", v }')"
|
||||
log "增量新增镜像层:${incremental_mib} MiB;传输耗时:${upload_seconds} 秒"
|
||||
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "'$remote_path/activate-incremental-release.sh' '$server_registry_image' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images'" \
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "'$remote_release_path/activate-incremental-release.sh' '$server_registry_image' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' '$expected_previous_sha' '$deploy_actor' '$app_url'" \
|
||||
|| fail '服务器增量激活或健康检查失败。'
|
||||
fi
|
||||
|
||||
|
||||
@ -348,6 +348,11 @@ try {
|
||||
throw '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
|
||||
}
|
||||
|
||||
Invoke-Native { git fetch origin main --quiet } '无法更新远端 main,已停止发布。'
|
||||
if ($branch -eq 'main' -and (git rev-parse HEAD).Trim() -ne (git rev-parse origin/main).Trim()) {
|
||||
throw '本地 main 不是最新 origin/main,请先 git pull --ff-only 再发布。'
|
||||
}
|
||||
|
||||
$deploySha = (git rev-parse HEAD).Trim()
|
||||
$imageTag = $deploySha.Substring(0, 12)
|
||||
$fullImage = "${imageName}:$imageTag"
|
||||
@ -372,7 +377,6 @@ try {
|
||||
}
|
||||
Invoke-NativeWithRetry { ssh @sshOptions $sshTarget true } '服务器登录失败或已取消,已停止部署。' -Attempts 3
|
||||
if ($authSession.Directory) { $env:CAIWU_DEPLOY_PASSWORD_UPDATE = $null }
|
||||
$remoteArchive = "$RemotePath/image-$imageTag.tar.gz"
|
||||
|
||||
# deploy-auth.ps1 is dot-sourced and can clear the automatic script path
|
||||
# variable on PowerShell Core. Restore it before invoking the build helper.
|
||||
@ -436,6 +440,11 @@ try {
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw '无法连接服务器。'
|
||||
}
|
||||
if ($previousSha -and $previousSha -notmatch '^[0-9a-f]{40}$') { throw '线上版本标记格式无效,已停止发布。' }
|
||||
$expectedPreviousSha = if ($previousSha) { $previousSha } else { 'none' }
|
||||
$deployActor = (((git config user.name) -join '') + '@' + [Environment]::MachineName) -replace '[^A-Za-z0-9_.@:-]', '_'
|
||||
$remoteReleasePath = "$RemotePath/releases/$imageTag-$([Guid]::NewGuid().ToString('N'))"
|
||||
$remoteArchive = "$remoteReleasePath/image-$imageTag.tar.gz"
|
||||
|
||||
$previousShaExists = $false
|
||||
if ($previousSha) {
|
||||
@ -487,7 +496,7 @@ try {
|
||||
}
|
||||
|
||||
Invoke-Native {
|
||||
ssh @sshOptions $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'"
|
||||
ssh @sshOptions $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath' '$remoteReleasePath'"
|
||||
} '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。'
|
||||
|
||||
Ensure-QiniuBusinessStorageRuntime
|
||||
@ -498,6 +507,9 @@ try {
|
||||
compose.jiangling.yml `
|
||||
deploy/activate-release.sh `
|
||||
deploy/activate-incremental-release.sh `
|
||||
deploy/release-guard.sh `
|
||||
deploy/retired-activation.sh `
|
||||
deploy/install-release-protection.sh `
|
||||
deploy/config-db-init.sh `
|
||||
deploy/run-finance-batch.sh `
|
||||
deploy/clickhouse-resource-policy.xml `
|
||||
@ -506,13 +518,9 @@ try {
|
||||
deploy/host-nginx-8442-dingtalk.conf `
|
||||
deploy/setup-incremental-registry.sh `
|
||||
deploy/registry-config.yml `
|
||||
"${sshTarget}:$RemotePath/"
|
||||
"${sshTarget}:$remoteReleasePath/"
|
||||
} '上传运行配置失败。'
|
||||
|
||||
Invoke-Native {
|
||||
ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/update-runtime-url.sh' && '$RemotePath/update-runtime-url.sh' '$AppUrl' '$RemotePath'"
|
||||
} '更新服务器访问地址失败。'
|
||||
|
||||
if ($BootstrapServer) {
|
||||
docker image inspect $DatabaseImage 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
@ -532,12 +540,12 @@ try {
|
||||
Write-Host "首次初始化全量包:$archiveSizeMB MiB"
|
||||
Invoke-Native { scp @scpOptions @sshOptions $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。'
|
||||
Invoke-Native {
|
||||
ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'"
|
||||
ssh @sshOptions $sshTarget "chmod 700 '$remoteReleasePath/'*.sh && '$remoteReleasePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' '$expectedPreviousSha' '$deployActor' '$AppUrl' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'"
|
||||
} '服务器激活、健康检查或增量仓库初始化失败。'
|
||||
}
|
||||
else {
|
||||
Invoke-NativeWithRetry {
|
||||
ssh @sshOptions -o ConnectTimeout=10 -o ConnectionAttempts=1 $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml')"
|
||||
ssh @sshOptions -o ConnectTimeout=10 -o ConnectionAttempts=1 $sshTarget "chmod 700 '$remoteReleasePath/'*.sh && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remoteReleasePath/setup-incremental-registry.sh' '$RemotePath' '$remoteReleasePath/registry-config.yml')"
|
||||
} '服务器增量镜像仓库初始化失败。'
|
||||
|
||||
# docker save 导出未压缩层,每次导入重新压缩会改变摘要,导致 Registry 无法复用。
|
||||
@ -653,7 +661,7 @@ try {
|
||||
Write-Host "增量新增镜像层:$incrementalMiB MiB;传输耗时:$([math]::Round($uploadTimer.Elapsed.TotalSeconds, 1)) 秒"
|
||||
|
||||
Invoke-Native {
|
||||
ssh @sshOptions $sshTarget "'$RemotePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'"
|
||||
ssh @sshOptions $sshTarget "'$remoteReleasePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' '$expectedPreviousSha' '$deployActor' '$AppUrl'"
|
||||
} '服务器增量激活或健康检查失败。'
|
||||
}
|
||||
|
||||
|
||||
289
tests/Feature/Business/CourierQuoteBillingRulesTest.php
Normal file
289
tests/Feature/Business/CourierQuoteBillingRulesTest.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -46,6 +46,8 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSeeText('厂家端数据')
|
||||
->assertSeeText('厂家多')
|
||||
->assertSeeText('取金额低的一方结算')
|
||||
->assertSeeText('采购单号 + 日期 + 商品编码')
|
||||
->assertSee('list="finance-supplier-options"', false)
|
||||
->assertSeeText('对账人')
|
||||
->assertSeeText('下载对账单模板');
|
||||
}
|
||||
@ -55,11 +57,13 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$companyId = (int) $this->company->id;
|
||||
$store->save('final:supplier:payment-reconciliation', 'line:000001', [
|
||||
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
|
||||
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-1', 'date' => '2026-09-30',
|
||||
'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
|
||||
'qty' => '2', 'unitPrice' => '128', 'amount' => '256', 'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
$store->save('final:supplier:payment-reconciliation', 'line:000002', [
|
||||
'summary_month' => '2026-09', 'supplier' => '鸣飞家具', 'skuCode' => 'SJ-1',
|
||||
'summary_month' => '2026-09', 'purchaseNo' => '编号-SJ-1', 'date' => '2026-09-30',
|
||||
'supplier' => '鸣飞家具', 'skuCode' => 'SJ-1',
|
||||
'qty' => '1', 'unitPrice' => '96', 'amount' => '96', 'reconciler' => '李四',
|
||||
], $companyId, null);
|
||||
|
||||
@ -81,15 +85,17 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$companyId = (int) $this->company->id;
|
||||
$store->save('final:supplier:payment-reconciliation', 'line:000001', [
|
||||
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
|
||||
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-1N1097V01', 'date' => '2026-09-30',
|
||||
'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
|
||||
'qty' => '2', 'unitPrice' => '128', 'amount' => '256', 'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
$store->save('final:supplier:payment-reconciliation', 'line:000002', [
|
||||
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-2',
|
||||
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-2N1097V01', 'date' => '2026-09-30',
|
||||
'supplier' => '晨丰', 'skuCode' => 'ZSJ-2',
|
||||
'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
$this->seedStatement('晨丰', [
|
||||
$this->statementRow('ZSJ-1N1097V01', '128.00', '2', '256.00'),
|
||||
$this->statementRow('ZSJ-1N1097V01', '128.00', '3', '384.00'),
|
||||
$this->statementRow('ZSJ-3N1097V01', '80.00', '1', '80.00'),
|
||||
]);
|
||||
|
||||
@ -103,11 +109,161 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSeeText('比对明细')
|
||||
->assertSeeText('返回供应商列表')
|
||||
->assertSeeText('结算金额')
|
||||
->assertSeeText('采购单号')
|
||||
->assertSeeText('日期')
|
||||
->assertSeeText('数量差异(厂家-我方)')
|
||||
->assertSeeText('厂家多 1.00')
|
||||
->assertSeeText('厂家多 ¥128.00')
|
||||
->assertSeeText('编号-ZSJ-1N1097V01')
|
||||
->assertSeeText('导出比对汇总')
|
||||
->assertSee('sr-comparison-scroll', false)
|
||||
->assertSeeText('每页 15 条')
|
||||
->assertSeeText('ZSJ-3')
|
||||
->assertSeeText('ZSJ-2')
|
||||
->assertDontSeeText('各厂家上传情况');
|
||||
}
|
||||
|
||||
public function test_finance_supplier_filter_requires_an_exact_searchable_name(): void
|
||||
{
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$companyId = (int) $this->company->id;
|
||||
foreach (['晨丰', '鸣飞家具'] as $supplier) {
|
||||
$store->save('final:supplier:payment-reconciliation', 'supplier:' . $supplier, [
|
||||
'summary_month' => '2026-09',
|
||||
'purchaseNo' => 'PO-' . $supplier,
|
||||
'date' => '2026-09-30',
|
||||
'supplier' => $supplier,
|
||||
'skuCode' => 'A-1',
|
||||
'qty' => '1',
|
||||
'unitPrice' => '10',
|
||||
'amount' => '10',
|
||||
'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
}
|
||||
|
||||
$pageUrl = route('business.pages.show', [
|
||||
'company_id' => $companyId,
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'finance-data',
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('晨丰'))
|
||||
->assertOk()
|
||||
->assertSeeText('晨丰 · 比对明细')
|
||||
->assertSee('value="晨丰"', false);
|
||||
|
||||
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('不存在的厂家'))
|
||||
->assertOk()
|
||||
->assertSeeText('未找到名为「不存在的厂家」的供应商')
|
||||
->assertDontSeeText('晨丰 · 比对明细')
|
||||
->assertDontSeeText('鸣飞家具 · 比对明细');
|
||||
}
|
||||
|
||||
public function test_finance_comparison_rows_are_paginated_fifteen_per_page(): void
|
||||
{
|
||||
$companyId = (int) $this->company->id;
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$statementRows = [];
|
||||
for ($i = 1; $i <= 20; $i++) {
|
||||
$purchaseNo = sprintf('PO-%02d', $i);
|
||||
$barcode = sprintf('A-%02d', $i);
|
||||
$store->save('final:supplier:payment-reconciliation', 'compare:' . $i, [
|
||||
'summary_month' => '2026-09',
|
||||
'purchaseNo' => $purchaseNo,
|
||||
'date' => '2026-09-30',
|
||||
'supplier' => '晨丰',
|
||||
'skuCode' => $barcode,
|
||||
'qty' => '1',
|
||||
'unitPrice' => '10',
|
||||
'amount' => '10',
|
||||
'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
$statementRows[] = array_merge(
|
||||
$this->statementRow($barcode . 'N1097V01', '10.00', '1', '10.00'),
|
||||
['purchaseNo' => $purchaseNo],
|
||||
);
|
||||
}
|
||||
$this->seedStatement('晨丰', $statementRows);
|
||||
|
||||
$pageUrl = fn (int $page): string => route('business.pages.show', [
|
||||
'company_id' => $companyId,
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'finance-data',
|
||||
'supplier' => '晨丰',
|
||||
'compare_page' => $page,
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($pageUrl(1))
|
||||
->assertOk()
|
||||
->assertSeeText('第 1 / 2 页')
|
||||
->assertSeeText('每页 15 条')
|
||||
->assertSeeText('A-01')
|
||||
->assertSeeText('A-15')
|
||||
->assertDontSeeText('A-16')
|
||||
->assertDontSeeText('A-20');
|
||||
|
||||
$this->loginAs()->get($pageUrl(2))
|
||||
->assertOk()
|
||||
->assertSeeText('第 2 / 2 页')
|
||||
->assertSeeText('A-16')
|
||||
->assertSeeText('A-20')
|
||||
->assertDontSeeText('A-15')
|
||||
->assertDontSeeText('A-01');
|
||||
}
|
||||
|
||||
public function test_finance_comparison_export_includes_summary_and_all_filtered_rows(): void
|
||||
{
|
||||
$companyId = (int) $this->company->id;
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
foreach ([
|
||||
'mine:1' => ['purchaseNo' => 'PO-1', 'skuCode' => 'A-1', 'qty' => '2', 'unitPrice' => '100', 'amount' => '200', 'reconciler' => '张三'],
|
||||
'mine:2' => ['purchaseNo' => 'PO-2', 'skuCode' => 'B-1', 'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三'],
|
||||
'mine:other' => ['purchaseNo' => 'PO-OTHER', 'skuCode' => 'C-1', 'qty' => '1', 'unitPrice' => '30', 'amount' => '30', 'reconciler' => '李四'],
|
||||
] as $key => $row) {
|
||||
$store->save('final:supplier:payment-reconciliation', $key, $row + [
|
||||
'summary_month' => $key === 'mine:other' ? '2026-10' : '2026-09',
|
||||
'date' => '2026-09-30',
|
||||
'supplier' => '晨丰',
|
||||
], $companyId, null);
|
||||
}
|
||||
$this->seedStatement('晨丰', [
|
||||
array_merge($this->statementRow('A-1N1097V01', '100.00', '2', '200.00'), ['purchaseNo' => 'PO-1']),
|
||||
array_merge($this->statementRow('B-1N1097V01', '50.00', '2', '100.00'), ['purchaseNo' => 'PO-2']),
|
||||
array_merge($this->statementRow('D-1N1097V01', '80.00', '1', '80.00'), ['purchaseNo' => 'PO-D']),
|
||||
]);
|
||||
|
||||
$response = $this->loginAs()->get(route('business.supplier-reconciliation.comparison.export', [
|
||||
'supplier' => '晨丰',
|
||||
'month' => '2026-09',
|
||||
'reconciler' => '张三',
|
||||
]));
|
||||
$content = $response->streamedContent();
|
||||
|
||||
$response->assertOk()
|
||||
->assertDownload()
|
||||
->assertHeader('content-type', 'text/csv; charset=UTF-8');
|
||||
|
||||
foreach ([
|
||||
'供应商,晨丰',
|
||||
'月份,2026-09',
|
||||
'对账人,张三',
|
||||
'双方一致,1',
|
||||
'厂家多,1',
|
||||
'数量/金额不一致,1',
|
||||
'厂家少,0',
|
||||
'采购单号,日期,我方商品编码,厂家条码,比对结果',
|
||||
'PO-1',
|
||||
'PO-2',
|
||||
'PO-D',
|
||||
'厂家多 1',
|
||||
'厂家多 ¥50.00',
|
||||
] as $expected) {
|
||||
$this->assertStringContainsString($expected, $content);
|
||||
}
|
||||
|
||||
$this->assertStringNotContainsString('PO-OTHER', $content);
|
||||
}
|
||||
|
||||
public function test_supplier_page_shows_uploads_and_return_rows(): void
|
||||
{
|
||||
$this->seedStatement('晨丰', [
|
||||
@ -365,6 +521,8 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
'mine:logistics:1',
|
||||
[
|
||||
'summary_month' => '2026-09',
|
||||
'purchaseNo' => 'PO-LOGISTICS-1',
|
||||
'date' => '2026-09-27',
|
||||
'supplier' => '曹县宇诺',
|
||||
'reconciler' => '陈亲园',
|
||||
'skuCode' => 'CZ008407',
|
||||
@ -835,14 +993,14 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
public function test_matcher_applies_the_agreed_settlement_rules(): void
|
||||
{
|
||||
$mine = [
|
||||
['skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], // 一致
|
||||
['skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'], // 金额不一致(厂家低)
|
||||
['skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'], // 厂家少
|
||||
['purchaseNo' => 'PO-A-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
['purchaseNo' => 'PO-B-1', 'date' => '2026-09-30', 'skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'],
|
||||
['purchaseNo' => 'PO-C-1', 'date' => '2026-09-30', 'skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'],
|
||||
];
|
||||
$theirs = [
|
||||
['barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
['barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'],
|
||||
['barcode' => 'D-1', 'unitPrice' => '80', 'qty' => '1', 'amount' => '80'], // 厂家多
|
||||
['purchaseNo' => 'PO-A-1', 'createdAt' => '2026-09-30', 'barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
['purchaseNo' => 'PO-B-1', 'createdAt' => '2026-09-30', 'barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'],
|
||||
['purchaseNo' => 'PO-D-1', 'createdAt' => '2026-09-30', 'barcode' => 'D-1', 'unitPrice' => '80', 'qty' => '1', 'amount' => '80'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
@ -852,25 +1010,31 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$this->assertSame(1, $result['summary']['supplierMissing']);
|
||||
$this->assertSame(1, $result['summary']['supplierExtra']);
|
||||
$this->assertSame(330.0, $result['summary']['mineAmount']);
|
||||
$this->assertSame(330.0, $result['summary']['theirsAmount']);
|
||||
$this->assertSame(80.0, $result['summary']['supplierOverAmount']);
|
||||
$this->assertSame(250.0, $result['summary']['settleAmount']);
|
||||
$this->assertSame(80.0, $result['summary']['differenceAmount']);
|
||||
|
||||
$byBarcode = collect($result['rows'])->keyBy('barcode');
|
||||
$this->assertSame(50.0, $byBarcode['B-1']['settleAmount']);
|
||||
$this->assertSame(-1.0, $byBarcode['B-1']['qtyDifference']);
|
||||
$this->assertSame(-50.0, $byBarcode['B-1']['amountDifference']);
|
||||
$this->assertSame('PO-B-1', $byBarcode['B-1']['purchaseNo']);
|
||||
$this->assertSame('2026-09-30', $byBarcode['B-1']['date']);
|
||||
$this->assertSame(0.0, $byBarcode['C-1']['settleAmount']);
|
||||
$this->assertSame(0.0, $byBarcode['D-1']['settleAmount']);
|
||||
}
|
||||
|
||||
public function test_matcher_normalizes_supplier_barcode_suffix(): void
|
||||
{
|
||||
// 真实场景:我方货号不带厂家后缀,厂家条码带「厂家编号 + 版本」(CJ019312 对 CJ019312N1097V01)
|
||||
// 真实场景:我方商品编码与厂家条码的厂家编号/版本后缀不同,去掉后缀后应一致。
|
||||
$mine = [
|
||||
['skuCode' => 'CJ019312', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||||
['skuCode' => 'DNZ037602', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
|
||||
['purchaseNo' => 'PO-CJ-1', 'date' => '2026-09-30', 'skuCode' => 'CJ019312', 'productCode' => 'CJ019312N1178V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||||
['purchaseNo' => 'PO-DNZ-1', 'date' => '2026-09-30', 'skuCode' => 'DNZ037602', 'productCode' => 'DNZ037602N1178V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
|
||||
];
|
||||
$theirs = [
|
||||
['barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||||
['barcode' => 'DNZ037602N1097V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
|
||||
['purchaseNo' => 'PO-CJ-1', 'createdAt' => '2026-09-30', 'barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||||
['purchaseNo' => 'PO-DNZ-1', 'createdAt' => '2026-09-30', 'barcode' => 'DNZ037602N1097V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
@ -883,6 +1047,92 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode'] ?? null);
|
||||
}
|
||||
|
||||
public function test_matcher_uses_product_code_instead_of_sku_code(): void
|
||||
{
|
||||
$mine = [[
|
||||
'purchaseNo' => 'PO-CODE-1',
|
||||
'date' => '2026-09-30',
|
||||
'skuCode' => 'SKU-ONLY',
|
||||
'productCode' => 'CJ019312N1178V01',
|
||||
'unitPrice' => '182.5',
|
||||
'qty' => '2',
|
||||
'amount' => '365',
|
||||
]];
|
||||
$theirs = [[
|
||||
'purchaseNo' => 'PO-CODE-1',
|
||||
'createdAt' => '2026-09-30',
|
||||
'barcode' => 'CJ019312N1097V01',
|
||||
'unitPrice' => '182.5',
|
||||
'qty' => '2',
|
||||
'amount' => '365',
|
||||
]];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
|
||||
$this->assertSame(1, $result['summary']['match']);
|
||||
$this->assertSame('CJ019312N1178V01', $result['rows'][0]['barcode']);
|
||||
$this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode']);
|
||||
}
|
||||
|
||||
public function test_matcher_uses_purchase_number_date_and_barcode_as_the_unique_key(): void
|
||||
{
|
||||
$mine = [
|
||||
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'],
|
||||
];
|
||||
$theirs = [
|
||||
['purchaseNo' => 'PO-1', 'createdAt' => '2026/9/30 09:30:00', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'],
|
||||
['purchaseNo' => 'PO-3', 'createdAt' => '2026-10-01', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
$rows = collect($result['rows'])->keyBy('purchaseNo');
|
||||
|
||||
$this->assertSame(SupplierStatementMatcher::TYPE_MISMATCH, $rows['PO-1']['type']);
|
||||
$this->assertSame('2026-09-30', $rows['PO-1']['date']);
|
||||
$this->assertSame(1.0, $rows['PO-1']['qtyDifference']);
|
||||
$this->assertSame(100.0, $rows['PO-1']['amountDifference']);
|
||||
$this->assertStringContainsString('厂家数量多 1', $rows['PO-1']['note']);
|
||||
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_MISSING, $rows['PO-2']['type']);
|
||||
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_EXTRA, $rows['PO-3']['type']);
|
||||
$this->assertSame(3.0, $result['summary']['supplierOverQty']);
|
||||
$this->assertSame(300.0, $result['summary']['supplierOverAmount']);
|
||||
}
|
||||
|
||||
public function test_matcher_can_fallback_only_when_missing_purchase_number_matches_one_row(): void
|
||||
{
|
||||
$mine = [
|
||||
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
];
|
||||
$theirs = [
|
||||
['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
|
||||
$this->assertSame(1, $result['summary']['match']);
|
||||
$this->assertSame('PO-1', $result['rows'][0]['purchaseNo']);
|
||||
$this->assertStringContainsString('按日期+商品编码唯一匹配', $result['rows'][0]['note']);
|
||||
}
|
||||
|
||||
public function test_matcher_does_not_guess_when_missing_purchase_number_is_ambiguous(): void
|
||||
{
|
||||
$mine = [
|
||||
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'],
|
||||
['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
];
|
||||
$theirs = [
|
||||
['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
|
||||
$this->assertSame(0, $result['summary']['match']);
|
||||
$this->assertSame(1, $result['summary']['supplierExtra']);
|
||||
$this->assertSame(2, $result['summary']['supplierMissing']);
|
||||
$this->assertStringContainsString('无法唯一匹配', $result['rows'][0]['note']);
|
||||
}
|
||||
|
||||
public function test_header_mismatch_message_lists_the_differences(): void
|
||||
{
|
||||
$file = UploadedFile::fake()->createWithContent(
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
@ -32,7 +32,7 @@ class StoreReceiptMemoryTest extends TestCase
|
||||
|
||||
return (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'aggregateBills'))->invoke(
|
||||
new SyncStoreReceiptOrderMatches(), $rows, ['label' => '淘系支付宝'], 'online_order_no', 'occurred_at',
|
||||
'income_amount', 'expense_amount', 'online_real_store', $planting, $map, '淘宝',
|
||||
'income_amount', 'expense_amount', 'online_real_store', $planting, [], [], $map, '淘宝',
|
||||
);
|
||||
}
|
||||
|
||||
@ -40,10 +40,10 @@ class StoreReceiptMemoryTest extends TestCase
|
||||
{
|
||||
$bills = [
|
||||
$this->bill('优惠收入', '260630-10001', 0, 3),
|
||||
$this->bill('订单收入', '10001', 100, 0, ['occurred_at' => '2026-08-02 10:00:00']),
|
||||
$this->bill('订单收入', '10001', 25),
|
||||
$this->bill('冲减收入', '10001', 0, 2),
|
||||
$this->bill('退款', '10001', 0, 7),
|
||||
$this->bill('订单收入', '260630-10001', 100, 0, ['occurred_at' => '2026-08-02 10:00:00']),
|
||||
$this->bill('订单收入', '260630-10001', 25),
|
||||
$this->bill('冲减收入', '260630-10001', 0, 2),
|
||||
$this->bill('退款', '260630-10001', 0, 7),
|
||||
$this->bill('优惠收入', '20002', 4),
|
||||
$this->bill('退款', '20002', 0, 1),
|
||||
$this->bill('冲减收入', '', 0, 8),
|
||||
@ -56,7 +56,7 @@ class StoreReceiptMemoryTest extends TestCase
|
||||
$result = $this->aggregate((function () use ($bills) { yield from $bills; })(), ['10001' => true]);
|
||||
$key = SyncStoreReceiptOrderMatches::normalizeShop('天猫_测试店');
|
||||
$this->assertCount(2, $result['groups']);
|
||||
$paid = $result['groups'][$key . '|10001'];
|
||||
$paid = $result['groups'][$key . '|260630-10001'];
|
||||
$this->assertSame(2, $paid['payment_count']);
|
||||
$this->assertSame(125.0, $paid['income_amount']);
|
||||
$this->assertSame(-3.0, $paid['discount_amount']);
|
||||
@ -84,6 +84,55 @@ class StoreReceiptMemoryTest extends TestCase
|
||||
$this->assertSame(-2.0, $result['promoOnlyGroups'][$key . '||退款']['refund_amount']);
|
||||
}
|
||||
|
||||
public function test_same_core_with_different_date_prefixes_stays_as_two_orders(): void
|
||||
{
|
||||
$result = $this->aggregate([
|
||||
$this->bill('订单收入', '260627-475634091573139', 13.91),
|
||||
$this->bill('订单收入', '260713-475634091573139', 29.51),
|
||||
]);
|
||||
|
||||
$shop = SyncStoreReceiptOrderMatches::normalizeShop('天猫_测试店');
|
||||
$this->assertCount(2, $result['groups']);
|
||||
$first = $result['groups'][$shop . '|260627-475634091573139'];
|
||||
$second = $result['groups'][$shop . '|260713-475634091573139'];
|
||||
$this->assertSame('475634091573139', $first['order_core_no']);
|
||||
$this->assertSame('475634091573139', $second['order_core_no']);
|
||||
$this->assertSame('260627-475634091573139', $first['order_identity']);
|
||||
$this->assertSame('260713-475634091573139', $second['order_identity']);
|
||||
$this->assertSame(13.91, $first['income_amount']);
|
||||
$this->assertSame(29.51, $second['income_amount']);
|
||||
}
|
||||
|
||||
public function test_order_side_exact_full_number_wins_and_ambiguous_core_fallback_never_sums(): void
|
||||
{
|
||||
$command = new SyncStoreReceiptOrderMatches();
|
||||
$method = new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'matchOrderSide');
|
||||
$shop = SyncStoreReceiptOrderMatches::normalizeShop('拼多多-欧意朗家具官方旗舰店');
|
||||
$core = '475634091573139';
|
||||
$first = '260627-475634091573139';
|
||||
$second = '260713-475634091573139';
|
||||
$amounts = [
|
||||
$shop . '|' . $first => 13.91,
|
||||
$shop . '|' . $second => 29.51,
|
||||
];
|
||||
$orderSeen = [
|
||||
$shop . '|' . $first => true,
|
||||
$shop . '|' . $second => true,
|
||||
];
|
||||
$identitiesByCore = [$core => [$shop => [$first => true, $second => true]]];
|
||||
$coresByCore = [$core => [$shop => true]];
|
||||
|
||||
$exact = $method->invoke(
|
||||
$command, $first, $core, $shop, $amounts, $orderSeen, [$core => true], $coresByCore, $identitiesByCore, '无金额'
|
||||
);
|
||||
$this->assertSame(['已匹配', 13.91, ''], $exact);
|
||||
|
||||
$ambiguous = $method->invoke(
|
||||
$command, $core, $core, $shop, $amounts, $orderSeen, [$core => true], $coresByCore, $identitiesByCore, '无金额'
|
||||
);
|
||||
$this->assertSame(['未匹配', null, '订单侧同号多条'], $ambiguous);
|
||||
}
|
||||
|
||||
public function test_empty_order_promotions_remain_separate_even_with_an_empty_order_payment(): void
|
||||
{
|
||||
$result = $this->aggregate([
|
||||
@ -134,7 +183,7 @@ class StoreReceiptMemoryTest extends TestCase
|
||||
$result = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'aggregateBills'))->invoke(
|
||||
new SyncStoreReceiptOrderMatches(), [(object) ['store_name' => '京东-测试店', 'order_no' => '70007',
|
||||
'bill_date' => '2026-08-09', 'income' => 12.5, 'bill_item' => '货款(收)']],
|
||||
['label' => '京东'], 'order_no', 'bill_date', 'income', '', '', [], $map, '京东',
|
||||
['label' => '京东'], 'order_no', 'bill_date', 'income', '', '', [], [], [], $map, '京东',
|
||||
);
|
||||
$group = reset($result['groups']);
|
||||
$this->assertSame(12.5, $result['incomeTotal']);
|
||||
|
||||
148
tests/scripts/release-guard.sh
Normal file
148
tests/scripts/release-guard.sh
Normal file
@ -0,0 +1,148 @@
|
||||
#!/bin/sh
|
||||
# Run in a disposable Linux container, never on a production host.
|
||||
set -eu
|
||||
REPO="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
fixture="$(mktemp -d /tmp/caiwu-release-guard.XXXXXX)"
|
||||
cleanup() {
|
||||
for legacy in activate-release.sh activate-incremental-release.sh; do
|
||||
chattr -i "$fixture/legacy-entrypoints/$legacy" 2>/dev/null || true
|
||||
done
|
||||
rm -rf "$fixture"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
mkdir -p "$fixture/bin"
|
||||
cat > "$fixture/bin/docker" <<'EOF'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$DOCKER_CALLS"
|
||||
if [ "$1" = inspect ]; then
|
||||
case "$*" in
|
||||
*Config.Image*) printf 'caiwu_akaunting:old\n' ;;
|
||||
*) printf '%s\n' "${FAKE_RUNNING_SHA:-}" ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
exit 91
|
||||
EOF
|
||||
chmod +x "$fixture/bin/docker"
|
||||
PATH="$fixture/bin:$PATH"
|
||||
export PATH
|
||||
old=1111111111111111111111111111111111111111
|
||||
new=2222222222222222222222222222222222222222
|
||||
requested=3333333333333333333333333333333333333333
|
||||
passed=0
|
||||
pass() { passed=$((passed + 1)); printf 'PASS: %s\n' "$*"; }
|
||||
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
new_runtime() {
|
||||
REMOTE_DIR="$fixture/$1"
|
||||
mkdir -p "$REMOTE_DIR/runtime"
|
||||
: > "$REMOTE_DIR/runtime/.env"
|
||||
: > "$REMOTE_DIR/runtime/config-db.env"
|
||||
printf 'CLICKHOUSE_PASSWORD=fixture\nBUSINESS_CLICKHOUSE_PASSWORD=fixture\n' > "$REMOTE_DIR/runtime/clickhouse.env"
|
||||
printf 'BUSINESS_STORAGE_DRIVER=qiniu\nQINIU_ACCESS_KEY=fixture\nQINIU_SECRET_KEY=fixture\n' > "$REMOTE_DIR/runtime/qiniu-storage.env"
|
||||
printf '%s\n' "$old" > "$REMOTE_DIR/.deployed-sha"
|
||||
printf 'previous runtime configuration\n' > "$REMOTE_DIR/compose.jiangling.yml"
|
||||
DOCKER_CALLS="$REMOTE_DIR/docker-calls"
|
||||
: > "$DOCKER_CALLS"
|
||||
FAKE_RUNNING_SHA="$old"
|
||||
export DOCKER_CALLS FAKE_RUNNING_SHA
|
||||
}
|
||||
|
||||
for activation in activate-release.sh activate-incremental-release.sh; do
|
||||
for scenario in stale missing-protocol marker-image-mismatch unchanged; do
|
||||
new_runtime "$activation-$scenario"
|
||||
: > "$REMOTE_DIR/upload.tar.gz"
|
||||
expected="$old"
|
||||
case "$scenario" in
|
||||
stale) printf '%s\n' "$new" > "$REMOTE_DIR/.deployed-sha"; FAKE_RUNNING_SHA="$new" ;;
|
||||
marker-image-mismatch) FAKE_RUNNING_SHA="$new" ;;
|
||||
esac
|
||||
set +e
|
||||
if [ "$scenario" = missing-protocol ]; then
|
||||
sh "$REPO/deploy/$activation" "$REMOTE_DIR/upload.tar.gz" tag "$REMOTE_DIR" "$requested" 0 2 > "$REMOTE_DIR/output" 2>&1
|
||||
else
|
||||
sh "$REPO/deploy/$activation" "$REMOTE_DIR/upload.tar.gz" tag "$REMOTE_DIR" "$requested" 0 2 "$expected" fixture > "$REMOTE_DIR/output" 2>&1
|
||||
fi
|
||||
status=$?
|
||||
set -e
|
||||
if [ "$scenario" = unchanged ]; then
|
||||
[ "$status" -eq 91 ] || { cat "$REMOTE_DIR/output"; fail 'accepted release did not reach image loading'; }
|
||||
else
|
||||
[ "$status" -ne 0 ] || fail "$activation accepted $scenario"
|
||||
if grep -Ev '^inspect ' "$DOCKER_CALLS" | grep -q .; then
|
||||
fail "$activation mutated Docker before rejecting $scenario"
|
||||
fi
|
||||
[ "$(cat "$REMOTE_DIR/compose.jiangling.yml")" = 'previous runtime configuration' ] || fail 'rejected release overwrote runtime configuration'
|
||||
fi
|
||||
if [ "$scenario" != missing-protocol ]; then
|
||||
[ -s "$REMOTE_DIR/runtime/private/deployments.tsv" ] || fail 'deployment receipt missing'
|
||||
if [ "$scenario" != unchanged ]; then
|
||||
grep -q 'blocked' "$REMOTE_DIR/runtime/private/deployments.tsv" || fail 'rejection not recorded'
|
||||
fi
|
||||
fi
|
||||
pass "$activation: $scenario"
|
||||
done
|
||||
done
|
||||
|
||||
. "$REPO/deploy/release-guard.sh"
|
||||
new_runtime bootstrap
|
||||
rm "$REMOTE_DIR/.deployed-sha"
|
||||
FAKE_RUNNING_SHA=''
|
||||
EXPECTED_PREVIOUS_SHA=none
|
||||
DEPLOY_SHA="$new"
|
||||
caiwu_verify_release_base
|
||||
pass 'first deployment with no running application'
|
||||
EXPECTED_PREVIOUS_SHA=''
|
||||
if caiwu_verify_release_base 2>/dev/null; then fail 'empty baseline accepted'; fi
|
||||
pass 'empty baseline rejected'
|
||||
|
||||
new_runtime ordered-uploaders
|
||||
EXPECTED_PREVIOUS_SHA="$old"
|
||||
DEPLOY_SHA="$new"
|
||||
# Both uploaders checked the same old revision. The newer one activates first.
|
||||
(
|
||||
exec 9> "$REMOTE_DIR/.deploy.lock"
|
||||
flock -x 9
|
||||
caiwu_verify_release_base
|
||||
printf '%s\n' "$new" > "$REMOTE_DIR/.deployed-sha"
|
||||
)
|
||||
FAKE_RUNNING_SHA="$new"
|
||||
DEPLOY_SHA="$requested"
|
||||
if (
|
||||
exec 9> "$REMOTE_DIR/.deploy.lock"
|
||||
flock -x 9
|
||||
caiwu_verify_release_base
|
||||
) 2>/dev/null; then fail 'delayed uploader overwrote the newer release'; fi
|
||||
[ "$(cat "$REMOTE_DIR/.deployed-sha")" = "$new" ] || fail 'production marker regressed'
|
||||
pass 'delayed uploader is rejected after a newer activation'
|
||||
|
||||
new_runtime staged-config
|
||||
RELEASE_DIR="$fixture/staging"
|
||||
RELEASE_APP_URL=''
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
for file in compose.jiangling.yml activate-release.sh activate-incremental-release.sh release-guard.sh retired-activation.sh install-release-protection.sh config-db-init.sh run-finance-batch.sh clickhouse-resource-policy.xml update-runtime-url.sh configure-dingtalk-callback.sh host-nginx-8442-dingtalk.conf setup-incremental-registry.sh registry-config.yml; do
|
||||
printf 'new staged runtime configuration\n' > "$RELEASE_DIR/$file"
|
||||
done
|
||||
printf '#!/bin/sh\nexit 0\n' > "$RELEASE_DIR/install-release-protection.sh"
|
||||
rm "$RELEASE_DIR/registry-config.yml"
|
||||
if caiwu_install_release_files 2>/dev/null; then fail 'partial upload accepted'; fi
|
||||
[ "$(cat "$REMOTE_DIR/compose.jiangling.yml")" = 'previous runtime configuration' ] || fail 'partial upload replaced runtime files'
|
||||
pass 'incomplete staging directory leaves runtime files untouched'
|
||||
printf 'complete\n' > "$RELEASE_DIR/registry-config.yml"
|
||||
caiwu_install_release_files
|
||||
[ "$(cat "$REMOTE_DIR/compose.jiangling.yml")" = 'new staged runtime configuration' ] || fail 'staged configuration not installed'
|
||||
pass 'complete isolated upload installs the intended runtime files'
|
||||
|
||||
new_runtime legacy-entrypoints
|
||||
for legacy in activate-release.sh activate-incremental-release.sh; do
|
||||
printf '#!/bin/sh\nexit 0\n' > "$REMOTE_DIR/$legacy"
|
||||
done
|
||||
sh "$REPO/deploy/install-release-protection.sh" "$REMOTE_DIR"
|
||||
for legacy in activate-release.sh activate-incremental-release.sh; do
|
||||
if cp "$REPO/deploy/$legacy" "$REMOTE_DIR/$legacy" 2>/dev/null; then fail 'old uploader replaced the protected entry point'; fi
|
||||
if sh "$REMOTE_DIR/$legacy" old-arguments 2>/dev/null; then fail 'old activation path accepted a release'; fi
|
||||
pass "$legacy: legacy uploader and activation both rejected"
|
||||
done
|
||||
sh "$REPO/deploy/install-release-protection.sh" "$REMOTE_DIR"
|
||||
pass 'installing entry point protection again is idempotent'
|
||||
printf 'PASS: %s release protection scenarios\n' "$passed"
|
||||
Loading…
x
Reference in New Issue
Block a user