diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index af661547..f4379575 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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'); diff --git a/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php b/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php index 97c49952..679bc0c7 100644 --- a/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/CourierSurchargeSettlementMirrorRepository.php @@ -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> $returnContext * @param array>> $ruleModes * @param array $contextWeights - * @param array $contextReturns + * @param array>> $contextReturns * @return array */ 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> $returnContext + * @param array>> $ruleModes + * @param array{overlength:list>,return:list>,intercept:list>} $quoteRules + * @param array> $overlengthContext + * @param array $contextWeights + * @param array>> $contextReturns + * @return array|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 $waybills + * @return array> + */ + 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 $context + * @param list $rules + * @return array + */ + 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 $context + * @param array>> $ruleModes + * @param array{overlength:list>,return:list>,intercept:list>} $quoteRules + * @param array $contextWeights + * @param array>> $contextReturns + * @return array + */ + 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 $fields + * @return array + */ + 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, + * return:list, + * intercept:list} + */ + 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 $skuIds + * @return array + */ + 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 $returnContext @@ -851,7 +1466,7 @@ class CourierSurchargeSettlementMirrorRepository /** * @param array> $returnContext - * @return array + * @return array> */ 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>> */ diff --git a/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php index 1a55c0f9..ec40bbde 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge-settlement.blade.php @@ -249,7 +249,7 @@ 下载导入模板
当前筛选结果:{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }} 条数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif
- 首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回与系统的差异金额。 + 首行只需「单号」「附加费类型」:系统会按单号自动匹配快递,并按启用的报价核算应结金额;「金额」可选,填了会返回与系统的差异金额。
diff --git a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php index dff9bbc6..65b2fc8b 100644 --- a/tests/Feature/Business/CourierSurchargeSettlementPageTest.php +++ b/tests/Feature/Business/CourierSurchargeSettlementPageTest.php @@ -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); } diff --git a/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php index 2ed8a50c..5cade37e 100644 --- a/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/CourierSurchargeSettlementMirrorRepositoryTest.php @@ -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> $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> $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(