附加费结算导入核验:只填单号+类型,自动匹配快递并按报价核算 #391

Merged
zhoushengchen merged 1 commits from feature/zhoushengchen-settlement-import-auto-quote into main 2026-10-19 16:44:51 +08:00
5 changed files with 820 additions and 25 deletions

View File

@ -4858,9 +4858,9 @@ class Pages extends Controller
return response()->streamDownload(function (): void {
$writer = new \App\Support\StreamingXlsxWriter('导入模板');
$writer->appendRow(['单号', '附加费类型', '快递名称', '金额(可选)']);
$writer->appendRow(['DPK317138931411', '退回费', '曹县德邦小件', 20]);
$writer->appendRow(['DPK379220912936', '超长费', '常州德邦', null]);
$writer->appendRow(['单号', '附加费类型', '金额(可选)']);
$writer->appendRow(['DPK317138931411', '退回费', 20]);
$writer->appendRow(['DPK379220912936', '超长费', null]);
$output = fopen('php://output', 'wb');

View File

@ -38,6 +38,9 @@ class CourierSurchargeSettlementMirrorRepository
/** 导入核验时最多回查多少张扣单工单,避免大表扫描过重。 */
private const IMPORT_RETURN_CONTEXT_LIMIT = 100;
/** 导入核验时最多按单号回查多少张销售出库单(超长费)。 */
private const IMPORT_OVERLENGTH_CONTEXT_LIMIT = 200;
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
@ -279,6 +282,22 @@ class CourierSurchargeSettlementMirrorRepository
$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 = [];
@ -323,7 +342,16 @@ class CourierSurchargeSettlementMirrorRepository
$results[] = $matched !== null
? $this->importMatchedRow($pair, $matched)
: $this->importMissingRow(
: ($this->importQuoteResult(
$pair,
$fallbackRows[$pair['waybill']] ?? null,
$returnContext,
$ruleModes,
$quoteRules,
$overlengthContext,
$contextWeights,
$contextReturns,
) ?? $this->importMissingRow(
$pair,
$returnContext,
$ruleModes,
@ -332,7 +360,7 @@ class CourierSurchargeSettlementMirrorRepository
$fallbackRows[$pair['waybill']] ?? null,
$contextWeights,
$contextReturns,
);
));
}
return $results;
@ -422,7 +450,7 @@ class CourierSurchargeSettlementMirrorRepository
* @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
* @param array<int,list<array<string,mixed>>> $contextReturns
* @return array<string,mixed>
*/
private function importMissingRow(
@ -474,7 +502,9 @@ class CourierSurchargeSettlementMirrorRepository
$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;
$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;
@ -517,6 +547,591 @@ class CourierSurchargeSettlementMirrorRepository
];
}
/**
* 导入核验的「按报价核算」:结算表里没有该运单+类型的结算行时,
* 用单号回销售出库单(超长费)或查件扣单(退回费/拦截费)取上下文,
* 再按启用的报价规则计算应结金额。返回 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
@ -851,7 +1466,7 @@ class CourierSurchargeSettlementMirrorRepository
/**
* @param array<string,array<string,mixed>> $returnContext
* @return array<int,string>
* @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
{
@ -882,7 +1497,8 @@ class CourierSurchargeSettlementMirrorRepository
try {
$rows = $upstream->select(
'SELECT a.o_id, a.l_id, ri.r_qty, CONVERT(varchar(19), ri.receive_date, 120) AS receive_date '
'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 . ')',
@ -904,17 +1520,17 @@ class CourierSurchargeSettlementMirrorRepository
continue;
}
$byOrder[$oId][$waybill] = true;
$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'] ?? '')),
];
}
}
$map = [];
foreach ($byOrder as $oId => $waybills) {
$map[(int) $oId] = implode(',', array_keys($waybills));
}
return $map;
return $byOrder;
}
/** @return array<string,array<string,list<string>>> */

View File

@ -249,7 +249,7 @@
<a class="settlement-button" href="{{ route('business.courier-surcharge.settlement.import-template') }}"><span class="material-icons-outlined" aria-hidden="true">download</span>下载导入模板</a>
</div>
<div class="settlement-import-meta" role="status"><span>当前筛选结果:<strong>{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }}</strong> 条</span><span>数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif</span></div>
<span class="settlement-table-hint">首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回与系统的差异金额。</span>
<span class="settlement-table-hint">首行只需「单号」「附加费类型」:系统会按单号自动匹配快递,并按启用的报价核算应结金额;「金额」可选,填了会返回与系统的差异金额。</span>
</form>
<div class="settlement-table-surface" data-finance-table-surface>

View File

@ -254,7 +254,7 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
return count($pairs) === 2
&& $pairs[0]['waybill'] === 'DPK317138931411'
&& $pairs[0]['surcharge_type'] === '退回费'
&& $pairs[0]['courier_name'] === '曹县德邦小件'
&& $pairs[0]['courier_name'] === ''
&& $pairs[0]['amount'] === 10.0
&& $pairs[1]['waybill'] === 'DPK1'
&& $pairs[1]['surcharge_type'] === '超长费'
@ -288,8 +288,8 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
$spreadsheet = new Spreadsheet();
$spreadsheet->getActiveSheet()->fromArray([
['单号', '附加费类型', '快递名称', '金额'],
['DPK317138931411', '退回费', '曹县德邦小件', 10],
['单号', '附加费类型', '金额'],
['DPK317138931411', '退回费', 10],
['DPK1', '超长费'],
]);
$importPath = tempnam(sys_get_temp_dir(), 'surcharge_import_');
@ -356,12 +356,11 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
try {
$this->assertSame('单号', $plain($sheet->getCell('A1')->getValue()));
$this->assertSame('附加费类型', $plain($sheet->getCell('B1')->getValue()));
$this->assertSame('快递名称', $plain($sheet->getCell('C1')->getValue()));
$this->assertSame('金额(可选)', $plain($sheet->getCell('D1')->getValue()));
$this->assertSame('金额(可选)', $plain($sheet->getCell('C1')->getValue()));
$this->assertNull($plain($sheet->getCell('D1')->getValue()));
$this->assertSame('DPK317138931411', $plain($sheet->getCell('A2')->getValue()));
$this->assertSame('退回费', $plain($sheet->getCell('B2')->getValue()));
$this->assertSame('曹县德邦小件', $plain($sheet->getCell('C2')->getValue()));
$this->assertEquals(20, $plain($sheet->getCell('D2')->getValue()));
$this->assertEquals(20, $plain($sheet->getCell('C2')->getValue()));
} finally {
@unlink($path);
}

View File

@ -5,7 +5,10 @@ namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Mockery;
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Tests\TestCase;
class CourierSurchargeSettlementMirrorRepositoryTest extends TestCase
@ -165,6 +168,183 @@ class CourierSurchargeSettlementMirrorRepositoryTest extends TestCase
$this->assertStringContainsString('不一致', $result[4]['reason']);
}
public function test_import_derives_courier_and_amount_from_overlength_quote(): void
{
$this->createTable();
$this->seedSettlementPlaceholder();
$this->createSupportTables();
$this->seedQuotes([
['id' => 6, 'courier' => '曹县德邦', 'types' => ['超长费'], 'mode' => '按尺寸', 'condition' => '单边超160cm', 'standard' => '8元/单'],
]);
DB::connection('business_mirror')->table('ops_jst_sku')->insert([
['sku_id' => 'SKU-1', 'l' => 194, 'w' => 83, 'h' => 12.5, 'weight' => 2],
]);
$this->mockUpstream(static function (string $sql): array {
if (str_contains($sql, 'jst_salesOutboundInfo')) {
return [[
'Id' => 1, 'io_id' => 9001, 'o_id' => 7001, 'l_id' => 'DPK9', 'so_id' => 'SO-9',
'shop_name' => '拼多多-测试店', 'io_date' => '2026-10-10 10:00:00', 'logistics_company' => '曹县德邦',
]];
}
if (str_contains($sql, 'jst_salesOutboundItem')) {
return [['io_id' => 9001, 'sku_id' => 'SKU-1', 'i_id' => 'I-1', 'qty' => 1]];
}
if (str_contains($sql, 'Jst_SendDataDetail_View')) {
return [['io_id' => 9001, 'sku_id' => 'SKU-1', 'shop_i_id' => '993815011271']];
}
return [];
});
$result = $this->repository->decideImportRows([
['row_number' => 2, 'waybill' => 'DPK9', 'surcharge_type' => '超长费', 'courier_name' => ''],
]);
$this->assertSame('正常结', $result[0]['decision']);
$this->assertSame('曹县德邦', $result[0]['courier_name']);
$this->assertSame(8.0, (float) $result[0]['settlement_amount']);
$this->assertSame('已计算', $result[0]['settlement_status']);
$this->assertSame(194.0, (float) $result[0]['length']);
$this->assertSame('993815011271', $result[0]['platform_product_id']);
$this->assertSame('拼多多-测试店', $result[0]['shop_name']);
$this->assertStringContainsString('160', (string) $result[0]['evidence']);
}
public function test_import_derives_courier_and_amount_from_deduct_quotes(): void
{
$this->createTable();
$this->seedSettlementPlaceholder();
$this->createSupportTables();
$this->seedQuotes([
['id' => 20, 'courier' => '曹县德邦小件', 'types' => ['退回费'], 'mode' => '固定单价', 'condition' => '查件扣单并且买家原因已退回', 'standard' => '30元/单'],
['id' => 21, 'courier' => '曹县德邦小件', 'types' => ['拦截费'], 'mode' => '固定单价', 'condition' => '查件扣单', 'standard' => '20元/单'],
]);
DB::connection('business_mirror')->table('ops_jst_sku')->insert([
['sku_id' => 'SKU-2', 'l' => 0, 'w' => 0, 'h' => 0, 'weight' => 2.4],
]);
DB::connection('business_mirror')->table('js_jst_work_order_info')->insert([
'Id' => 1,
'title' => '查件',
'businessFieldList' => json_encode([
['fieldId' => 'lIdField', 'value' => 'DPK10'],
['fieldId' => 'logisticsField', 'value' => '曹县德邦小件'],
['fieldId' => 'o_id', 'value' => 7001],
['fieldId' => 'orderField', 'value' => '260710-1'],
['fieldId' => 'shopField', 'valueLabel' => '拼多多-测试店'],
['fieldId' => 'orderSendTime', 'value' => '2026-10-10 09:00:00'],
['fieldId' => 'SELECT_CASCADEField1413856620250813172526', 'value' => '扣单/买家-不想要'],
['fieldId' => 'itemInfo', 'childFieldList' => [[
['fieldId' => 'skuId', 'value' => 'SKU-2'],
['fieldId' => 'qty', 'value' => 1],
['fieldId' => 'platformItemId', 'value' => 'P-2'],
['fieldId' => 'iid', 'value' => 'I-2'],
]]],
], JSON_UNESCAPED_UNICODE),
]);
$this->mockUpstream(static function (string $sql): array {
if (str_contains($sql, 'jst_returnAndRefundInfo')) {
return [[
'o_id' => 7001, 'l_id' => 'DPK10', 'sku_id' => 'SKU-2', 'qty' => 1, 'r_qty' => 1,
'receive_date' => '2026-10-12 09:00:00',
]];
}
return [];
});
$result = $this->repository->decideImportRows([
['row_number' => 2, 'waybill' => 'DPK10', 'surcharge_type' => '退回费', 'courier_name' => ''],
['row_number' => 3, 'waybill' => 'DPK10', 'surcharge_type' => '拦截费', 'courier_name' => ''],
]);
$this->assertSame('正常结', $result[0]['decision']);
$this->assertSame('曹县德邦小件', $result[0]['courier_name']);
$this->assertSame(30.0, (float) $result[0]['settlement_amount']);
$this->assertSame(2.4, (float) $result[0]['settlement_weight']);
$this->assertSame('扣单', $result[0]['inspection_type']);
$this->assertSame('DPK10', $result[0]['return_waybill_no']);
$this->assertSame('正常结', $result[1]['decision']);
$this->assertSame(20.0, (float) $result[1]['settlement_amount']);
}
private function seedSettlementPlaceholder(): void
{
DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([
'rule_id' => 1, 'surcharge_type' => '超长费', 'courier_name' => '占位快递',
'waybill' => 'PLACEHOLDER', 'settlement_amount' => 0, 'settlement_status' => '已计算',
'shipped_at' => '2026-10-01 00:00:00', 'pulled_at' => '2026-10-01 00:00:00',
'created_at' => '2026-10-01 00:00:00', 'updated_at' => '2026-10-01 00:00:00',
]);
}
private function createSupportTables(): void
{
$defaultSchema = Schema::connection((string) config('database.default'));
if (! $defaultSchema->hasTable('business_courier_surcharge_quotes')) {
$defaultSchema->create('business_courier_surcharge_quotes', function (Blueprint $table): void {
$table->increments('id');
$table->string('courier');
$table->string('types');
$table->string('mode');
$table->string('condition');
$table->string('standard');
$table->string('status');
});
}
$schema = Schema::connection('business_mirror');
$schema->create('ops_jst_sku', function (Blueprint $table): void {
$table->string('sku_id');
$table->decimal('l', 12, 2)->default(0);
$table->decimal('w', 12, 2)->default(0);
$table->decimal('h', 12, 2)->default(0);
$table->decimal('weight', 12, 2)->default(0);
});
$schema->create('js_jst_work_order_info', function (Blueprint $table): void {
$table->bigIncrements('Id');
$table->string('title')->nullable();
$table->text('businessFieldList')->nullable();
});
}
/** @param list<array<string,mixed>> $rules */
private function seedQuotes(array $rules): void
{
$table = DB::connection((string) config('database.default'))->table('business_courier_surcharge_quotes');
$table->delete();
$table->insert(
array_map(static fn (array $rule): array => [
'id' => $rule['id'],
'company_id' => 1,
'courier' => $rule['courier'],
'scope' => '快递应付',
'reconciler' => '测试',
'types' => json_encode($rule['types'], JSON_UNESCAPED_UNICODE),
'mode' => $rule['mode'],
'condition' => $rule['condition'],
'standard' => $rule['standard'],
'effective_at' => '2026-01-01',
'status' => 'active',
], $rules),
);
}
/** @param callable(string,array):list<array<string,mixed>> $handler */
private function mockUpstream(callable $handler): void
{
$query = Mockery::mock(UpstreamReadOnlyQuery::class);
$query->shouldReceive('select')->andReturnUsing(
static fn (string $sql, array $bindings = []): array => $handler($sql, $bindings),
);
$factory = Mockery::mock(UpstreamReadOnlyQueryFactory::class);
$factory->shouldReceive('forSource')->andReturn($query);
$this->app->instance(UpstreamReadOnlyQueryFactory::class, $factory);
}
private function createTable(): void
{
Schema::connection('business_mirror')->create(