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 */ 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|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 $filters * @return array{data:list>,total:int,has_more:bool,summary:array,sync:array,state:array} */ 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 $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 $filters * @return \Generator */ 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 $pairs * @return list> */ 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 || app(\Modules\EcommerceFinance\Services\WorkOrderWaybillLookup::class)->ready(); $returnContext = $this->deductOrderContext(array_keys($unmatchedDeductWaybills)); $ruleModes = $this->activeRuleModes(); $quoteRules = $this->importQuoteRules(); // 超长费没有结算行时,按单号回销售出库单查快递/尺寸并按启用的报价核算。 $unmatchedOverlengthWaybills = []; foreach ($normalized as $pair) { if ($pair['waybill'] === '' || $pair['surcharge_type'] !== '超长费') { continue; } if ($this->matchImportRow($rowsByWaybill[$pair['waybill']] ?? [], $pair['surcharge_type'], $pair['courier_name']) === null) { $unmatchedOverlengthWaybills[$pair['waybill']] = true; } } $overlengthContext = $this->importOverlengthContext(array_keys($unmatchedOverlengthWaybills)); $fallbackRows = []; foreach ($rowsByWaybill as $waybill => $rows) { if ($rows !== []) { $fallbackRows[$waybill] = $rows[0]; } } $contextSkuIds = []; foreach ($returnContext as $context) { foreach ($context['items'] ?? [] as $item) { $skuId = trim((string) ($item['sku_id'] ?? '')); if ($skuId !== '') { $contextSkuIds[$skuId] = true; } } } $contextWeights = $this->importWeights(array_keys($contextSkuIds)); $contextReturns = $this->importReturns($returnContext); $results = []; foreach ($normalized as $pair) { $matched = null; $mismatchedCourier = ''; if ($pair['waybill'] !== '') { $candidates = $rowsByWaybill[$pair['waybill']] ?? []; $matched = $this->matchImportRow($candidates, $pair['surcharge_type'], $pair['courier_name']); if ($matched === null && $pair['courier_name'] !== '') { $other = $this->matchImportRow($candidates, $pair['surcharge_type']); if ($other !== null) { $mismatchedCourier = trim((string) $other->courier_name); } } } $results[] = $matched !== null ? $this->importMatchedRow($pair, $matched) : ($this->importQuoteResult( $pair, $fallbackRows[$pair['waybill']] ?? null, $returnContext, $ruleModes, $quoteRules, $overlengthContext, $contextWeights, $contextReturns, ) ?? $this->importMissingRow( $pair, $returnContext, $ruleModes, $contextComplete, $mismatchedCourier, $fallbackRows[$pair['waybill']] ?? null, $contextWeights, $contextReturns, )); } return $results; } private function normalizeImportType(string $type): string { $type = trim($type); return [ '退回件' => '退回费', '退货费' => '退回费', '退货件' => '退回费', '超长' => '超长费', '改地址费' => '改地址', ][$type] ?? $type; } /** * @param iterable $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 */ 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> $returnContext * @param array>> $ruleModes * @param array $contextWeights * @param array>> $contextReturns * @return array */ private function importMissingRow( array $pair, array $returnContext, array $ruleModes, bool $contextComplete, string $mismatchedCourier, ?object $fallbackRow, array $contextWeights, array $contextReturns, ): array { $context = $returnContext[$pair['waybill']] ?? null; $courier = $pair['courier_name']; if ($courier === '') { $courier = trim((string) ($fallbackRow->courier_name ?? ($context['courier'] ?? ''))); } $platformProductId = null; $productCode = null; $shopName = null; $onlineOrderNo = null; $inspectionType = null; $inspectionReason = null; $returnWaybillNo = null; $settlementWeight = null; $shippedAt = null; $evidence = null; if ($fallbackRow !== null) { // 同一运单已有其他类型的结算行时,直接复用其来源字段。 $platformProductId = $fallbackRow->platform_product_id; $productCode = $fallbackRow->product_code; $shopName = $fallbackRow->shop_name; $onlineOrderNo = $fallbackRow->online_order_no; $inspectionType = $fallbackRow->inspection_type; $inspectionReason = $fallbackRow->inspection_reason; $returnWaybillNo = $fallbackRow->return_waybill_no; $settlementWeight = $fallbackRow->settlement_weight; $shippedAt = $fallbackRow->shipped_at; $evidence = '未生成「' . $pair['surcharge_type'] . '」结算行;同运单已有「' . $fallbackRow->surcharge_type . '」结算记录'; } elseif ($context !== null) { // 没有任何结算行时,从查件扣单工单补齐快递、商品、店铺、线上下单等信息。 $primary = $this->primaryImportItem($context['items'] ?? []); $platformProductId = $primary['platform_product_id']; $productCode = $primary['sku_id'] !== '' ? $primary['sku_id'] : $primary['i_id']; $shopName = trim((string) ($context['shop_name'] ?? '')); $onlineOrderNo = trim((string) ($context['online_order_no'] ?? '')); $inspectionType = trim((string) ($context['inspection_type'] ?? '')) ?: null; $inspectionReason = trim((string) ($context['inspection_reason'] ?? '')); $orderReturns = $contextReturns[(int) ($context['o_id'] ?? 0)] ?? []; $returnWaybills = array_values(array_unique(array_filter(array_column($orderReturns, 'l_id')))); $returnWaybillNo = $returnWaybills === [] ? null : implode(',', $returnWaybills); $settlementWeight = $this->importWeightSum($context['items'] ?? [], $contextWeights); $sendTime = trim((string) ($context['send_time'] ?? '')); $shippedAt = $sendTime !== '' ? $sendTime : null; $evidence = ($inspectionType ?? '查件') . ';未生成「' . $pair['surcharge_type'] . '」结算行'; $platformProductId = $platformProductId !== '' ? $platformProductId : null; $productCode = $productCode !== '' ? $productCode : null; $shopName = $shopName !== '' ? $shopName : null; $onlineOrderNo = $onlineOrderNo !== '' ? $onlineOrderNo : null; $inspectionReason = $inspectionReason !== '' ? $inspectionReason : null; } return [ 'row_number' => $pair['row_number'], 'waybill' => $pair['waybill'], 'surcharge_type' => $pair['surcharge_type'], 'decision' => '不结', 'reason' => $this->missingReason($pair, $returnContext, $ruleModes, $contextComplete, $mismatchedCourier), 'courier_name' => $courier !== '' ? $courier : null, 'shipped_at' => $shippedAt, 'platform_product_id' => $platformProductId, 'product_code' => $productCode, 'shop_name' => $shopName, 'online_order_no' => $onlineOrderNo, 'length' => null, 'width' => null, 'height' => null, 'volume' => null, 'inspection_type' => $inspectionType, 'inspection_reason' => $inspectionReason, 'return_waybill_no' => $returnWaybillNo !== null && $returnWaybillNo !== '' ? mb_substr((string) $returnWaybillNo, 0, 191) : null, 'settlement_weight' => $settlementWeight !== null && (float) $settlementWeight > 0 ? round((float) $settlementWeight, 2) : null, 'settlement_amount' => null, 'settlement_status' => null, 'evidence' => $evidence, ]; } /** * 导入核验的「按报价核算」:结算表里没有该运单+类型的结算行时, * 用单号回销售出库单(超长费)或查件扣单(退回费/拦截费)取上下文, * 再按启用的报价规则计算应结金额。返回 null 表示该单号取不到上下文, * 交给 importMissingRow 输出兜底的不结原因。 * * @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair * @param array> $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 * @param array>> $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 $waybills * @return array> */ 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 !== '', ))); $lookup = app(\Modules\EcommerceFinance\Services\WorkOrderWaybillLookup::class); $indexed = $lookup->ready(); if ($waybills === [] || (! $indexed && count($waybills) > self::IMPORT_RETURN_CONTEXT_LIMIT)) { return []; } $context = []; foreach (array_chunk($waybills, $indexed ? 300 : 50) as $chunk) { try { if ($indexed) { $rows = $this->connection->table($lookup::TABLE . ' as lookup') ->join('js_jst_work_order_info as raw', 'raw._mirror_key', '=', 'lookup.mirror_key') ->whereIn('lookup.waybill', $chunk) ->orderByDesc('lookup.source_id')->get(['raw.businessFieldList']); } else { // Retain the existing path only until the resumable initial index is ready. $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> $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> $items * @param array $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 $skuIds * @return array */ 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> $returnContext * @return array> */ private function importReturns(array $returnContext): array { $oIds = []; foreach ($returnContext as $context) { $oId = (int) ($context['o_id'] ?? 0); if ($oId > 0) { $oIds[$oId] = true; } } if ($oIds === []) { return []; } try { $upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('operations_replica'); } catch (Throwable) { return []; } $byOrder = []; foreach (array_chunk(array_keys($oIds), 200) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); try { $rows = $upstream->select( 'SELECT a.o_id, a.l_id, ri.sku_id, ri.r_qty, ' . 'CONVERT(varchar(19), ri.receive_date, 120) AS receive_date ' . 'FROM rpt.jst_returnAndRefundInfo a ' . 'INNER JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = a.as_id ' . 'WHERE a.o_id IN (' . $placeholders . ')', $chunk, ); } catch (Throwable) { continue; } foreach ($rows as $row) { $row = (array) $row; $oId = (int) ($row['o_id'] ?? 0); $waybill = trim((string) ($row['l_id'] ?? '')); if ($oId <= 0 || $waybill === '' || (float) ($row['r_qty'] ?? 0) <= 0 || trim((string) ($row['receive_date'] ?? '')) === '') { continue; } $byOrder[$oId][] = [ 'o_id' => $oId, 'l_id' => $waybill, 'sku_id' => trim((string) ($row['sku_id'] ?? '')), 'r_qty' => (float) ($row['r_qty'] ?? 0), 'receive_date' => trim((string) ($row['receive_date'] ?? '')), ]; } } return $byOrder; } /** @return array>> */ 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 */ 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; } } }