安全整合存量报价计费规则与独立成本价观测 (#410)
This commit is contained in:
parent
d68548646d
commit
5633ed2430
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 Illuminate\Support\Facades\Log;
|
||||||
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
|
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
|
||||||
use Modules\EcommerceFinance\Services\JushuitanCostPriceHistorySynchronizer;
|
use Modules\EcommerceFinance\Services\JushuitanCostPriceHistorySynchronizer;
|
||||||
|
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class SyncJushuitanCostPriceHistory extends Command
|
class SyncJushuitanCostPriceHistory extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'business-mirror:sync-jushuitan-cost-price-history
|
protected $signature = 'business-mirror:sync-jushuitan-cost-price-history
|
||||||
{--full : 强制启动或继续首次全量同步}
|
{--full : 强制启动或继续首次全量同步}
|
||||||
|
{--source=open_api : open_api 为正式操作历史;snapshot 为独立的镜像价格观测}
|
||||||
{--page-size= : 聚水潭接口每页读取条数,最大 50}
|
{--page-size= : 聚水潭接口每页读取条数,最大 50}
|
||||||
{--max-pages= : 本轮最多读取页数,0 表示直到完成}';
|
{--max-pages= : 本轮最多读取页数,0 表示直到完成}';
|
||||||
|
|
||||||
@ -25,6 +27,25 @@ class SyncJushuitanCostPriceHistory extends Command
|
|||||||
return self::SUCCESS;
|
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 {
|
try {
|
||||||
$state = $mirror->state();
|
$state = $mirror->state();
|
||||||
$full = $this->shouldRunFull($state);
|
$full = $this->shouldRunFull($state);
|
||||||
|
|||||||
@ -14,6 +14,7 @@ class CourierQuoteVersion extends Model
|
|||||||
'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name',
|
'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',
|
'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',
|
'status', 'signature_status', 'created_by', 'approved_by', 'void_reason', 'voided_at', 'voided_by',
|
||||||
|
'billing_structure', 'billing_rows',
|
||||||
];
|
];
|
||||||
|
|
||||||
public const STATUS_APPROVED = 'approved';
|
public const STATUS_APPROVED = 'approved';
|
||||||
@ -26,6 +27,8 @@ class CourierQuoteVersion extends Model
|
|||||||
'effective_date' => 'date',
|
'effective_date' => 'date',
|
||||||
'end_date' => 'date',
|
'end_date' => 'date',
|
||||||
'voided_at' => 'datetime',
|
'voided_at' => 'datetime',
|
||||||
|
'billing_structure' => 'array',
|
||||||
|
'billing_rows' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */
|
/** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */
|
||||||
@ -56,6 +59,14 @@ class CourierQuoteVersion extends Model
|
|||||||
: $this->effective_date->toDateString() . ' 起';
|
: $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
|
public function isVoided(): bool
|
||||||
{
|
{
|
||||||
return $this->status === self::STATUS_VOIDED;
|
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)',
|
'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_BANDS = 12;
|
||||||
|
|
||||||
public const MAX_TIERS = 6;
|
public const MAX_TIERS = 6;
|
||||||
@ -272,10 +279,120 @@ final class CourierQuoteStructure
|
|||||||
? "首重{$fw}kg价+(重量-{$fw})*续重"
|
? "首重{$fw}kg价+(重量-{$fw})*续重"
|
||||||
: '面单+重量*费率') . $tierNote,
|
: '面单+重量*费率') . $tierNote,
|
||||||
'other' => '特殊计费,见附件报价表',
|
'other' => '特殊计费,见附件报价表',
|
||||||
|
self::SEGMENTS => '分段计费:每个重量段单独设一口价、面单+费率或首重+续重(可表示累进计费)',
|
||||||
default => '面单+重量*费率' . $tierNote,
|
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
|
public static function usesRates(string $mode): bool
|
||||||
{
|
{
|
||||||
return in_array($mode, ['waybill_rate', 'first_additional', 'flat_then'], true);
|
return in_array($mode, ['waybill_rate', 'first_additional', 'flat_then'], true);
|
||||||
|
|||||||
@ -142,6 +142,23 @@ final class QuoteManagementSnapshot
|
|||||||
return $companyName !== '' ? $companyName : $name;
|
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
|
private function normalizeVersion(CourierQuoteVersion $version): array
|
||||||
{
|
{
|
||||||
$values = $this->decodeMatrix($version->values_json);
|
$values = $this->decodeMatrix($version->values_json);
|
||||||
@ -155,6 +172,7 @@ final class QuoteManagementSnapshot
|
|||||||
'company_name' => $version->company_name,
|
'company_name' => $version->company_name,
|
||||||
'courier_expcode' => $version->courier_expcode,
|
'courier_expcode' => $version->courier_expcode,
|
||||||
'billing_rules' => $version->billing_rules,
|
'billing_rules' => $version->billing_rules,
|
||||||
|
'billing_summary' => $version->hasBillingRules() ? $this->billingSummary($version) : null,
|
||||||
'carrier' => $version->carrier_name,
|
'carrier' => $version->carrier_name,
|
||||||
'quote_type' => $version->quote_type,
|
'quote_type' => $version->quote_type,
|
||||||
'effective_date' => $version->effective_date?->toDateString(),
|
'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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -210,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
|
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 /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
|
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
|
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 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
|
# 店铺违规信息预警:每天 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
|
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
|
||||||
|
|||||||
@ -214,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
|
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 /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
|
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
|
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 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
|
# 店铺违规信息预警:每天 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
|
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
|
||||||
|
|||||||
@ -35,6 +35,14 @@
|
|||||||
|
|
||||||
抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。
|
抖音月中校准可在上述导入命令追加 `--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)
|
## 经营分析副本库(operations_replica)
|
||||||
|
|
||||||
店铺维度报表不使用订单件数代替销量。中央刷新节点按上游主键 `Id`
|
店铺维度报表不使用订单件数代替销量。中央刷新节点按上游主键 `Id`
|
||||||
|
|||||||
@ -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']);
|
$this->attachWorkPlatformPrices($rows, $workPlatformPriceSyncStatus['initialized']);
|
||||||
$jushuitanCostPriceHistorySyncStatus = $this->jushuitanCostPriceHistorySyncStatus();
|
$jushuitanCostPriceHistorySyncStatus = $this->jushuitanCostPriceHistorySyncStatus();
|
||||||
$this->attachJushuitanCostPriceHistory($rows, $jushuitanCostPriceHistorySyncStatus['initialized']);
|
$this->attachJushuitanCostPriceHistory($rows, $jushuitanCostPriceHistorySyncStatus['initialized']);
|
||||||
|
app(JushuitanCostPriceObservationRepository::class)->attach($rows);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'data' => $rows,
|
'data' => $rows,
|
||||||
@ -571,6 +572,7 @@ class ProductCatalogMirrorRepository
|
|||||||
$query = $this->connection->table($productTable)
|
$query = $this->connection->table($productTable)
|
||||||
->where('sku_type', 'normal')
|
->where('sku_type', 'normal')
|
||||||
->select([
|
->select([
|
||||||
|
'_mirror_key',
|
||||||
'autoid',
|
'autoid',
|
||||||
'i_id',
|
'i_id',
|
||||||
'sku_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -865,6 +865,7 @@
|
|||||||
<div><dt>原始范围</dt><dd data-quote-detail="range"></dd></div>
|
<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="date"></dd></div>
|
||||||
<div><dt>公式单元格</dt><dd data-quote-detail="formulas"></dd></div>
|
<div><dt>公式单元格</dt><dd data-quote-detail="formulas"></dd></div>
|
||||||
|
<div><dt>系统计费规则</dt><dd data-quote-detail="billing"></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="quote-signed" data-quote-signed hidden>
|
<div class="quote-signed" data-quote-signed hidden>
|
||||||
<span class="material-icons-outlined" aria-hidden="true">task_alt</span>
|
<span class="material-icons-outlined" aria-hidden="true">task_alt</span>
|
||||||
@ -1180,6 +1181,7 @@
|
|||||||
setDetail('range', text(quote.source_range, '-'));
|
setDetail('range', text(quote.source_range, '-'));
|
||||||
setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注'));
|
setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注'));
|
||||||
setDetail('formulas', String(quote.formula_count));
|
setDetail('formulas', String(quote.formula_count));
|
||||||
|
setDetail('billing', text(quote.billing_summary, '未设置(运费需人工核算)'));
|
||||||
var originals = root.querySelector('[data-stock-original-links]');
|
var originals = root.querySelector('[data-stock-original-links]');
|
||||||
originals.replaceChildren();
|
originals.replaceChildren();
|
||||||
(quote.stock_originals || []).forEach(function (file, index, files) {
|
(quote.stock_originals || []).forEach(function (file, index, files) {
|
||||||
|
|||||||
@ -778,7 +778,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (!($productCatalogJushuitanCostPriceHistorySyncStatus['initialized'] ?? false) && !$productCatalogError)
|
@if ((!($productCatalogJushuitanCostPriceHistorySyncStatus['initialized'] ?? false) || ($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'failed') && !$productCatalogError)
|
||||||
<div
|
<div
|
||||||
class="product-catalog-notice"
|
class="product-catalog-notice"
|
||||||
role="status"
|
role="status"
|
||||||
@ -786,7 +786,9 @@
|
|||||||
>
|
>
|
||||||
<span class="material-icons-outlined text-base" aria-hidden="true">schedule</span>
|
<span class="material-icons-outlined text-base" aria-hidden="true">schedule</span>
|
||||||
<span>
|
<span>
|
||||||
@if (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initializing')
|
@if (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'failed')
|
||||||
|
聚水潭成本价历史最近同步失败,记录可能不完整;镜像价格观测单独展示,不代表完整历史。
|
||||||
|
@elseif (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initializing')
|
||||||
聚水潭成本价记录正在执行首次同步,完成前不显示记录数量。
|
聚水潭成本价记录正在执行首次同步,完成前不显示记录数量。
|
||||||
@elseif (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initial_failed')
|
@elseif (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initial_failed')
|
||||||
聚水潭成本价记录首次同步失败,当前记录数量不代表聚水潭真实历史。
|
聚水潭成本价记录首次同步失败,当前记录数量不代表聚水潭真实历史。
|
||||||
@ -905,6 +907,14 @@
|
|||||||
<span class="product-catalog-history-count">{{ $jushuitanHistoryInitialized ? $jushuitanHistoryCount . ' 条' : '待同步' }}</span>
|
<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>
|
<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>
|
</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>
|
||||||
<td>
|
<td>
|
||||||
<div class="product-catalog-history-action">
|
<div class="product-catalog-history-action">
|
||||||
|
|||||||
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' => '商品与库存管理导入',
|
'operation' => '商品与库存管理导入',
|
||||||
]],
|
]],
|
||||||
'jushuitan_cost_price_history_count' => 1,
|
'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_unit_price' => 65,
|
||||||
'purchase_order_finished_at' => '2026-08-20 11:03:05',
|
'purchase_order_finished_at' => '2026-08-20 11:03:05',
|
||||||
'purchase_order_id' => 827380,
|
'purchase_order_id' => 827380,
|
||||||
@ -143,7 +147,7 @@ class ProductCatalogPageTest extends FeatureTestCase
|
|||||||
'last_succeeded_at' => '2026-09-01 03:02:00',
|
'last_succeeded_at' => '2026-09-01 03:02:00',
|
||||||
],
|
],
|
||||||
'jushuitan_cost_price_history_sync_status' => [
|
'jushuitan_cost_price_history_sync_status' => [
|
||||||
'code' => 'ready',
|
'code' => 'failed',
|
||||||
'initialized' => true,
|
'initialized' => true,
|
||||||
'last_succeeded_at' => '2026-09-15 00:05:00',
|
'last_succeeded_at' => '2026-09-15 00:05:00',
|
||||||
],
|
],
|
||||||
@ -166,6 +170,8 @@ class ProductCatalogPageTest extends FeatureTestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('data-product-catalog-page', false)
|
->assertSee('data-product-catalog-page', false)
|
||||||
->assertSeeText('普通商品资料')
|
->assertSeeText('普通商品资料')
|
||||||
|
->assertSeeText('聚水潭成本价历史最近同步失败')
|
||||||
|
->assertSeeText('观测 1 条(最近20条)')
|
||||||
->assertSee('data-sync-state="healthy"', false)
|
->assertSee('data-sync-state="healthy"', false)
|
||||||
->assertSeeText('同步正常')
|
->assertSeeText('同步正常')
|
||||||
->assertSeeText('最近同步 2026-08-15 17:51:03')
|
->assertSeeText('最近同步 2026-08-15 17:51:03')
|
||||||
|
|||||||
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user