From 5633ed2430eac89d9cd4cb7b4c8aa80dc554ec58 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 20 Oct 2026 19:08:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=85=A8=E6=95=B4=E5=90=88=E5=AD=98?= =?UTF-8?q?=E9=87=8F=E6=8A=A5=E4=BB=B7=E8=AE=A1=E8=B4=B9=E8=A7=84=E5=88=99?= =?UTF-8?q?=E4=B8=8E=E7=8B=AC=E7=AB=8B=E6=88=90=E6=9C=AC=E4=BB=B7=E8=A7=82?= =?UTF-8?q?=E6=B5=8B=20(#410)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ImportCourierQuoteBillingRules.php | 280 +++++++++++++++++ .../SyncJushuitanCostPriceHistory.php | 21 ++ app/Models/Business/CourierQuoteVersion.php | 11 + app/Support/CourierQuoteFreightCalculator.php | 157 ++++++++++ app/Support/CourierQuoteStructure.php | 117 +++++++ app/Support/QuoteManagementSnapshot.php | 18 ++ ...ure_to_business_courier_quote_versions.php | 46 +++ ...eate_jushuitan_cost_price_observations.php | 48 +++ deploy/activate-incremental-release.sh | 2 + deploy/activate-release.sh | 2 + docs/上游业务镜像清单.md | 8 + ...ushuitanCostPriceObservationRepository.php | 42 +++ .../ProductCatalogMirrorRepository.php | 2 + .../JushuitanCostPriceSnapshotDiffer.php | 137 +++++++++ .../quote-management.blade.php | 2 + .../normal-products.blade.php | 14 +- .../Business/CourierQuoteBillingRulesTest.php | 289 ++++++++++++++++++ .../Business/ProductCatalogPageTest.php | 8 +- .../JushuitanCostPriceSnapshotDifferTest.php | 113 +++++++ 19 files changed, 1314 insertions(+), 3 deletions(-) create mode 100644 app/Console/Commands/ImportCourierQuoteBillingRules.php create mode 100644 app/Support/CourierQuoteFreightCalculator.php create mode 100644 database/migrations/2026_10_18_000000_add_billing_structure_to_business_courier_quote_versions.php create mode 100644 database/migrations/2026_10_20_190000_create_jushuitan_cost_price_observations.php create mode 100644 modules/EcommerceFinance/Repositories/JushuitanCostPriceObservationRepository.php create mode 100644 modules/EcommerceFinance/Services/JushuitanCostPriceSnapshotDiffer.php create mode 100644 tests/Feature/Business/CourierQuoteBillingRulesTest.php create mode 100644 tests/Unit/EcommerceFinance/JushuitanCostPriceSnapshotDifferTest.php diff --git a/app/Console/Commands/ImportCourierQuoteBillingRules.php b/app/Console/Commands/ImportCourierQuoteBillingRules.php new file mode 100644 index 00000000..182ed795 --- /dev/null +++ b/app/Console/Commands/ImportCourierQuoteBillingRules.php @@ -0,0 +1,280 @@ +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 $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 */ + private function numbers(mixed $values): array + { + return array_values(array_map('floatval', array_filter((array) $values, 'is_numeric'))); + } +} diff --git a/app/Console/Commands/SyncJushuitanCostPriceHistory.php b/app/Console/Commands/SyncJushuitanCostPriceHistory.php index 110ab52b..945dad7b 100644 --- a/app/Console/Commands/SyncJushuitanCostPriceHistory.php +++ b/app/Console/Commands/SyncJushuitanCostPriceHistory.php @@ -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); diff --git a/app/Models/Business/CourierQuoteVersion.php b/app/Models/Business/CourierQuoteVersion.php index 764c3131..e7935fa2 100644 --- a/app/Models/Business/CourierQuoteVersion.php +++ b/app/Models/Business/CourierQuoteVersion.php @@ -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; diff --git a/app/Support/CourierQuoteFreightCalculator.php b/app/Support/CourierQuoteFreightCalculator.php new file mode 100644 index 00000000..d5f8ab74 --- /dev/null +++ b/app/Support/CourierQuoteFreightCalculator.php @@ -0,0 +1,157 @@ +> */ + 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> $rows + * @return array|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; + } +} diff --git a/app/Support/CourierQuoteStructure.php b/app/Support/CourierQuoteStructure.php index 3ae31c6d..30b50ded 100644 --- a/app/Support/CourierQuoteStructure.php +++ b/app/Support/CourierQuoteStructure.php @@ -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 $structure + * @param array $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); diff --git a/app/Support/QuoteManagementSnapshot.php b/app/Support/QuoteManagementSnapshot.php index 526b7409..440630d1 100644 --- a/app/Support/QuoteManagementSnapshot.php +++ b/app/Support/QuoteManagementSnapshot.php @@ -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(), diff --git a/database/migrations/2026_10_18_000000_add_billing_structure_to_business_courier_quote_versions.php b/database/migrations/2026_10_18_000000_add_billing_structure_to_business_courier_quote_versions.php new file mode 100644 index 00000000..41c6b3e6 --- /dev/null +++ b/database/migrations/2026_10_18_000000_add_billing_structure_to_business_courier_quote_versions.php @@ -0,0 +1,46 @@ +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); + } + } + }); + } +}; diff --git a/database/migrations/2026_10_20_190000_create_jushuitan_cost_price_observations.php b/database/migrations/2026_10_20_190000_create_jushuitan_cost_price_observations.php new file mode 100644 index 00000000..3dd3a7bb --- /dev/null +++ b/database/migrations/2026_10_20_190000_create_jushuitan_cost_price_observations.php @@ -0,0 +1,48 @@ +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'); + } +}; diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 354930ff..1a1a8014 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -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 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 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index 191b0504..c6d3d7c6 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -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 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 diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index e654e2fa..ad612a9e 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -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` diff --git a/modules/EcommerceFinance/Repositories/JushuitanCostPriceObservationRepository.php b/modules/EcommerceFinance/Repositories/JushuitanCostPriceObservationRepository.php new file mode 100644 index 00000000..f800f3b4 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/JushuitanCostPriceObservationRepository.php @@ -0,0 +1,42 @@ +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(); + } + } +} diff --git a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php index 0f49c1aa..b4e1a0dd 100644 --- a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php @@ -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', diff --git a/modules/EcommerceFinance/Services/JushuitanCostPriceSnapshotDiffer.php b/modules/EcommerceFinance/Services/JushuitanCostPriceSnapshotDiffer.php new file mode 100644 index 00000000..736fc73c --- /dev/null +++ b/modules/EcommerceFinance/Services/JushuitanCostPriceSnapshotDiffer.php @@ -0,0 +1,137 @@ +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; + } + } +} diff --git a/resources/views/business/pages/courier-payables/quote-management.blade.php b/resources/views/business/pages/courier-payables/quote-management.blade.php index 1b26a3f4..63628bdc 100644 --- a/resources/views/business/pages/courier-payables/quote-management.blade.php +++ b/resources/views/business/pages/courier-payables/quote-management.blade.php @@ -865,6 +865,7 @@
原始范围
执行日期
公式单元格
+
系统计费规则
@endif - @if (!($productCatalogJushuitanCostPriceHistorySyncStatus['initialized'] ?? false) && !$productCatalogError) + @if ((!($productCatalogJushuitanCostPriceHistorySyncStatus['initialized'] ?? false) || ($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'failed') && !$productCatalogError)
- @if (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initializing') + @if (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'failed') + 聚水潭成本价历史最近同步失败,记录可能不完整;镜像价格观测单独展示,不代表完整历史。 + @elseif (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initializing') 聚水潭成本价记录正在执行首次同步,完成前不显示记录数量。 @elseif (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initial_failed') 聚水潭成本价记录首次同步失败,当前记录数量不代表聚水潭真实历史。 @@ -905,6 +907,14 @@ {{ $jushuitanHistoryInitialized ? $jushuitanHistoryCount . ' 条' : '待同步' }}
+ @php($observations = $row->jushuitan_cost_price_observations ?? []) +
+ + 观测 {{ count($observations) }} 条(最近20条) + +
diff --git a/tests/Feature/Business/CourierQuoteBillingRulesTest.php b/tests/Feature/Business/CourierQuoteBillingRulesTest.php new file mode 100644 index 00000000..3068f4ec --- /dev/null +++ b/tests/Feature/Business/CourierQuoteBillingRulesTest.php @@ -0,0 +1,289 @@ +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, + ]); + } +} diff --git a/tests/Feature/Business/ProductCatalogPageTest.php b/tests/Feature/Business/ProductCatalogPageTest.php index abbdbdff..55a9d4ab 100644 --- a/tests/Feature/Business/ProductCatalogPageTest.php +++ b/tests/Feature/Business/ProductCatalogPageTest.php @@ -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') diff --git a/tests/Unit/EcommerceFinance/JushuitanCostPriceSnapshotDifferTest.php b/tests/Unit/EcommerceFinance/JushuitanCostPriceSnapshotDifferTest.php new file mode 100644 index 00000000..e499e1e0 --- /dev/null +++ b/tests/Unit/EcommerceFinance/JushuitanCostPriceSnapshotDifferTest.php @@ -0,0 +1,113 @@ + ['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, + ]); + } +}