CaiWuXiTong/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php
zhoushengchen d3fee64f80 feat: 接入聚水潭附加费结算镜像
(cherry picked from commit 4b79370faea61aefe05dfe058480600b6fe9981e)
2026-10-12 09:19:55 +08:00

612 lines
31 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Repositories;
use App\Models\Business\CourierSurchargeQuote;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
/**
* 附加费结算的只读业务镜像入口。
*
* 聚水潭原表只在共享镜像刷新任务中读取;页面请求永远不连接上游。
* 类型和审核条件在这里固定,避免用户输入进入 SQL 或绕过业务范围。
*/
class CourierSurchargeSettlementMirrorRepository
{
public const PRIMARY_DATASET = 'jiansun.dbo.ekf_Bookkeeping';
public const EXPRESS_ORIGINAL_DATASET = 'jiansun.dbo.express_Original';
public const BOOKKEEPING_CONFIG_DATASET = 'jiansun.dbo.ekf_BookkeepingConfig';
public const CHECK_DATASET = 'jiansun.dbo.ekf_rCheck_Piece';
public const CHECK_CONFIG_DATASET = 'jiansun.dbo.ekf_rCheckPieceConfig';
public const SHIPPING_DETAILS_DATASET = 'jiansun.dbo.jst_order_express_details';
public const SHIPPING_EXTENSION_DATASET = 'jiansun.dbo.Jst_SendData_View_extension';
public const OUTBOUND_DATASET = 'jiansun.dbo.jst_salesOutboundInfo';
public const RETURN_DATASET = 'jiansun.dbo.ekf_ReturnPickupManagement';
/** @var list<string> */
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<string> */
public const SURCHARGE_TYPES = [
'退回费', '拦截费', '改地址', '上楼费', '送货费', '超区费', '安装费', '保费', '进仓费',
];
/**
* 上游审核字段是自由文本;只有这些明确的已批准值允许进入页面。
* 其它说明、退回原因、空值都不能被当作审核通过。
*
* @var list<string>
*/
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<string,mixed> */
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<string,string> $filters
* @param Collection<int,CourierSurchargeQuote> $quotes
* @return array{data:Collection<int,array<string,mixed>>,has_more:bool,total:int,summary:array<string,mixed>,sync:array<string,mixed>,state:array<string,mixed>}
*/
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<string,string> $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<string> */
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<int,array<string,mixed>|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<string,?array> $states */
private function datasetKeyForState(array $states, ?array $needle): ?string
{
foreach ($states as $key => $state) {
if ($state === $needle) return $key;
}
return null;
}
}