612 lines
31 KiB
PHP
612 lines
31 KiB
PHP
<?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;
|
||
}
|
||
}
|