*/ private const REQUIRED_DATASETS = [ self::PRIMARY_DATASET, self::EXPRESS_ORIGINAL_DATASET, self::BOOKKEEPING_CONFIG_DATASET, self::CHECK_DATASET, self::CHECK_CONFIG_DATASET, self::SHIPPING_DETAILS_DATASET, self::SHIPPING_EXTENSION_DATASET, self::OUTBOUND_DATASET, self::RETURN_DATASET, ]; /** @var list */ public const SURCHARGE_TYPES = [ '退回费', '拦截费', '改地址', '上楼费', '送货费', '超区费', '安装费', '保费', '进仓费', ]; /** * 上游审核字段是自由文本;只有这些明确的已批准值允许进入页面。 * 其它说明、退回原因、空值都不能被当作审核通过。 * * @var list */ private const APPROVED_VALUES = ['通过', '同意', '同意记账']; private readonly ConnectionInterface $connection; public function __construct( DatabaseManager $database, private readonly BusinessMirrorDatasetRegistry $datasets, private readonly GenericBusinessMirrorRepository $mirror, ) { $this->connection = $database->connection((string) config('upstream_mirrors.connection', 'business_mirror')); } public function available(): bool { $schema = Schema::connection($this->connection->getName()); if (! $schema->hasTable('business_mirror_table_states')) { return false; } foreach (self::REQUIRED_DATASETS as $key) { if (! $schema->hasTable($this->table($key))) { return false; } } return true; } public function ready(): bool { foreach (self::REQUIRED_DATASETS as $key) { $dataset = $this->datasets->get($key); if (! $this->mirror->ready($dataset)) { return false; } } return true; } /** @return array */ public function state(): array { $states = []; foreach (self::REQUIRED_DATASETS as $key) { $dataset = $this->datasets->get($key); $states[$key] = $this->mirror->state($dataset); } $required = array_intersect_key($states, array_flip(self::REQUIRED_DATASETS)); $failed = collect($required)->first(fn (?array $state): bool => ($state['status'] ?? null) === 'failed'); $pending = collect($required)->first(fn (?array $state): bool => ($state['initial_completed_at'] ?? null) === null); $running = collect($required)->first(fn (?array $state): bool => ($state['status'] ?? null) === 'running'); // The page is only as fresh as its oldest required dataset. Using the // newest timestamp here would hide a lagging lookup table behind a // recently completed fact-table sync. $lastSucceeded = collect($required) ->map(fn (?array $state): ?string => $state['last_succeeded_at'] ?? null) ->filter() ->sort() ->first(); $staleMinutes = max((int) config('upstream_mirrors.courier_surcharge_settlement.stale_after_minutes', 60), 1); $code = $failed !== null ? 'failed' : ($pending !== null ? 'pending' : ($running !== null ? 'syncing' : 'fresh')); if ($code === 'fresh' && $lastSucceeded !== null && now()->diffInMinutes($lastSucceeded) > $staleMinutes) { $code = 'stale'; } return [ 'code' => $code, 'last_succeeded_at' => $lastSucceeded, 'failure_stage' => $failed === null ? null : $this->datasetKeyForState($required, $failed), 'error_code' => $failed['last_error_code'] ?? null, 'required' => $required, ]; } /** * @param array $filters * @param Collection $quotes * @return array{data:Collection>,has_more:bool,total:int,summary:array,sync:array,state:array} */ public function paginate(array $filters, int $page, int $limit, Collection $quotes): array { $state = $this->state(); if (! $this->ready()) { return [ 'data' => collect(), 'has_more' => false, 'total' => 0, 'summary' => ['count' => 0, 'settlement_amount' => 0.0], 'sync' => $state, 'state' => $state, ]; } $query = $this->baseQuery(); $this->applyFilters($query, $filters); $limit = min(max($limit, 1), 100); if (($filters['status'] ?? '') !== '') { // 结算状态来自本地报价匹配,不能下推到镜像 SQL。按稳定顺序 // 流式读取并只保留当前页,避免大镜像表一次性 get() 造成内存峰值。 $offset = (max($page, 1) - 1) * $limit; $matched = collect(); $total = 0; foreach ($query->orderBy('b.CreationTime')->orderBy('b.Id')->cursor() as $sourceRow) { $row = $this->mapRow($sourceRow, $quotes); if ($row['settlement_status'] !== $filters['status']) { continue; } if ($total >= $offset && $matched->count() < $limit) { $matched->push($row); } $total++; } $rows = $matched; } else { $total = (clone $query)->count('b.Id'); $rows = $query ->orderBy('b.CreationTime') ->orderBy('b.Id') ->forPage(max($page, 1), $limit + 1) ->get() ->map(fn (object $row): array => $this->mapRow($row, $quotes)) ->values(); } $hasMore = ($filters['status'] ?? '') !== '' ? $total > ((max($page, 1) - 1) * $limit) + $limit : $rows->count() > $limit; $rows = $rows->take($limit)->values(); return [ 'data' => $rows, 'has_more' => $hasMore, 'total' => $total, 'summary' => [ 'count' => $total, 'settlement_amount' => round((float) $rows->sum(fn (array $row): float => (float) ($row['settlement_amount'] ?? 0)), 2), ], 'sync' => $state, 'state' => $state, ]; } private function baseQuery(): Builder { $bookkeeping = $this->table(self::PRIMARY_DATASET); $bookkeepingConfig = $this->table(self::BOOKKEEPING_CONFIG_DATASET); $check = $this->table(self::CHECK_DATASET); $checkConfig = $this->table(self::CHECK_CONFIG_DATASET); $query = $this->connection->table($bookkeeping . ' as b') ->leftJoin($bookkeepingConfig . ' as bm', 'bm.Id', '=', 'b.ReasonId') ->leftJoin($bookkeepingConfig . ' as bs', 'bs.Id', '=', 'b.SecodaryCause') ->leftJoinSub($this->latestCheck(), 'r', function ($join): void { $join->on('r.ExpressNum', '=', 'b.ExpressNum') ->on('r.OrderNum', '=', 'b.OrderNum'); }) ->leftJoin($checkConfig . ' as rt', 'rt.Id', '=', 'r.CheckType') ->leftJoin($checkConfig . ' as rr', function ($join): void { $join->on('rr.Id', '=', 'r.CheckReason') // 查件原因必须是所选查件类型的二级选项,避免同名配置串层。 ->on('rr.ParentId', '=', 'r.CheckType'); }); // 镜像同步使用同一固定条件;这里再次约束,避免旧镜像中残留未审核行。 $query->whereIn('b.IsAgreeReason', $this->approvedValues()); // 物流公司、商品、店铺、尺寸和重量复用已经上线的物流业务镜像。 // 先按单号+线上订单号精确关联,再用单号的最新记录补齐历史数据, // 避免新增依赖旧的 fys_omsDataDay 快照表。 $query->leftJoinSub($this->latestShippingDetailByOrder(), 'd', function ($join): void { $join->on('d.logistic_id', '=', 'b.ExpressNum') ->on('d.so_id', '=', 'b.OrderNum'); })->leftJoinSub($this->latestShippingDetailByWaybill(), 'd0', function ($join): void { $join->on('d0.logistic_id', '=', 'b.ExpressNum'); })->leftJoinSub($this->latestShippingExtension(), 'x', function ($join): void { $join->on('x.so_id', '=', 'b.OrderNum'); })->leftJoinSub($this->latestOutbound(), 'o', function ($join): void { $join->on('o.io_id', '=', 'x.io_id'); })->leftJoinSub($this->latestExpressOriginal(), 'e', function ($join): void { // express_Original.OrderNum 是附加费记录的物流单号;保留记账表 // 字段作为后备,避免历史记录没有对应原始物流行时整行消失。 $join->on('e.OrderNum', '=', 'b.ExpressNum'); }); if ($this->hasTable(self::RETURN_DATASET)) { $query->leftJoinSub($this->latestReturn(), 'ret', function ($join): void { $join->where(function ($condition): void { $condition->on('ret.transactionNo', '=', 'b.OrderNum') ->orOn('ret.orderId', '=', 'b.OrderNum') ->orOn('ret.returnExpressNo', '=', 'b.ExpressNum'); }); }); } $type = $this->surchargeTypeExpression(); $select = [ 'b.Id as source_id', 'b.CreationTime as created_at', $this->connection->raw("COALESCE(NULLIF(TRIM(r.OmsSDName), ''), NULLIF(TRIM(e.ExpressName), ''), NULLIF(TRIM(d.logistic_company_name), ''), NULLIF(TRIM(d0.logistic_company_name), ''), NULLIF(TRIM(b.ExpressName), ''), b.ShipExpressName) as courier_name"), $this->connection->raw("COALESCE(NULLIF(TRIM(e.OrderNum), ''), b.ExpressNum) as waybill"), $this->connection->raw("COALESCE(NULLIF(TRIM(e.shopproid), ''), NULLIF(TRIM(b.shopproid), ''), NULLIF(TRIM(r.shopproid), ''), NULLIF(TRIM(x.shop_i_id), '')) as platform_product_id"), $this->connection->raw("COALESCE(NULLIF(TRIM(e.SkuCode), ''), NULLIF(TRIM(b.SkuCode), ''), NULLIF(TRIM(r.SkuCode), ''), NULLIF(TRIM(x.sku_ids), '')) as product_code"), $this->connection->raw("COALESCE(NULLIF(TRIM(e.ShopName), ''), NULLIF(TRIM(x.shop_name), ''), NULLIF(TRIM(d.shop_name), ''), NULLIF(TRIM(d0.shop_name), ''), b.ShopCode) as shop_name"), $this->connection->raw("COALESCE(NULLIF(TRIM(e.JiaoYiNum), ''), b.OrderNum) as online_order_no"), $this->connection->raw("COALESCE(e.Lenght, x.l) as length"), $this->connection->raw("COALESCE(e.Width, x.w) as width"), $this->connection->raw("COALESCE(e.Height, x.h) as height"), $this->connection->raw("COALESCE(e.Volume, NULLIF(o.f_volume, ''), NULLIF(o.volume, ''), NULLIF(x.volume, ''), b.Svolume) as volume"), $this->connection->raw('COALESCE(rt.Name, \'\') as inspection_type'), // “查件原因”必须来自查件配置的二级选项;记账次要原因 // 不是同一业务字段,不能在这里兜底混入。 $this->connection->raw("COALESCE(rr.Name, '') as inspection_reason"), $this->connection->raw('ret.returnExpressNo as return_waybill_no'), $this->connection->raw("COALESCE(e.SetWeight, NULLIF(o.f_weight, ''), NULLIF(o.weight, ''), NULLIF(d.f_weight, ''), NULLIF(d0.f_weight, ''), NULLIF(x.weight, ''), b.Skuweight) as settlement_weight"), $this->connection->raw("COALESCE(e.SetMoney, b.Payfee) as source_amount"), $this->connection->raw("{$type} as surcharge_type"), 'b.IsAgreeReason as approval_status', 'bm.Name as primary_reason', 'bs.Name as secondary_reason', ]; return $query->select($select); } private function latestCheck(): Builder { $table = $this->table(self::CHECK_DATASET); return $this->connection->table($table . ' as candidate') ->whereNotExists(function ($query) use ($table): void { $query->selectRaw('1')->from($table . ' as newer') ->whereColumn('newer.ExpressNum', '=', 'candidate.ExpressNum') ->whereColumn('newer.OrderNum', '=', 'candidate.OrderNum') ->whereColumn('newer.Id', '>', 'candidate.Id'); }) ->select([ 'candidate.ExpressNum', 'candidate.OrderNum', 'candidate.CheckType', 'candidate.CheckReason', 'candidate.OmsSDName', 'candidate.DHLName', 'candidate.SkuCode', 'candidate.shopproid', ]); } private function latestExpressOriginal(): Builder { $table = $this->table(self::EXPRESS_ORIGINAL_DATASET); return $this->connection->table($table . ' as candidate') ->whereNotExists(function ($query) use ($table): void { $query->selectRaw('1')->from($table . ' as newer') ->whereColumn('newer.OrderNum', '=', 'candidate.OrderNum') ->whereColumn('newer.Id', '>', 'candidate.Id'); }) ->select([ 'candidate.Id', 'candidate.ExpressName', 'candidate.OrderNum', 'candidate.Lenght', 'candidate.Width', 'candidate.Height', 'candidate.Volume', 'candidate.SkuCode', 'candidate.JiaoYiNum', 'candidate.ShopName', 'candidate.SetMoney', 'candidate.SetWeight', 'candidate.shopproid', ]); } private function latestShippingDetailByOrder(): Builder { $table = $this->table(self::SHIPPING_DETAILS_DATASET); return $this->connection->table($table . ' as candidate') ->whereNotExists(function ($query) use ($table): void { $query->selectRaw('1')->from($table . ' as newer') ->whereColumn('newer.logistic_id', '=', 'candidate.logistic_id') ->whereColumn('newer.so_id', '=', 'candidate.so_id') ->whereColumn('newer.id', '>', 'candidate.id'); }) ->select(['candidate.id', 'candidate.logistic_id', 'candidate.so_id', 'candidate.logistic_company_name', 'candidate.shop_name', 'candidate.f_weight']); } private function latestShippingDetailByWaybill(): Builder { $table = $this->table(self::SHIPPING_DETAILS_DATASET); return $this->connection->table($table . ' as candidate') ->whereNotExists(function ($query) use ($table): void { $query->selectRaw('1')->from($table . ' as newer') ->whereColumn('newer.logistic_id', '=', 'candidate.logistic_id') ->whereColumn('newer.id', '>', 'candidate.id'); }) ->select(['candidate.id', 'candidate.logistic_id', 'candidate.logistic_company_name', 'candidate.shop_name', 'candidate.f_weight']); } private function latestShippingExtension(): Builder { $table = $this->table(self::SHIPPING_EXTENSION_DATASET); return $this->connection->table($table . ' as candidate') ->whereNotExists(function ($query) use ($table): void { $query->selectRaw('1')->from($table . ' as newer') ->whereColumn('newer.so_id', '=', 'candidate.so_id') ->whereColumn('newer.Id', '>', 'candidate.Id'); }) ->select(['candidate.Id', 'candidate.io_id', 'candidate.so_id', 'candidate.shop_i_id', 'candidate.sku_ids', 'candidate.shop_name', 'candidate.l', 'candidate.w', 'candidate.h', 'candidate.volume', 'candidate.weight']); } private function latestOutbound(): Builder { $table = $this->table(self::OUTBOUND_DATASET); return $this->connection->table($table . ' as candidate') ->whereNotExists(function ($query) use ($table): void { $query->selectRaw('1')->from($table . ' as newer') ->whereColumn('newer.io_id', '=', 'candidate.io_id') ->whereColumn('newer.Id', '>', 'candidate.Id'); }) ->select(['candidate.Id', 'candidate.io_id', 'candidate.f_weight', 'candidate.weight', 'candidate.f_volume', 'candidate.volume']); } private function latestReturn(): Builder { $table = $this->table(self::RETURN_DATASET); return $this->connection->table($table . ' as candidate') ->whereNotExists(function ($query) use ($table): void { $query->selectRaw('1')->from($table . ' as newer') ->where(function ($condition): void { $condition->whereColumn('newer.transactionNo', '=', 'candidate.transactionNo') ->orWhereColumn('newer.orderId', '=', 'candidate.orderId') ->orWhereColumn('newer.returnExpressNo', '=', 'candidate.returnExpressNo'); }) ->whereColumn('newer.Id', '>', 'candidate.Id'); }) ->select(['candidate.orderId', 'candidate.transactionNo', 'candidate.returnExpressNo']); } /** @param array $filters */ private function applyFilters(Builder $query, array $filters): void { if (($filters['courier_name'] ?? '') !== '') { $courier = trim((string) $filters['courier_name']); $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $courier) . '%'; $query->whereRaw("COALESCE(NULLIF(TRIM(r.OmsSDName), ''), NULLIF(TRIM(e.ExpressName), ''), NULLIF(TRIM(d.logistic_company_name), ''), NULLIF(TRIM(d0.logistic_company_name), ''), NULLIF(TRIM(b.ExpressName), ''), b.ShipExpressName) LIKE ? ESCAPE '!'", [$needle]); } $waybillColumn = "COALESCE(NULLIF(TRIM(e.OrderNum), ''), b.ExpressNum)"; if (($filters['waybill'] ?? '') !== '') { $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['waybill'])) . '%'; $query->whereRaw($waybillColumn . " LIKE ? ESCAPE '!'", [$needle]); } $onlineOrderColumn = "COALESCE(NULLIF(TRIM(e.JiaoYiNum), ''), b.OrderNum)"; if (($filters['online_order_no'] ?? '') !== '') { $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['online_order_no'])) . '%'; $query->whereRaw($onlineOrderColumn . " LIKE ? ESCAPE '!'", [$needle]); } $platformProductColumn = "COALESCE(NULLIF(TRIM(e.shopproid), ''), NULLIF(TRIM(b.shopproid), ''), NULLIF(TRIM(r.shopproid), ''), NULLIF(TRIM(x.shop_i_id), ''))"; $productCodeColumn = "COALESCE(NULLIF(TRIM(e.SkuCode), ''), NULLIF(TRIM(b.SkuCode), ''), NULLIF(TRIM(r.SkuCode), ''), NULLIF(TRIM(x.sku_ids), ''))"; if (($filters['platform_product_id'] ?? '') !== '') { $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['platform_product_id'])) . '%'; $query->whereRaw($platformProductColumn . " LIKE ? ESCAPE '!'", [$needle]); } if (($filters['product_code'] ?? '') !== '') { $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim((string) $filters['product_code'])) . '%'; $query->whereRaw($productCodeColumn . " LIKE ? ESCAPE '!'", [$needle]); } if (($filters['shop_name'] ?? '') !== '') { $shop = trim((string) $filters['shop_name']); $needle = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $shop) . '%'; $shopColumn = "COALESCE(NULLIF(TRIM(e.ShopName), ''), NULLIF(TRIM(x.shop_name), ''), NULLIF(TRIM(d.shop_name), ''), NULLIF(TRIM(d0.shop_name), ''), b.ShopCode)"; $query->whereRaw($shopColumn . " LIKE ? ESCAPE '!'", [$needle]); } if (($filters['created_from'] ?? '') !== '') { $query->where('b.CreationTime', '>=', $filters['created_from'] . ' 00:00:00'); } $type = $this->surchargeTypeExpression(); if (($filters['surcharge_type'] ?? '') !== '') $query->whereRaw("{$type} = ?", [$filters['surcharge_type']]); if (($filters['inspection_type'] ?? '') !== '') $query->where('rt.Name', '=', $filters['inspection_type']); if (($filters['inspection_reason'] ?? '') !== '') $query->where('rr.Name', '=', $filters['inspection_reason']); if (($filters['created_to'] ?? '') !== '') $query->where('b.CreationTime', '<', date('Y-m-d 00:00:00', strtotime($filters['created_to'] . ' +1 day'))); $query->whereRaw("{$type} IN (?, ?, ?, ?, ?, ?, ?, ?, ?)", self::SURCHARGE_TYPES); } private function surchargeTypeExpression(): string { return "CASE WHEN LOWER(LTRIM(RTRIM(COALESCE(b.IfReturn, '')))) IN ('已退回', '1', '是', 'true') THEN '退回费' WHEN LOWER(LTRIM(RTRIM(COALESCE(b.IfBuckle, '')))) IN ('已扣单', '1', '是', 'true') THEN '拦截费' WHEN COALESCE(bm.Name, '') LIKE '%退回%' OR COALESCE(bs.Name, '') LIKE '%退回%' OR COALESCE(bm.Name, '') LIKE '%退货%' OR COALESCE(bs.Name, '') LIKE '%退货%' OR COALESCE(rt.Name, '') LIKE '%退回%' OR COALESCE(rr.Name, '') LIKE '%退回%' THEN '退回费' WHEN COALESCE(bm.Name, '') LIKE '%拦截%' OR COALESCE(bs.Name, '') LIKE '%拦截%' OR COALESCE(b.expressCause, '') LIKE '%拦截%' OR COALESCE(rt.Name, '') LIKE '%拦截%' OR COALESCE(rr.Name, '') LIKE '%拦截%' OR COALESCE(rt.Name, '') LIKE '%扣单%' OR COALESCE(rr.Name, '') LIKE '%扣单%' THEN '拦截费' WHEN COALESCE(bm.Name, '') LIKE '%改地址%' OR COALESCE(bs.Name, '') LIKE '%改地址%' OR COALESCE(b.expressCause, '') LIKE '%改地址%' OR COALESCE(rt.Name, '') LIKE '%改地址%' OR COALESCE(rr.Name, '') LIKE '%改地址%' THEN '改地址' WHEN COALESCE(bm.Name, '') LIKE '%上楼%' OR COALESCE(bs.Name, '') LIKE '%上楼%' OR COALESCE(b.expressCause, '') LIKE '%上楼%' OR COALESCE(rt.Name, '') LIKE '%上楼%' OR COALESCE(rr.Name, '') LIKE '%上楼%' THEN '上楼费' WHEN COALESCE(bm.Name, '') LIKE '%送货%' OR COALESCE(bs.Name, '') LIKE '%送货%' OR COALESCE(b.expressCause, '') LIKE '%送货%' OR COALESCE(rt.Name, '') LIKE '%送货%' OR COALESCE(rr.Name, '') LIKE '%送货%' THEN '送货费' WHEN COALESCE(bm.Name, '') LIKE '%超区%' OR COALESCE(bs.Name, '') LIKE '%超区%' OR COALESCE(b.expressCause, '') LIKE '%超区%' OR COALESCE(rt.Name, '') LIKE '%超区%' OR COALESCE(rr.Name, '') LIKE '%超区%' THEN '超区费' WHEN COALESCE(bm.Name, '') LIKE '%安装%' OR COALESCE(bs.Name, '') LIKE '%安装%' OR COALESCE(b.expressCause, '') LIKE '%安装%' OR COALESCE(rt.Name, '') LIKE '%安装%' OR COALESCE(rr.Name, '') LIKE '%安装%' THEN '安装费' WHEN COALESCE(bm.Name, '') LIKE '%保费%' OR COALESCE(bs.Name, '') LIKE '%保费%' OR COALESCE(bm.Name, '') LIKE '%保丢保损%' OR COALESCE(bs.Name, '') LIKE '%保丢保损%' OR COALESCE(b.expressCause, '') LIKE '%保费%' OR COALESCE(rt.Name, '') LIKE '%保费%' OR COALESCE(rr.Name, '') LIKE '%保费%' THEN '保费' WHEN COALESCE(bm.Name, '') LIKE '%进仓%' OR COALESCE(bs.Name, '') LIKE '%进仓%' OR COALESCE(b.expressCause, '') LIKE '%进仓%' OR COALESCE(rt.Name, '') LIKE '%进仓%' OR COALESCE(rr.Name, '') LIKE '%进仓%' THEN '进仓费' ELSE NULL END"; } /** @return list */ private function approvedValues(): array { $configured = (array) config('upstream_mirrors.courier_surcharge_settlement.approved_values', self::APPROVED_VALUES); $values = array_values(array_filter(array_map('strval', $configured), static fn (string $value): bool => trim($value) !== '')); return $values === [] ? self::APPROVED_VALUES : $values; } /** @param Collection|CourierSurchargeQuote> $quotes */ private function mapRow(object $row, Collection $quotes): array { $type = trim((string) ($row->surcharge_type ?? '')); $quote = $quotes->first(function (array|CourierSurchargeQuote $quote) use ($row, $type): bool { $status = is_array($quote) ? ($quote['status_code'] ?? $quote['status'] ?? null) : $quote->status; $courier = is_array($quote) ? ($quote['courier'] ?? '') : $quote->courier; $types = is_array($quote) ? ($quote['types'] ?? []) : $quote->types; return $status === CourierSurchargeQuote::STATUS_ACTIVE && (string) $courier === (string) ($row->courier_name ?? '') && in_array($type, (array) $types, true); }); $weight = $this->number($row->settlement_weight ?? null); $volume = $this->number($row->volume ?? null); $length = $this->number($row->length ?? null); $width = $this->number($row->width ?? null); $height = $this->number($row->height ?? null); $amount = $quote === null ? null : $this->calculate($quote, $weight, $volume, $row->source_amount ?? null, $length, $width, $height); return [ 'source_id' => (string) ($row->source_id ?? ''), 'created_at' => (string) ($row->created_at ?? ''), 'courier_name' => (string) ($row->courier_name ?? ''), 'waybill' => (string) ($row->waybill ?? ''), 'platform_product_id' => (string) ($row->platform_product_id ?? ''), 'product_code' => (string) ($row->product_code ?? ''), 'shop_name' => (string) ($row->shop_name ?? ''), 'online_order_no' => (string) ($row->online_order_no ?? ''), 'length' => $length, 'width' => $width, 'height' => $height, 'volume' => $volume, 'inspection_type' => (string) ($row->inspection_type ?? ''), 'inspection_reason' => (string) ($row->inspection_reason ?? ''), 'return_waybill_no' => (string) ($row->return_waybill_no ?? ''), 'settlement_weight' => $weight, 'settlement_amount' => $amount, 'surcharge_type' => $type, 'approval_status' => (string) ($row->approval_status ?? ''), 'quote_standard' => is_array($quote) ? ($quote['standard'] ?? null) : $quote?->standard, 'settlement_status' => $quote === null ? '未匹配报价' : ($amount === null ? '待核价' : '已计算'), ]; } private function calculate(array|CourierSurchargeQuote $quote, ?float $weight, ?float $volume, mixed $sourceAmount, ?float $length = null, ?float $width = null, ?float $height = null): ?float { $mode = is_array($quote) ? (string) ($quote['mode'] ?? '') : (string) $quote->mode; $standard = is_array($quote) ? ($quote['standard'] ?? null) : $quote->standard; if ($mode === '固定单价') return $this->firstNumber($standard); if ($mode === '按尺寸/最长边') { // 尺寸报价以最长边和标准中的比较符匹配,例如: // “最长边 > 160 cm:8 元/单”。没有明确阈值时不猜测金额。 $dimensions = array_filter([$length, $width, $height], static fn (?float $dimension): bool => $dimension !== null); return $this->thresholdAmount($standard, $dimensions === [] ? null : max($dimensions)); } if ($mode === '按重量' && $weight !== null && $this->hasComparator($standard)) { return $this->thresholdAmount($standard, $weight); } if ($mode === '按体积' && $volume !== null) { $threshold = $this->thresholdAmount($standard, $volume); if ($threshold !== null || $this->hasComparator($standard)) return $threshold; } $unit = $this->firstNumber($standard); if ($unit !== null && $mode === '按重量' && $weight !== null) return round($unit * $weight, 2); if ($unit !== null && $mode === '按体积' && $volume !== null) return round($unit * $volume, 2); if ($mode === '发出运费倍数' && $sourceAmount !== null) { $multiplier = $this->firstNumber($standard); $amount = $this->number($sourceAmount); return $multiplier === null || $amount === null ? null : round($amount * $multiplier, 2); } if (in_array($mode, ['取发出运费', '按账单'], true)) { return $this->number($sourceAmount); } return null; } private function thresholdAmount(?string $standard, ?float $value): ?float { if ($value === null) return null; $text = trim((string) $standard); if (preg_match('/(<=|>=|≤|≥|<|>|=)\s*(-?\d+(?:\.\d+)?)[^\d]{0,30}[::]\s*(-?\d+(?:\.\d+)?)/u', $text, $match) !== 1) { return null; } $operator = $match[1]; $threshold = (float) $match[2]; $amount = (float) $match[3]; $matched = match ($operator) { '<' => $value < $threshold, '<=', '≤' => $value <= $threshold, '>' => $value > $threshold, '>=', '≥' => $value >= $threshold, '=' => abs($value - $threshold) < 0.000001, default => false, }; return $matched ? round($amount, 2) : null; } private function hasComparator(?string $standard): bool { return preg_match('/(?:<=|>=|≤|≥|<|>|=)/u', (string) $standard) === 1; } private function firstNumber(?string $value): ?float { return preg_match('/-?\d+(?:\.\d+)?/u', (string) $value, $match) === 1 ? (float) $match[0] : null; } private function number(mixed $value): ?float { return is_numeric($value) ? (float) $value : null; } private function table(string $datasetKey): string { return $this->datasets->get($datasetKey)->targetTable(); } private function hasTable(string $datasetKey): bool { return Schema::connection($this->connection->getName())->hasTable($this->table($datasetKey)); } /** @param array $states */ private function datasetKeyForState(array $states, ?array $needle): ?string { foreach ($states as $key => $state) { if ($state === $needle) return $key; } return null; } }