995 lines
36 KiB
PHP
995 lines
36 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 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();
|
||
|
||
$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->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,string> $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'] ?? ''));
|
||
$returnWaybillNo = $contextReturns[(int) ($context['o_id'] ?? 0)] ?? null;
|
||
$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,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @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,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.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][$waybill] = true;
|
||
}
|
||
}
|
||
|
||
$map = [];
|
||
|
||
foreach ($byOrder as $oId => $waybills) {
|
||
$map[(int) $oId] = implode(',', array_keys($waybills));
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
/** @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;
|
||
}
|
||
}
|
||
}
|