1611 lines
60 KiB
PHP
1611 lines
60 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Repositories;
|
||
|
||
use Carbon\CarbonInterface;
|
||
use Illuminate\Database\ConnectionInterface;
|
||
use Illuminate\Database\DatabaseManager;
|
||
use Illuminate\Database\Query\Builder;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 「快递应付 → 附加费报价维护 → 附加费结算」的只读仓库。
|
||
*
|
||
* 结算行由 `courier-surcharge:pull-overlength` 按启用的附加费报价规则
|
||
* (第一期只处理“超长费 + 按尺寸/最长边”)从销售出库单拉取后写入
|
||
* courier_surcharge_settlement_rows;页面只读该表,不在请求中回源上游。
|
||
*/
|
||
class CourierSurchargeSettlementMirrorRepository
|
||
{
|
||
public const TABLE = 'courier_surcharge_settlement_rows';
|
||
|
||
/** 页面附加费类型下拉使用的固定类型。 */
|
||
public const SURCHARGE_TYPES = [
|
||
'退回费', '拦截费', '改地址', '上楼费', '送货费', '超区费', '安装费', '保费', '进仓费', '超长费',
|
||
];
|
||
|
||
/** 超出多久没有重新拉取就标记为“稍有延迟”。 */
|
||
private const STALE_AFTER_MINUTES = 1440;
|
||
|
||
/** 查件工单里「查件类型/原因」级联字段。 */
|
||
private const CHECK_TYPE_FIELD = 'SELECT_CASCADEField1413856620250813172526';
|
||
|
||
/** 导入核验时最多回查多少张扣单工单,避免大表扫描过重。 */
|
||
private const IMPORT_RETURN_CONTEXT_LIMIT = 100;
|
||
|
||
/** 导入核验时最多按单号回查多少张销售出库单(超长费)。 */
|
||
private const IMPORT_OVERLENGTH_CONTEXT_LIMIT = 200;
|
||
|
||
private readonly ConnectionInterface $connection;
|
||
|
||
public function __construct(DatabaseManager $database)
|
||
{
|
||
$this->connection = $database->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
}
|
||
|
||
public function available(): bool
|
||
{
|
||
try {
|
||
return Schema::connection($this->connection->getName())->hasTable(self::TABLE);
|
||
} catch (Throwable) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public function ready(): bool
|
||
{
|
||
return $this->available() && $this->connection->table(self::TABLE)->exists();
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
public function state(): array
|
||
{
|
||
if (! $this->available()) {
|
||
return [
|
||
'code' => 'pending',
|
||
'status' => 'pending',
|
||
'last_succeeded_at' => null,
|
||
'mirror_row_count' => 0,
|
||
];
|
||
}
|
||
|
||
try {
|
||
$lastPulledAt = $this->connection->table(self::TABLE)->max('pulled_at');
|
||
$rowCount = (int) $this->connection->table(self::TABLE)->count();
|
||
} catch (Throwable) {
|
||
return [
|
||
'code' => 'pending',
|
||
'status' => 'pending',
|
||
'last_succeeded_at' => null,
|
||
'mirror_row_count' => 0,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'code' => $lastPulledAt === null ? 'pending' : 'fresh',
|
||
'status' => $lastPulledAt === null ? 'pending' : 'succeeded',
|
||
'last_succeeded_at' => $lastPulledAt,
|
||
'mirror_row_count' => $rowCount,
|
||
];
|
||
}
|
||
|
||
/** @param array<string,mixed>|null $state @return array{code:string,lag_minutes:?int} */
|
||
public function syncStatus(?array $state = null, ?CarbonInterface $at = null): array
|
||
{
|
||
$state ??= $this->state();
|
||
$lastSucceeded = $this->timestamp($state['last_succeeded_at'] ?? null);
|
||
|
||
if ($lastSucceeded === null) {
|
||
return ['code' => 'pending', 'lag_minutes' => null];
|
||
}
|
||
|
||
$lagMinutes = (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded) / 60);
|
||
|
||
return [
|
||
'code' => $lagMinutes > self::STALE_AFTER_MINUTES ? 'stale' : 'fresh',
|
||
'lag_minutes' => $lagMinutes,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string,string> $filters
|
||
* @return array{data:list<array<string,mixed>>,total:int,has_more:bool,summary:array<string,mixed>,sync:array<string,mixed>,state:array<string,mixed>}
|
||
*/
|
||
public function paginate(array $filters, int $page, int $limit, mixed $quotes = null): array
|
||
{
|
||
if (! $this->ready()) {
|
||
throw new RuntimeException('surcharge_settlement_rows_not_ready');
|
||
}
|
||
|
||
$page = max($page, 1);
|
||
$limit = min(max($limit, 1), 100);
|
||
$query = $this->filteredQuery($filters);
|
||
$total = (int) (clone $query)->count();
|
||
$rows = $query
|
||
->orderByDesc('shipped_at')
|
||
->orderByDesc('id')
|
||
->forPage($page, $limit)
|
||
->get();
|
||
|
||
$data = [];
|
||
$amount = 0.0;
|
||
|
||
foreach ($rows as $row) {
|
||
$mapped = $this->mapRow($row);
|
||
$amount += (float) ($mapped['settlement_amount'] ?? 0);
|
||
$data[] = $mapped;
|
||
}
|
||
|
||
$state = $this->state();
|
||
|
||
return [
|
||
'data' => $data,
|
||
'total' => $total,
|
||
'has_more' => ($page * $limit) < $total,
|
||
'summary' => ['count' => $total, 'settlement_amount' => round($amount, 2)],
|
||
'sync' => ['code' => $state['code'], 'last_succeeded_at' => $state['last_succeeded_at']],
|
||
'state' => $state,
|
||
];
|
||
}
|
||
|
||
/** @param array<string,string> $filters */
|
||
private function filteredQuery(array $filters): Builder
|
||
{
|
||
$query = $this->connection->table(self::TABLE);
|
||
|
||
foreach ([
|
||
'courier_name' => 'courier_name',
|
||
'waybill' => 'waybill',
|
||
'platform_product_id' => 'platform_product_id',
|
||
'product_code' => 'product_code',
|
||
'shop_name' => 'shop_name',
|
||
'online_order_no' => 'online_order_no',
|
||
'inspection_type' => 'inspection_type',
|
||
'inspection_reason' => 'inspection_reason',
|
||
] as $filter => $column) {
|
||
$value = trim((string) ($filters[$filter] ?? ''));
|
||
|
||
if ($value !== '') {
|
||
$query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains($value)]);
|
||
}
|
||
}
|
||
|
||
if (trim((string) ($filters['surcharge_type'] ?? '')) !== '') {
|
||
$query->where('surcharge_type', trim((string) $filters['surcharge_type']));
|
||
}
|
||
|
||
if (trim((string) ($filters['status'] ?? '')) !== '') {
|
||
$query->where('settlement_status', trim((string) $filters['status']));
|
||
}
|
||
|
||
if (trim((string) ($filters['created_from'] ?? '')) !== '') {
|
||
$query->where('shipped_at', '>=', $filters['created_from'] . ' 00:00:00');
|
||
}
|
||
|
||
if (trim((string) ($filters['created_to'] ?? '')) !== '') {
|
||
$query->where('shipped_at', '<', Carbon::parse($filters['created_to'])->addDay()->toDateTimeString());
|
||
}
|
||
|
||
return $query;
|
||
}
|
||
|
||
/**
|
||
* 导出用流式游标:按筛选条件顺序读取结算行,默认最多 5 万行。
|
||
*
|
||
* @param array<string,string> $filters
|
||
* @return \Generator<int,object>
|
||
*/
|
||
public function exportRows(array $filters, int $maxRows = 50000): \Generator
|
||
{
|
||
if (! $this->ready()) {
|
||
throw new RuntimeException('surcharge_settlement_rows_not_ready');
|
||
}
|
||
|
||
$query = $this->filteredQuery($filters)
|
||
->orderByDesc('shipped_at')
|
||
->orderByDesc('id')
|
||
->limit(min(max($maxRows, 1), 50000));
|
||
|
||
foreach ($query->cursor() as $row) {
|
||
yield $row;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 导入核验:按「单号 + 附加费类型」匹配结算行,给出正常结/不结结论与原因。
|
||
*
|
||
* @param list<array{row_number:int,waybill:string,surcharge_type:string}> $pairs
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
public function decideImportRows(array $pairs): array
|
||
{
|
||
if (! $this->ready()) {
|
||
throw new RuntimeException('surcharge_settlement_rows_not_ready');
|
||
}
|
||
|
||
$waybills = [];
|
||
|
||
foreach ($pairs as $pair) {
|
||
$waybill = trim((string) ($pair['waybill'] ?? ''));
|
||
|
||
if ($waybill !== '') {
|
||
$waybills[$waybill] = true;
|
||
}
|
||
}
|
||
|
||
$rowsByWaybill = [];
|
||
|
||
foreach (array_chunk(array_keys($waybills), 300) as $chunk) {
|
||
$rows = $this->connection->table(self::TABLE)
|
||
->whereIn('waybill', $chunk)
|
||
->orderByDesc('settlement_amount')
|
||
->orderByDesc('id')
|
||
->get();
|
||
|
||
foreach ($rows as $row) {
|
||
$waybill = trim((string) $row->waybill);
|
||
|
||
if ($waybill !== '') {
|
||
$rowsByWaybill[$waybill][] = $row;
|
||
}
|
||
}
|
||
}
|
||
|
||
$normalized = [];
|
||
|
||
foreach ($pairs as $pair) {
|
||
$normalized[] = [
|
||
'row_number' => (int) ($pair['row_number'] ?? 0),
|
||
'waybill' => trim((string) ($pair['waybill'] ?? '')),
|
||
'surcharge_type' => $this->normalizeImportType((string) ($pair['surcharge_type'] ?? '')),
|
||
'courier_name' => trim((string) ($pair['courier_name'] ?? '')),
|
||
];
|
||
}
|
||
|
||
$unmatchedDeductWaybills = [];
|
||
|
||
foreach ($normalized as $pair) {
|
||
if ($pair['waybill'] === '' || ! in_array($pair['surcharge_type'], ['退回费', '拦截费'], true)) {
|
||
continue;
|
||
}
|
||
|
||
if ($this->matchImportRow($rowsByWaybill[$pair['waybill']] ?? [], $pair['surcharge_type'], $pair['courier_name']) === null) {
|
||
$unmatchedDeductWaybills[$pair['waybill']] = true;
|
||
}
|
||
}
|
||
|
||
$contextComplete = count($unmatchedDeductWaybills) <= self::IMPORT_RETURN_CONTEXT_LIMIT;
|
||
$returnContext = $this->deductOrderContext(array_keys($unmatchedDeductWaybills));
|
||
$ruleModes = $this->activeRuleModes();
|
||
$quoteRules = $this->importQuoteRules();
|
||
|
||
// 超长费没有结算行时,按单号回销售出库单查快递/尺寸并按启用的报价核算。
|
||
$unmatchedOverlengthWaybills = [];
|
||
|
||
foreach ($normalized as $pair) {
|
||
if ($pair['waybill'] === '' || $pair['surcharge_type'] !== '超长费') {
|
||
continue;
|
||
}
|
||
|
||
if ($this->matchImportRow($rowsByWaybill[$pair['waybill']] ?? [], $pair['surcharge_type'], $pair['courier_name']) === null) {
|
||
$unmatchedOverlengthWaybills[$pair['waybill']] = true;
|
||
}
|
||
}
|
||
|
||
$overlengthContext = $this->importOverlengthContext(array_keys($unmatchedOverlengthWaybills));
|
||
|
||
$fallbackRows = [];
|
||
|
||
foreach ($rowsByWaybill as $waybill => $rows) {
|
||
if ($rows !== []) {
|
||
$fallbackRows[$waybill] = $rows[0];
|
||
}
|
||
}
|
||
|
||
$contextSkuIds = [];
|
||
|
||
foreach ($returnContext as $context) {
|
||
foreach ($context['items'] ?? [] as $item) {
|
||
$skuId = trim((string) ($item['sku_id'] ?? ''));
|
||
|
||
if ($skuId !== '') {
|
||
$contextSkuIds[$skuId] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
$contextWeights = $this->importWeights(array_keys($contextSkuIds));
|
||
$contextReturns = $this->importReturns($returnContext);
|
||
$results = [];
|
||
|
||
foreach ($normalized as $pair) {
|
||
$matched = null;
|
||
$mismatchedCourier = '';
|
||
|
||
if ($pair['waybill'] !== '') {
|
||
$candidates = $rowsByWaybill[$pair['waybill']] ?? [];
|
||
$matched = $this->matchImportRow($candidates, $pair['surcharge_type'], $pair['courier_name']);
|
||
|
||
if ($matched === null && $pair['courier_name'] !== '') {
|
||
$other = $this->matchImportRow($candidates, $pair['surcharge_type']);
|
||
|
||
if ($other !== null) {
|
||
$mismatchedCourier = trim((string) $other->courier_name);
|
||
}
|
||
}
|
||
}
|
||
|
||
$results[] = $matched !== null
|
||
? $this->importMatchedRow($pair, $matched)
|
||
: ($this->importQuoteResult(
|
||
$pair,
|
||
$fallbackRows[$pair['waybill']] ?? null,
|
||
$returnContext,
|
||
$ruleModes,
|
||
$quoteRules,
|
||
$overlengthContext,
|
||
$contextWeights,
|
||
$contextReturns,
|
||
) ?? $this->importMissingRow(
|
||
$pair,
|
||
$returnContext,
|
||
$ruleModes,
|
||
$contextComplete,
|
||
$mismatchedCourier,
|
||
$fallbackRows[$pair['waybill']] ?? null,
|
||
$contextWeights,
|
||
$contextReturns,
|
||
));
|
||
}
|
||
|
||
return $results;
|
||
}
|
||
|
||
private function normalizeImportType(string $type): string
|
||
{
|
||
$type = trim($type);
|
||
|
||
return [
|
||
'退回件' => '退回费',
|
||
'退货费' => '退回费',
|
||
'退货件' => '退回费',
|
||
'超长' => '超长费',
|
||
'改地址费' => '改地址',
|
||
][$type] ?? $type;
|
||
}
|
||
|
||
/**
|
||
* @param iterable<object> $rows
|
||
*/
|
||
private function matchImportRow(iterable $rows, string $type, string $courier = ''): ?object
|
||
{
|
||
$fallback = null;
|
||
|
||
foreach ($rows as $row) {
|
||
if (trim((string) $row->surcharge_type) !== $type) {
|
||
continue;
|
||
}
|
||
|
||
if ($courier !== '' && trim((string) $row->courier_name) !== $courier) {
|
||
continue;
|
||
}
|
||
|
||
$fallback ??= $row;
|
||
|
||
if ((float) ($row->settlement_amount ?? 0) > 0) {
|
||
return $row;
|
||
}
|
||
}
|
||
|
||
return $fallback;
|
||
}
|
||
|
||
/**
|
||
* @param array{row_number:int,waybill:string,surcharge_type:string} $pair
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function importMatchedRow(array $pair, object $row): array
|
||
{
|
||
$amount = (float) ($row->settlement_amount ?? 0);
|
||
$status = trim((string) ($row->settlement_status ?? ''));
|
||
$evidence = trim((string) ($row->evidence ?? ''));
|
||
$reason = '';
|
||
|
||
if ($amount > 0) {
|
||
$decision = '正常结';
|
||
} else {
|
||
$decision = '不结';
|
||
|
||
if ($status === '未匹配报价') {
|
||
$reason = '未匹配到启用的报价规则';
|
||
} elseif ($status === '待核价') {
|
||
$reason = '待核价:商品资料缺少重量或规则金额无法计算';
|
||
} elseif (str_contains($evidence, '无进仓') || str_contains($evidence, '无退货')) {
|
||
$reason = '售后退货退款无进仓时间/实退数量';
|
||
} else {
|
||
$reason = '结算金额为 0';
|
||
}
|
||
}
|
||
|
||
$mapped = $this->mapRow($row);
|
||
$mapped['settlement_status'] = $status;
|
||
$mapped['evidence'] = $evidence;
|
||
|
||
return array_merge([
|
||
'row_number' => $pair['row_number'],
|
||
'waybill' => $pair['waybill'],
|
||
'surcharge_type' => $pair['surcharge_type'],
|
||
'decision' => $decision,
|
||
'reason' => $reason,
|
||
], $mapped);
|
||
}
|
||
|
||
/**
|
||
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
|
||
* @param array<string,array<string,mixed>> $returnContext
|
||
* @param array<string,array<string,list<string>>> $ruleModes
|
||
* @param array<string,float> $contextWeights
|
||
* @param array<int,list<array<string,mixed>>> $contextReturns
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function importMissingRow(
|
||
array $pair,
|
||
array $returnContext,
|
||
array $ruleModes,
|
||
bool $contextComplete,
|
||
string $mismatchedCourier,
|
||
?object $fallbackRow,
|
||
array $contextWeights,
|
||
array $contextReturns,
|
||
): array {
|
||
$context = $returnContext[$pair['waybill']] ?? null;
|
||
$courier = $pair['courier_name'];
|
||
|
||
if ($courier === '') {
|
||
$courier = trim((string) ($fallbackRow->courier_name ?? ($context['courier'] ?? '')));
|
||
}
|
||
|
||
$platformProductId = null;
|
||
$productCode = null;
|
||
$shopName = null;
|
||
$onlineOrderNo = null;
|
||
$inspectionType = null;
|
||
$inspectionReason = null;
|
||
$returnWaybillNo = null;
|
||
$settlementWeight = null;
|
||
$shippedAt = null;
|
||
$evidence = null;
|
||
|
||
if ($fallbackRow !== null) {
|
||
// 同一运单已有其他类型的结算行时,直接复用其来源字段。
|
||
$platformProductId = $fallbackRow->platform_product_id;
|
||
$productCode = $fallbackRow->product_code;
|
||
$shopName = $fallbackRow->shop_name;
|
||
$onlineOrderNo = $fallbackRow->online_order_no;
|
||
$inspectionType = $fallbackRow->inspection_type;
|
||
$inspectionReason = $fallbackRow->inspection_reason;
|
||
$returnWaybillNo = $fallbackRow->return_waybill_no;
|
||
$settlementWeight = $fallbackRow->settlement_weight;
|
||
$shippedAt = $fallbackRow->shipped_at;
|
||
$evidence = '未生成「' . $pair['surcharge_type'] . '」结算行;同运单已有「' . $fallbackRow->surcharge_type . '」结算记录';
|
||
} elseif ($context !== null) {
|
||
// 没有任何结算行时,从查件扣单工单补齐快递、商品、店铺、线上下单等信息。
|
||
$primary = $this->primaryImportItem($context['items'] ?? []);
|
||
$platformProductId = $primary['platform_product_id'];
|
||
$productCode = $primary['sku_id'] !== '' ? $primary['sku_id'] : $primary['i_id'];
|
||
$shopName = trim((string) ($context['shop_name'] ?? ''));
|
||
$onlineOrderNo = trim((string) ($context['online_order_no'] ?? ''));
|
||
$inspectionType = trim((string) ($context['inspection_type'] ?? '')) ?: null;
|
||
$inspectionReason = trim((string) ($context['inspection_reason'] ?? ''));
|
||
$orderReturns = $contextReturns[(int) ($context['o_id'] ?? 0)] ?? [];
|
||
$returnWaybills = array_values(array_unique(array_filter(array_column($orderReturns, 'l_id'))));
|
||
$returnWaybillNo = $returnWaybills === [] ? null : implode(',', $returnWaybills);
|
||
$settlementWeight = $this->importWeightSum($context['items'] ?? [], $contextWeights);
|
||
$sendTime = trim((string) ($context['send_time'] ?? ''));
|
||
$shippedAt = $sendTime !== '' ? $sendTime : null;
|
||
$evidence = ($inspectionType ?? '查件') . ';未生成「' . $pair['surcharge_type'] . '」结算行';
|
||
|
||
$platformProductId = $platformProductId !== '' ? $platformProductId : null;
|
||
$productCode = $productCode !== '' ? $productCode : null;
|
||
$shopName = $shopName !== '' ? $shopName : null;
|
||
$onlineOrderNo = $onlineOrderNo !== '' ? $onlineOrderNo : null;
|
||
$inspectionReason = $inspectionReason !== '' ? $inspectionReason : null;
|
||
}
|
||
|
||
return [
|
||
'row_number' => $pair['row_number'],
|
||
'waybill' => $pair['waybill'],
|
||
'surcharge_type' => $pair['surcharge_type'],
|
||
'decision' => '不结',
|
||
'reason' => $this->missingReason($pair, $returnContext, $ruleModes, $contextComplete, $mismatchedCourier),
|
||
'courier_name' => $courier !== '' ? $courier : null,
|
||
'shipped_at' => $shippedAt,
|
||
'platform_product_id' => $platformProductId,
|
||
'product_code' => $productCode,
|
||
'shop_name' => $shopName,
|
||
'online_order_no' => $onlineOrderNo,
|
||
'length' => null,
|
||
'width' => null,
|
||
'height' => null,
|
||
'volume' => null,
|
||
'inspection_type' => $inspectionType,
|
||
'inspection_reason' => $inspectionReason,
|
||
'return_waybill_no' => $returnWaybillNo !== null && $returnWaybillNo !== ''
|
||
? mb_substr((string) $returnWaybillNo, 0, 191)
|
||
: null,
|
||
'settlement_weight' => $settlementWeight !== null && (float) $settlementWeight > 0
|
||
? round((float) $settlementWeight, 2)
|
||
: null,
|
||
'settlement_amount' => null,
|
||
'settlement_status' => null,
|
||
'evidence' => $evidence,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 导入核验的「按报价核算」:结算表里没有该运单+类型的结算行时,
|
||
* 用单号回销售出库单(超长费)或查件扣单(退回费/拦截费)取上下文,
|
||
* 再按启用的报价规则计算应结金额。返回 null 表示该单号取不到上下文,
|
||
* 交给 importMissingRow 输出兜底的不结原因。
|
||
*
|
||
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
|
||
* @param array<string,array<string,mixed>> $returnContext
|
||
* @param array<string,array<string,list<string>>> $ruleModes
|
||
* @param array{overlength:list<array<string,mixed>>,return:list<array<string,mixed>>,intercept:list<array<string,mixed>>} $quoteRules
|
||
* @param array<string,array<string,mixed>> $overlengthContext
|
||
* @param array<string,float> $contextWeights
|
||
* @param array<int,list<array<string,mixed>>> $contextReturns
|
||
* @return array<string,mixed>|null
|
||
*/
|
||
private function importQuoteResult(
|
||
array $pair,
|
||
?object $fallbackRow,
|
||
array $returnContext,
|
||
array $ruleModes,
|
||
array $quoteRules,
|
||
array $overlengthContext,
|
||
array $contextWeights,
|
||
array $contextReturns,
|
||
): ?array {
|
||
$type = $pair['surcharge_type'];
|
||
|
||
if ($type === '超长费') {
|
||
$context = $overlengthContext[$pair['waybill']] ?? null;
|
||
|
||
return $context === null
|
||
? null
|
||
: $this->importOverlengthQuoteResult($pair, $context, $quoteRules['overlength'] ?? []);
|
||
}
|
||
|
||
if (! in_array($type, ['退回费', '拦截费'], true)) {
|
||
return null;
|
||
}
|
||
|
||
$context = $returnContext[$pair['waybill']] ?? null;
|
||
|
||
return $context === null
|
||
? null
|
||
: $this->importDeductQuoteResult($pair, $context, $fallbackRow, $ruleModes, $quoteRules, $contextWeights, $contextReturns);
|
||
}
|
||
|
||
/**
|
||
* 按单号回销售出库单(JianSun)取快递、明细、尺寸与平台商品ID。
|
||
*
|
||
* @param list<string> $waybills
|
||
* @return array<string,array<string,mixed>>
|
||
*/
|
||
private function importOverlengthContext(array $waybills): array
|
||
{
|
||
if ($waybills === [] || count($waybills) > self::IMPORT_OVERLENGTH_CONTEXT_LIMIT) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
|
||
} catch (Throwable) {
|
||
return [];
|
||
}
|
||
|
||
$orders = [];
|
||
$orderByWaybill = [];
|
||
|
||
foreach (array_chunk($waybills, 100) as $chunk) {
|
||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||
|
||
try {
|
||
$rows = $upstream->select(
|
||
'SELECT Id, io_id, o_id, l_id, so_id, shop_name, '
|
||
. "CONVERT(varchar(19), io_date, 120) AS io_date, logistics_company "
|
||
. 'FROM dbo.jst_salesOutboundInfo WHERE l_id IN (' . $placeholders . ') ORDER BY Id DESC',
|
||
$chunk,
|
||
);
|
||
} catch (Throwable) {
|
||
continue;
|
||
}
|
||
|
||
foreach ($rows as $row) {
|
||
$row = (array) $row;
|
||
$waybill = trim((string) ($row['l_id'] ?? ''));
|
||
|
||
if ($waybill === '' || isset($orderByWaybill[$waybill])) {
|
||
continue;
|
||
}
|
||
|
||
$orderByWaybill[$waybill] = $row;
|
||
$orders[] = $row;
|
||
}
|
||
}
|
||
|
||
if ($orders === []) {
|
||
return [];
|
||
}
|
||
|
||
$ioIds = array_values(array_unique(array_filter(array_map(
|
||
static fn (array $order): int => (int) ($order['io_id'] ?? 0),
|
||
$orders,
|
||
))));
|
||
$items = [];
|
||
$skuIds = [];
|
||
|
||
foreach (array_chunk($ioIds, 200) as $chunk) {
|
||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||
|
||
try {
|
||
$rows = $upstream->select(
|
||
'SELECT io_id, sku_id, i_id, qty FROM dbo.jst_salesOutboundItem WHERE io_id IN (' . $placeholders . ')',
|
||
$chunk,
|
||
);
|
||
} catch (Throwable) {
|
||
continue;
|
||
}
|
||
|
||
foreach ($rows as $row) {
|
||
$row = (array) $row;
|
||
$ioId = (int) ($row['io_id'] ?? 0);
|
||
$skuId = trim((string) ($row['sku_id'] ?? ''));
|
||
|
||
if ($ioId <= 0) {
|
||
continue;
|
||
}
|
||
|
||
$items[$ioId][] = $row;
|
||
|
||
if ($skuId !== '') {
|
||
$skuIds[$skuId] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
$dimensions = $this->importDimensions(array_keys($skuIds));
|
||
$platform = [];
|
||
|
||
foreach (array_chunk($ioIds, 200) as $chunk) {
|
||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||
|
||
try {
|
||
$rows = $upstream->select(
|
||
'SELECT io_id, sku_id, shop_i_id FROM dbo.Jst_SendDataDetail_View WHERE io_id IN (' . $placeholders . ')'
|
||
. ' AND LEN(shop_i_id) > 0',
|
||
$chunk,
|
||
);
|
||
} catch (Throwable) {
|
||
continue;
|
||
}
|
||
|
||
foreach ($rows as $row) {
|
||
$row = (array) $row;
|
||
$ioId = (int) ($row['io_id'] ?? 0);
|
||
$shopIId = trim((string) ($row['shop_i_id'] ?? ''));
|
||
$skuId = trim((string) ($row['sku_id'] ?? ''));
|
||
|
||
if ($ioId <= 0 || $shopIId === '') {
|
||
continue;
|
||
}
|
||
|
||
if ($skuId !== '') {
|
||
$platform[$ioId . '|' . $skuId] = $shopIId;
|
||
}
|
||
|
||
$platform[$ioId . '|'] ??= $shopIId;
|
||
}
|
||
}
|
||
|
||
$contexts = [];
|
||
|
||
foreach ($orderByWaybill as $waybill => $order) {
|
||
$ioId = (int) ($order['io_id'] ?? 0);
|
||
$contexts[$waybill] = [
|
||
'order' => $order,
|
||
'items' => $items[$ioId] ?? [],
|
||
'dimensions' => $dimensions,
|
||
'platform' => $platform,
|
||
];
|
||
}
|
||
|
||
return $contexts;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $context
|
||
* @param list<array{id:int,courier:string,threshold:float,amount:float}> $rules
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function importOverlengthQuoteResult(array $pair, array $context, array $rules): array
|
||
{
|
||
$order = $context['order'];
|
||
$actualCourier = trim((string) ($order['logistics_company'] ?? ''));
|
||
$importedCourier = $pair['courier_name'];
|
||
$courier = $importedCourier !== '' ? $importedCourier : $actualCourier;
|
||
$ioId = (int) ($order['io_id'] ?? 0);
|
||
$best = null;
|
||
|
||
foreach ($context['items'] as $item) {
|
||
$skuId = trim((string) ($item['sku_id'] ?? ''));
|
||
$dimension = $context['dimensions'][$skuId] ?? null;
|
||
|
||
if ($dimension === null) {
|
||
continue;
|
||
}
|
||
|
||
$maxSide = max($dimension['l'], $dimension['w'], $dimension['h']);
|
||
|
||
if ($best === null || $maxSide > $best['max_side']) {
|
||
$best = [
|
||
'max_side' => $maxSide,
|
||
'sku_id' => $skuId,
|
||
'i_id' => trim((string) ($item['i_id'] ?? '')),
|
||
'length' => $dimension['l'],
|
||
'width' => $dimension['w'],
|
||
'height' => $dimension['h'],
|
||
'platform_product_id' => $context['platform'][$ioId . '|' . $skuId]
|
||
?? ($context['platform'][$ioId . '|'] ?? null),
|
||
];
|
||
}
|
||
}
|
||
|
||
$fields = [
|
||
'courier_name' => $courier !== '' ? $courier : null,
|
||
'shipped_at' => trim((string) ($order['io_date'] ?? '')) ?: null,
|
||
'platform_product_id' => $best['platform_product_id'] ?? null,
|
||
'product_code' => $best === null
|
||
? null
|
||
: ($best['sku_id'] !== '' ? $best['sku_id'] : ($best['i_id'] !== '' ? $best['i_id'] : null)),
|
||
'shop_name' => trim((string) ($order['shop_name'] ?? '')) ?: null,
|
||
'online_order_no' => trim((string) ($order['so_id'] ?? '')) ?: null,
|
||
'length' => $best['length'] ?? null,
|
||
'width' => $best['width'] ?? null,
|
||
'height' => $best['height'] ?? null,
|
||
'volume' => $best === null ? null : round($best['length'] * $best['width'] * $best['height'], 2),
|
||
'inspection_type' => null,
|
||
'inspection_reason' => null,
|
||
'return_waybill_no' => null,
|
||
'settlement_weight' => null,
|
||
];
|
||
|
||
if ($importedCourier !== '' && $actualCourier !== '' && $importedCourier !== $actualCourier) {
|
||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||
'不结:该运单实际快递为「' . $actualCourier . '」,与导入的「' . $importedCourier . '」不一致', null);
|
||
}
|
||
|
||
$rule = null;
|
||
|
||
foreach ($rules as $candidate) {
|
||
if ($candidate['courier'] === $actualCourier) {
|
||
$rule = $candidate;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if ($rule === null) {
|
||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||
'不结:快递「' . ($actualCourier !== '' ? $actualCourier : '未知') . '」没有启用的「超长费」报价规则', null);
|
||
}
|
||
|
||
if ($best === null) {
|
||
return $this->importQuoteRow($pair, $fields, null, '待核价',
|
||
'待核价:商品资料缺少长宽高,暂无法核算超长费', '销售出库单已匹配,但条形码在商品资料中没有长宽高');
|
||
}
|
||
|
||
$evidence = mb_substr(sprintf(
|
||
'规则 #%d;条形码 %s 尺寸 %s×%s×%s,最长边 %scm %s %scm',
|
||
$rule['id'],
|
||
$best['sku_id'] !== '' ? $best['sku_id'] : '(无)',
|
||
$this->trimNumber($best['length']),
|
||
$this->trimNumber($best['width']),
|
||
$this->trimNumber($best['height']),
|
||
$this->trimNumber($best['max_side']),
|
||
$best['max_side'] > $rule['threshold'] ? '>' : '≤',
|
||
$this->trimNumber($rule['threshold']),
|
||
), 0, 500);
|
||
|
||
if ($best['max_side'] <= $rule['threshold']) {
|
||
return $this->importQuoteRow($pair, $fields, 0.0, '已计算',
|
||
'不结:最长边 ' . $this->trimNumber($best['max_side']) . 'cm 未超过报价阈值 ' . $this->trimNumber($rule['threshold']) . 'cm',
|
||
$evidence);
|
||
}
|
||
|
||
return $this->importQuoteRow($pair, $fields, (float) $rule['amount'], '已计算', '', $evidence);
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $context
|
||
* @param array<string,array<string,list<string>>> $ruleModes
|
||
* @param array{overlength:list<array<string,mixed>>,return:list<array<string,mixed>>,intercept:list<array<string,mixed>>} $quoteRules
|
||
* @param array<string,float> $contextWeights
|
||
* @param array<int,list<array<string,mixed>>> $contextReturns
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function importDeductQuoteResult(
|
||
array $pair,
|
||
array $context,
|
||
?object $fallbackRow,
|
||
array $ruleModes,
|
||
array $quoteRules,
|
||
array $contextWeights,
|
||
array $contextReturns,
|
||
): array {
|
||
$type = $pair['surcharge_type'];
|
||
$importedCourier = $pair['courier_name'];
|
||
$contextCourier = trim((string) ($context['courier'] ?? ''));
|
||
$fallbackCourier = trim((string) ($fallbackRow->courier_name ?? ''));
|
||
$courier = $importedCourier !== '' ? $importedCourier : ($contextCourier !== '' ? $contextCourier : $fallbackCourier);
|
||
$inspectionType = trim((string) ($context['inspection_type'] ?? ''));
|
||
$items = $context['items'] ?? [];
|
||
$primary = $this->primaryImportItem($items);
|
||
$orderWeight = $this->importWeightSum($items, $contextWeights) ?? 0.0;
|
||
$oId = (int) ($context['o_id'] ?? 0);
|
||
$returnWaybills = [];
|
||
$hasReturn = false;
|
||
$returnedWeight = 0.0;
|
||
$hasReturnWeight = false;
|
||
|
||
foreach ($contextReturns[$oId] ?? [] as $return) {
|
||
$rQty = (float) ($return['r_qty'] ?? 0);
|
||
$receiveDate = trim((string) ($return['receive_date'] ?? ''));
|
||
|
||
if ($rQty <= 0 || $receiveDate === '') {
|
||
continue;
|
||
}
|
||
|
||
$hasReturn = true;
|
||
$returnWaybill = trim((string) ($return['l_id'] ?? ''));
|
||
|
||
if ($returnWaybill !== '') {
|
||
$returnWaybills[$returnWaybill] = true;
|
||
}
|
||
|
||
$skuId = strtoupper(trim((string) ($return['sku_id'] ?? '')));
|
||
$weight = $skuId !== '' ? ($contextWeights[$skuId] ?? null) : null;
|
||
|
||
if ($weight !== null) {
|
||
$returnedWeight += $rQty * (float) $weight;
|
||
$hasReturnWeight = true;
|
||
}
|
||
}
|
||
|
||
$settlementWeight = $hasReturn
|
||
? ($hasReturnWeight && $returnedWeight > 0 ? $returnedWeight : $orderWeight)
|
||
: $orderWeight;
|
||
$fields = [
|
||
'courier_name' => $courier !== '' ? $courier : null,
|
||
'shipped_at' => trim((string) ($context['send_time'] ?? '')) ?: null,
|
||
'platform_product_id' => $primary['platform_product_id'] !== '' ? $primary['platform_product_id'] : null,
|
||
'product_code' => $primary['sku_id'] !== '' ? $primary['sku_id'] : ($primary['i_id'] !== '' ? $primary['i_id'] : null),
|
||
'shop_name' => trim((string) ($context['shop_name'] ?? '')) ?: null,
|
||
'online_order_no' => trim((string) ($context['online_order_no'] ?? '')) ?: null,
|
||
'length' => null,
|
||
'width' => null,
|
||
'height' => null,
|
||
'volume' => null,
|
||
'inspection_type' => $inspectionType !== '' ? $inspectionType : null,
|
||
'inspection_reason' => trim((string) ($context['inspection_reason'] ?? '')) ?: null,
|
||
'return_waybill_no' => $returnWaybills === [] ? null : mb_substr((string) array_key_first($returnWaybills), 0, 191),
|
||
'settlement_weight' => $settlementWeight > 0 ? round($settlementWeight, 2) : null,
|
||
];
|
||
|
||
if ($importedCourier !== '' && $contextCourier !== '' && $importedCourier !== $contextCourier) {
|
||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||
'不结:该运单实际快递为「' . $contextCourier . '」,与导入的「' . $importedCourier . '」不一致', null);
|
||
}
|
||
|
||
if ($inspectionType !== '扣单') {
|
||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||
'不结:该运单在查件工单里不是「扣单」类型', null);
|
||
}
|
||
|
||
$rules = $type === '拦截费' ? ($quoteRules['intercept'] ?? []) : ($quoteRules['return'] ?? []);
|
||
$rule = null;
|
||
|
||
foreach ($rules as $candidate) {
|
||
if ($candidate['courier'] === $courier) {
|
||
$rule = $candidate;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if ($rule === null) {
|
||
$modes = array_values(array_unique($ruleModes[$courier][$type] ?? []));
|
||
|
||
if ($modes !== []) {
|
||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||
'不结:快递「' . $courier . '」的' . $type . '报价规则(' . implode('、', $modes) . ')暂不支持自动核算', null);
|
||
}
|
||
|
||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||
'不结:快递「' . ($courier !== '' ? $courier : '未知') . '」没有启用的「' . $type . '」报价规则', null);
|
||
}
|
||
|
||
if ($type === '拦截费') {
|
||
return $this->importQuoteRow($pair, $fields, (float) $rule['value'], '已计算', '',
|
||
'规则 #' . $rule['id'] . ';查件扣单按固定单价 ' . $this->trimNumber((float) $rule['value']) . ' 元/单');
|
||
}
|
||
|
||
$evidenceParts = ['规则 #' . $rule['id']];
|
||
|
||
if ($hasReturn) {
|
||
$evidenceParts[] = '售后退货退款已进仓,结算重量 ' . $this->trimNumber($settlementWeight) . 'kg';
|
||
} else {
|
||
$evidenceParts[] = '售后退货退款无进仓时间/实退数量';
|
||
}
|
||
|
||
if ($rule['mode'] === '固定单价') {
|
||
return $this->importQuoteRow(
|
||
$pair,
|
||
$fields,
|
||
$hasReturn ? (float) $rule['value'] : 0.0,
|
||
'已计算',
|
||
$hasReturn ? '' : '不结:售后退货退款无进仓时间/实退数量',
|
||
implode(';', $evidenceParts),
|
||
);
|
||
}
|
||
|
||
if (! $hasReturn) {
|
||
return $this->importQuoteRow($pair, $fields, 0.0, '已计算',
|
||
'不结:售后退货退款无进仓时间/实退数量', implode(';', $evidenceParts));
|
||
}
|
||
|
||
if ($settlementWeight <= 0) {
|
||
return $this->importQuoteRow($pair, $fields, null, '待核价',
|
||
'待核价:商品资料缺少重量,暂无法按重量核算退回费', implode(';', $evidenceParts));
|
||
}
|
||
|
||
return $this->importQuoteRow(
|
||
$pair,
|
||
$fields,
|
||
round($settlementWeight * (float) $rule['value'], 2),
|
||
'已计算',
|
||
'',
|
||
implode(';', $evidenceParts) . ';' . $this->trimNumber($settlementWeight) . 'kg × '
|
||
. $this->trimNumber((float) $rule['value']) . ' 元/kg',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
|
||
* @param array<string,mixed> $fields
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function importQuoteRow(
|
||
array $pair,
|
||
array $fields,
|
||
?float $amount,
|
||
string $status,
|
||
string $reason,
|
||
?string $evidence,
|
||
): array {
|
||
$decision = $amount !== null && $amount > 0 ? '正常结' : '不结';
|
||
|
||
return array_merge([
|
||
'row_number' => $pair['row_number'],
|
||
'waybill' => $pair['waybill'],
|
||
'surcharge_type' => $pair['surcharge_type'],
|
||
'decision' => $decision,
|
||
'reason' => $decision === '正常结'
|
||
? ''
|
||
: ($reason !== '' ? $reason : '结算金额为 0'),
|
||
], $fields, [
|
||
'settlement_amount' => $amount,
|
||
'settlement_status' => $status,
|
||
'evidence' => $evidence,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 当前启用的报价规则里,可用于导入核算的部分。
|
||
*
|
||
* @return array{overlength:list<array{id:int,courier:string,threshold:float,amount:float}>,
|
||
* return:list<array{id:int,courier:string,mode:string,value:float}>,
|
||
* intercept:list<array{id:int,courier:string,value:float}>}
|
||
*/
|
||
private function importQuoteRules(): array
|
||
{
|
||
$rules = ['overlength' => [], 'return' => [], 'intercept' => []];
|
||
|
||
try {
|
||
$rows = DB::table('business_courier_surcharge_quotes')
|
||
->where('status', 'active')
|
||
->orderBy('id')
|
||
->get();
|
||
} catch (Throwable) {
|
||
return $rules;
|
||
}
|
||
|
||
foreach ($rows as $rule) {
|
||
$types = json_decode((string) $rule->types, true) ?: [];
|
||
$courier = trim((string) $rule->courier);
|
||
$mode = trim((string) $rule->mode);
|
||
$condition = (string) $rule->condition;
|
||
$standard = (string) $rule->standard;
|
||
|
||
if ($courier === '') {
|
||
continue;
|
||
}
|
||
|
||
if (in_array('超长费', $types, true)
|
||
&& (str_contains($mode, '按尺寸') || str_contains($mode, '最长边'))
|
||
&& preg_match('/单边超\s*(\d+(?:\.\d+)?)\s*(?:cm|厘米|CM|㎝)/u', $condition, $threshold) === 1
|
||
&& preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/?\s*单/u', $standard, $price) === 1) {
|
||
$rules['overlength'][] = [
|
||
'id' => (int) $rule->id,
|
||
'courier' => $courier,
|
||
'threshold' => (float) $threshold[1],
|
||
'amount' => (float) $price[1],
|
||
];
|
||
}
|
||
|
||
if (! str_contains($condition, '扣单')) {
|
||
continue;
|
||
}
|
||
|
||
if (in_array('拦截费', $types, true)
|
||
&& $mode === '固定单价'
|
||
&& preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $price) === 1) {
|
||
$rules['intercept'][] = [
|
||
'id' => (int) $rule->id,
|
||
'courier' => $courier,
|
||
'value' => (float) $price[1],
|
||
];
|
||
}
|
||
|
||
if (in_array('退回费', $types, true)) {
|
||
if ($mode === '固定单价'
|
||
&& preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $price) === 1) {
|
||
$rules['return'][] = [
|
||
'id' => (int) $rule->id,
|
||
'courier' => $courier,
|
||
'mode' => '固定单价',
|
||
'value' => (float) $price[1],
|
||
];
|
||
} elseif ($mode === '按重量'
|
||
&& preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/\s*(?:kg|KG|公斤|千克)/u', $standard, $price) === 1) {
|
||
$rules['return'][] = [
|
||
'id' => (int) $rule->id,
|
||
'courier' => $courier,
|
||
'mode' => '按重量',
|
||
'value' => (float) $price[1],
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
return $rules;
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $skuIds
|
||
* @return array<string,array{l:float,w:float,h:float}>
|
||
*/
|
||
private function importDimensions(array $skuIds): array
|
||
{
|
||
$map = [];
|
||
|
||
foreach (array_chunk(array_values(array_unique(array_filter($skuIds))), 500) as $chunk) {
|
||
try {
|
||
$rows = $this->connection->table('ops_jst_sku')
|
||
->whereIn('sku_id', $chunk)
|
||
->get(['sku_id', 'l', 'w', 'h']);
|
||
} catch (Throwable) {
|
||
return $map;
|
||
}
|
||
|
||
foreach ($rows as $row) {
|
||
$length = (float) $row->l;
|
||
$width = (float) $row->w;
|
||
$height = (float) $row->h;
|
||
|
||
if ($length > 0 && $width > 0 && $height > 0) {
|
||
$map[trim((string) $row->sku_id)] = ['l' => $length, 'w' => $width, 'h' => $height];
|
||
}
|
||
}
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
private function trimNumber(float $value): string
|
||
{
|
||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||
}
|
||
|
||
/**
|
||
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
|
||
* @param array<string,array{courier:string}> $returnContext
|
||
* @param array<string,array<string,list<string>>> $ruleModes
|
||
*/
|
||
private function missingReason(
|
||
array $pair,
|
||
array $returnContext,
|
||
array $ruleModes,
|
||
bool $contextComplete,
|
||
string $mismatchedCourier = '',
|
||
): string {
|
||
$courier = $pair['courier_name'];
|
||
$type = $pair['surcharge_type'];
|
||
|
||
if ($pair['waybill'] === '') {
|
||
return '单号为空';
|
||
}
|
||
|
||
if ($type === '') {
|
||
return '附加费类型为空';
|
||
}
|
||
|
||
if ($mismatchedCourier !== '') {
|
||
return '不结:该运单+类型匹配到快递「' . $mismatchedCourier . '」,与导入的快递「' . $courier . '」不一致';
|
||
}
|
||
|
||
if ($courier !== '') {
|
||
$modes = array_values(array_unique($ruleModes[$courier][$type] ?? []));
|
||
|
||
if ($modes === []) {
|
||
return '不结:快递「' . $courier . '」没有启用的「' . $type . '」报价规则';
|
||
}
|
||
|
||
if ($type === '退回费') {
|
||
if (array_intersect($modes, ['固定单价', '按重量']) === []) {
|
||
return '不结:快递「' . $courier . '」的退回费规则(' . implode('、', $modes) . ')暂不支持自动核算';
|
||
}
|
||
|
||
$context = $returnContext[$pair['waybill']] ?? null;
|
||
$inspectionType = trim((string) ($context['inspection_type'] ?? ''));
|
||
|
||
if ($inspectionType !== '' && $inspectionType !== '扣单') {
|
||
return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($context['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件';
|
||
}
|
||
|
||
if ($context !== null) {
|
||
return '不结:快递「' . $courier . '」有启用的退回费规则,但该运单未生成结算行(不在已拉取范围或结算数据尚未同步)';
|
||
}
|
||
|
||
return '不结:查件工单中没有该运单的扣单记录';
|
||
}
|
||
|
||
if ($type === '拦截费') {
|
||
if (array_intersect($modes, ['固定单价']) === []) {
|
||
return '不结:快递「' . $courier . '」的拦截费规则(' . implode('、', $modes) . ')暂不支持自动核算';
|
||
}
|
||
|
||
$context = $returnContext[$pair['waybill']] ?? null;
|
||
$inspectionType = trim((string) ($context['inspection_type'] ?? ''));
|
||
|
||
if ($inspectionType !== '' && $inspectionType !== '扣单') {
|
||
return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($context['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件';
|
||
}
|
||
|
||
if ($context !== null) {
|
||
return '不结:快递「' . $courier . '」有启用的拦截费规则,但该运单未生成结算行(不在已拉取范围或结算数据尚未同步)';
|
||
}
|
||
|
||
return '不结:查件工单中没有该运单的扣单记录';
|
||
}
|
||
|
||
if ($type === '超长费') {
|
||
return '不结:快递「' . $courier . '」有启用的超长费规则,但该运单未达条件或不在已拉取范围';
|
||
}
|
||
|
||
return '不结:快递「' . $courier . '」有启用规则(' . implode('、', $modes) . '),但「' . $type . '」暂未接入自动核算';
|
||
}
|
||
|
||
// 未提供快递名称时,沿用查件工单兜底原因。
|
||
if ($type === '退回费') {
|
||
if (! $contextComplete) {
|
||
return '不结:该运单没有查件扣单记录,或快递/规则不在已拉取范围';
|
||
}
|
||
|
||
$contextCourier = trim((string) ($returnContext[$pair['waybill']]['courier'] ?? ''));
|
||
|
||
if ($contextCourier === '') {
|
||
return '不结:查件工单中没有该运单的扣单记录';
|
||
}
|
||
|
||
$inspectionType = trim((string) ($returnContext[$pair['waybill']]['inspection_type'] ?? ''));
|
||
|
||
if ($inspectionType !== '' && $inspectionType !== '扣单') {
|
||
return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($returnContext[$pair['waybill']]['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件';
|
||
}
|
||
|
||
$modes = array_values(array_unique($ruleModes[$contextCourier]['退回费'] ?? []));
|
||
|
||
if ($modes === []) {
|
||
return '不结:快递「' . $contextCourier . '」没有启用的退回费报价规则';
|
||
}
|
||
|
||
if (array_intersect($modes, ['固定单价', '按重量']) === []) {
|
||
return '不结:快递「' . $contextCourier . '」的退回费规则(' . implode('、', $modes) . ')暂不支持自动核算';
|
||
}
|
||
|
||
return '不结:该运单未生成退回费结算行(不在已拉取范围或结算数据尚未同步)';
|
||
}
|
||
|
||
if ($type === '拦截费') {
|
||
if (! $contextComplete) {
|
||
return '不结:该运单没有查件扣单记录,或快递/规则不在已拉取范围';
|
||
}
|
||
|
||
$contextCourier = trim((string) ($returnContext[$pair['waybill']]['courier'] ?? ''));
|
||
|
||
if ($contextCourier === '') {
|
||
return '不结:查件工单中没有该运单的扣单记录';
|
||
}
|
||
|
||
$inspectionType = trim((string) ($returnContext[$pair['waybill']]['inspection_type'] ?? ''));
|
||
|
||
if ($inspectionType !== '' && $inspectionType !== '扣单') {
|
||
return '不结:查件工单类型是「' . $inspectionType . '」(' . trim((string) ($returnContext[$pair['waybill']]['inspection_reason'] ?? '')) . '),不是扣单,不满足生效条件';
|
||
}
|
||
|
||
$modes = array_values(array_unique($ruleModes[$contextCourier]['拦截费'] ?? []));
|
||
|
||
if ($modes === []) {
|
||
return '不结:快递「' . $contextCourier . '」没有启用的拦截费报价规则';
|
||
}
|
||
|
||
if (array_intersect($modes, ['固定单价']) === []) {
|
||
return '不结:快递「' . $contextCourier . '」的拦截费规则(' . implode('、', $modes) . ')暂不支持自动核算';
|
||
}
|
||
|
||
return '不结:该运单未生成拦截费结算行(不在已拉取范围或结算数据尚未同步)';
|
||
}
|
||
|
||
if ($type === '超长费') {
|
||
return '不结:未找到超长费结算行,单边未超报价阈值或不在已拉取范围';
|
||
}
|
||
|
||
return '不结:该附加费类型暂未接入自动核算';
|
||
}
|
||
|
||
/**
|
||
* 未匹配到结算行时,回查查件工单补齐快递、商品、店铺、线上下单号等信息。
|
||
*
|
||
* @param list<string> $waybills
|
||
* @return array<string,array<string,mixed>>
|
||
*/
|
||
private function deductOrderContext(array $waybills): array
|
||
{
|
||
$waybills = array_values(array_unique(array_filter(
|
||
array_map(static fn ($waybill): string => trim((string) $waybill), $waybills),
|
||
static fn (string $waybill): bool => $waybill !== '',
|
||
)));
|
||
|
||
if ($waybills === [] || count($waybills) > self::IMPORT_RETURN_CONTEXT_LIMIT) {
|
||
return [];
|
||
}
|
||
|
||
$context = [];
|
||
|
||
foreach (array_chunk($waybills, 50) as $chunk) {
|
||
try {
|
||
$rows = $this->connection->table('js_jst_work_order_info')
|
||
->where('title', '查件')
|
||
->where(function ($query) use ($chunk): void {
|
||
foreach ($chunk as $waybill) {
|
||
$query->orWhere('businessFieldList', 'like', '%' . str_replace(['%', '_'], '', $waybill) . '%');
|
||
}
|
||
})
|
||
->orderByDesc('Id')
|
||
->get(['businessFieldList']);
|
||
} catch (Throwable) {
|
||
continue;
|
||
}
|
||
|
||
foreach ($rows as $row) {
|
||
$fields = json_decode((string) $row->businessFieldList, true);
|
||
|
||
if (! is_array($fields)) {
|
||
continue;
|
||
}
|
||
|
||
$flat = [];
|
||
|
||
foreach ($fields as $field) {
|
||
if (is_array($field) && isset($field['fieldId'])) {
|
||
$flat[(string) $field['fieldId']] = $field;
|
||
}
|
||
}
|
||
|
||
$waybill = trim((string) ($flat['lIdField']['value'] ?? ''));
|
||
$cascade = trim((string) ($flat[self::CHECK_TYPE_FIELD]['value'] ?? ''));
|
||
|
||
if ($waybill === '') {
|
||
continue;
|
||
}
|
||
|
||
if (isset($context[$waybill]) && ($context[$waybill]['inspection_type'] ?? '') === '扣单') {
|
||
continue;
|
||
}
|
||
|
||
[$inspectionType, $inspectionReason] = array_pad(explode('/', $cascade, 2), 2, '');
|
||
$items = [];
|
||
|
||
foreach (($flat['itemInfo']['childFieldList'] ?? []) as $group) {
|
||
if (! is_array($group)) {
|
||
continue;
|
||
}
|
||
|
||
$item = [];
|
||
|
||
foreach ($group as $field) {
|
||
if (is_array($field) && isset($field['fieldId'])) {
|
||
$item[(string) $field['fieldId']] = $field['value'] ?? null;
|
||
}
|
||
}
|
||
|
||
$skuId = strtoupper(trim((string) ($item['skuId'] ?? '')));
|
||
|
||
if ($skuId === '') {
|
||
continue;
|
||
}
|
||
|
||
$items[] = [
|
||
'sku_id' => $skuId,
|
||
'qty' => (float) ($item['qty'] ?? 0),
|
||
'platform_product_id' => trim((string) ($item['platformItemId'] ?? '')),
|
||
'i_id' => trim((string) ($item['iid'] ?? '')),
|
||
];
|
||
}
|
||
|
||
$context[$waybill] = [
|
||
'courier' => trim((string) ($flat['logisticsField']['value'] ?? '')),
|
||
'o_id' => (int) ($flat['o_id']['value'] ?? 0),
|
||
'online_order_no' => trim((string) ($flat['orderField']['value'] ?? '')),
|
||
'shop_name' => trim((string) ($flat['shopField']['valueLabel'] ?? ($flat['shopField']['value'] ?? ''))),
|
||
'inspection_type' => trim((string) $inspectionType),
|
||
'inspection_reason' => trim((string) $inspectionReason),
|
||
'send_time' => trim((string) ($flat['orderSendTime']['value'] ?? '')),
|
||
'items' => $items,
|
||
];
|
||
}
|
||
}
|
||
|
||
return $context;
|
||
}
|
||
|
||
/**
|
||
* @param list<array<string,mixed>> $items
|
||
* @return array{sku_id:string,i_id:string,platform_product_id:string}
|
||
*/
|
||
private function primaryImportItem(array $items): array
|
||
{
|
||
foreach ($items as $item) {
|
||
if (trim((string) ($item['platform_product_id'] ?? '')) !== '') {
|
||
return [
|
||
'sku_id' => strtoupper(trim((string) ($item['sku_id'] ?? ''))),
|
||
'i_id' => trim((string) ($item['i_id'] ?? '')),
|
||
'platform_product_id' => trim((string) $item['platform_product_id']),
|
||
];
|
||
}
|
||
}
|
||
|
||
$first = $items[0] ?? [];
|
||
|
||
return [
|
||
'sku_id' => strtoupper(trim((string) ($first['sku_id'] ?? ''))),
|
||
'i_id' => trim((string) ($first['i_id'] ?? '')),
|
||
'platform_product_id' => trim((string) ($first['platform_product_id'] ?? '')),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param list<array<string,mixed>> $items
|
||
* @param array<string,float> $weights
|
||
*/
|
||
private function importWeightSum(array $items, array $weights): ?float
|
||
{
|
||
$total = 0.0;
|
||
$found = false;
|
||
|
||
foreach ($items as $item) {
|
||
$skuId = strtoupper(trim((string) ($item['sku_id'] ?? '')));
|
||
$weight = $skuId !== '' ? ($weights[$skuId] ?? null) : null;
|
||
|
||
if ($weight !== null) {
|
||
$found = true;
|
||
$total += (float) ($item['qty'] ?? 0) * $weight;
|
||
}
|
||
}
|
||
|
||
return $found ? round($total, 2) : null;
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $skuIds
|
||
* @return array<string,float>
|
||
*/
|
||
private function importWeights(array $skuIds): array
|
||
{
|
||
$map = [];
|
||
|
||
foreach (array_chunk($skuIds, 500) as $chunk) {
|
||
try {
|
||
$rows = $this->connection->table('ops_jst_sku')
|
||
->whereIn('sku_id', $chunk)
|
||
->get(['sku_id', 'weight']);
|
||
} catch (Throwable) {
|
||
return [];
|
||
}
|
||
|
||
foreach ($rows as $row) {
|
||
$skuId = strtoupper(trim((string) $row->sku_id));
|
||
$weight = $row->weight;
|
||
|
||
if ($skuId === '' || $weight === null || $weight === '' || isset($map[$skuId])) {
|
||
continue;
|
||
}
|
||
|
||
$map[$skuId] = (float) $weight;
|
||
}
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,array<string,mixed>> $returnContext
|
||
* @return array<int,list<array{o_id:int,l_id:string,sku_id:string,r_qty:float,receive_date:string}>>
|
||
*/
|
||
private function importReturns(array $returnContext): array
|
||
{
|
||
$oIds = [];
|
||
|
||
foreach ($returnContext as $context) {
|
||
$oId = (int) ($context['o_id'] ?? 0);
|
||
|
||
if ($oId > 0) {
|
||
$oIds[$oId] = true;
|
||
}
|
||
}
|
||
|
||
if ($oIds === []) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('operations_replica');
|
||
} catch (Throwable) {
|
||
return [];
|
||
}
|
||
|
||
$byOrder = [];
|
||
|
||
foreach (array_chunk(array_keys($oIds), 200) as $chunk) {
|
||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||
|
||
try {
|
||
$rows = $upstream->select(
|
||
'SELECT a.o_id, a.l_id, ri.sku_id, ri.r_qty, '
|
||
. 'CONVERT(varchar(19), ri.receive_date, 120) AS receive_date '
|
||
. 'FROM rpt.jst_returnAndRefundInfo a '
|
||
. 'INNER JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = a.as_id '
|
||
. 'WHERE a.o_id IN (' . $placeholders . ')',
|
||
$chunk,
|
||
);
|
||
} catch (Throwable) {
|
||
continue;
|
||
}
|
||
|
||
foreach ($rows as $row) {
|
||
$row = (array) $row;
|
||
$oId = (int) ($row['o_id'] ?? 0);
|
||
$waybill = trim((string) ($row['l_id'] ?? ''));
|
||
|
||
if ($oId <= 0
|
||
|| $waybill === ''
|
||
|| (float) ($row['r_qty'] ?? 0) <= 0
|
||
|| trim((string) ($row['receive_date'] ?? '')) === '') {
|
||
continue;
|
||
}
|
||
|
||
$byOrder[$oId][] = [
|
||
'o_id' => $oId,
|
||
'l_id' => $waybill,
|
||
'sku_id' => trim((string) ($row['sku_id'] ?? '')),
|
||
'r_qty' => (float) ($row['r_qty'] ?? 0),
|
||
'receive_date' => trim((string) ($row['receive_date'] ?? '')),
|
||
];
|
||
}
|
||
}
|
||
|
||
return $byOrder;
|
||
}
|
||
|
||
/** @return array<string,array<string,list<string>>> */
|
||
private function activeRuleModes(): array
|
||
{
|
||
try {
|
||
$rules = DB::table('business_courier_surcharge_quotes')
|
||
->where('status', 'active')
|
||
->get(['courier', 'types', 'mode']);
|
||
} catch (Throwable) {
|
||
return [];
|
||
}
|
||
|
||
$map = [];
|
||
|
||
foreach ($rules as $rule) {
|
||
$types = json_decode((string) $rule->types, true) ?: [];
|
||
$courier = trim((string) $rule->courier);
|
||
|
||
if ($courier === '') {
|
||
continue;
|
||
}
|
||
|
||
foreach ($types as $type) {
|
||
$type = trim((string) $type);
|
||
|
||
if ($type !== '') {
|
||
$map[$courier][$type][] = trim((string) $rule->mode);
|
||
}
|
||
}
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
private function contains(string $value): string
|
||
{
|
||
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
private function mapRow(object $row): array
|
||
{
|
||
return [
|
||
'courier_name' => $row->courier_name,
|
||
'waybill' => $row->waybill,
|
||
'shipped_at' => $row->shipped_at,
|
||
'platform_product_id' => $row->platform_product_id,
|
||
'product_code' => $row->product_code,
|
||
'shop_name' => $row->shop_name,
|
||
'online_order_no' => $row->online_order_no,
|
||
'length' => $row->length,
|
||
'width' => $row->width,
|
||
'height' => $row->height,
|
||
'volume' => $row->volume,
|
||
'inspection_type' => $row->inspection_type,
|
||
'inspection_reason' => $row->inspection_reason,
|
||
'return_waybill_no' => $row->return_waybill_no,
|
||
'settlement_weight' => $row->settlement_weight,
|
||
'settlement_amount' => $row->settlement_amount,
|
||
'surcharge_type' => $row->surcharge_type,
|
||
];
|
||
}
|
||
|
||
private function timestamp(mixed $value): ?int
|
||
{
|
||
if (! is_string($value) || $value === '') {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
return Carbon::parse($value)->getTimestamp();
|
||
} catch (Throwable) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|