findOrFail($this->batchId); if (in_array($batch->status, ['completed', 'completed_with_errors'], true)) { return; } $batch->update([ 'status' => 'processing', 'started_at' => now(), 'error_message' => null, ]); try { $summary = $batch->source_type === 'orders' ? $this->importOrders($batch) : $this->importSettlements($batch); $batch->update([ 'status' => empty($summary['errors']) ? 'completed' : 'completed_with_errors', 'row_count' => $summary['rows'], 'success_count' => $summary['success'], 'error_count' => count($summary['errors']), 'summary' => $summary, 'completed_at' => now(), ]); foreach ($summary['settlement_ids'] ?? [] as $settlementId) { $this->dispatch(new MatchSettlementLines($settlementId)); } } catch (Throwable $exception) { $batch->update([ 'status' => 'failed', 'error_message' => $exception->getMessage(), 'completed_at' => now(), ]); throw $exception; } } private function importOrders(ImportBatch $batch): array { $summary = ['rows' => 0, 'success' => 0, 'errors' => []]; foreach ($this->rows($batch, ['external_order_id', 'external_line_id', 'order_status', 'item_name', 'quantity']) as $line => $row) { ++$summary['rows']; try { DB::transaction(function () use ($batch, $row) { $order = Order::withoutGlobalScopes()->withTrashed()->updateOrCreate( ['store_id' => $batch->store_id, 'external_order_id' => $this->required($row, 'external_order_id')], [ 'company_id' => $batch->company_id, 'order_status' => $this->required($row, 'order_status'), 'ordered_at' => $this->date($row, 'ordered_at'), 'paid_at' => $this->date($row, 'paid_at'), 'shipped_at' => $this->date($row, 'shipped_at'), 'completed_at' => $this->date($row, 'completed_at'), 'currency_code' => strtoupper($this->value($row, 'currency_code') ?: 'CNY'), 'gross_amount' => $this->money($row, 'gross_amount'), 'buyer_paid_amount' => $this->money($row, 'buyer_paid_amount'), 'platform_subsidy_amount' => $this->money($row, 'platform_subsidy_amount'), 'merchant_subsidy_amount' => $this->money($row, 'merchant_subsidy_amount'), 'refunded_amount' => $this->money($row, 'refunded_amount'), 'payload' => $row, 'created_by' => $batch->created_by, 'created_from' => 'ecommerce-finance:import', ] ); if ($order->trashed()) { $order->restore(); } $item = OrderItem::withoutGlobalScopes()->withTrashed()->updateOrCreate( ['order_id' => $order->id, 'external_line_id' => $this->required($row, 'external_line_id')], [ 'company_id' => $batch->company_id, 'sku_code' => $this->value($row, 'sku_code'), 'name' => $this->required($row, 'item_name'), 'quantity' => $this->number($row, 'quantity'), 'sale_amount' => $this->money($row, 'sale_amount'), 'discount_amount' => $this->money($row, 'discount_amount'), 'refund_amount' => $this->money($row, 'refund_amount'), 'created_by' => $batch->created_by, 'created_from' => 'ecommerce-finance:import', ] ); if ($item->trashed()) { $item->restore(); } }); ++$summary['success']; } catch (Throwable $exception) { $summary['errors'][] = ['line' => $line, 'message' => $exception->getMessage()]; } } return $summary; } private function importSettlements(ImportBatch $batch): array { $summary = ['rows' => 0, 'success' => 0, 'errors' => [], 'settlement_ids' => []]; foreach ($this->rows($batch, ['external_settlement_id', 'external_line_id', 'line_type', 'line_amount']) as $line => $row) { ++$summary['rows']; try { $settlementId = null; DB::transaction(function () use ($batch, $row, &$settlementId) { $settlement = Settlement::withoutGlobalScopes()->withTrashed()->updateOrCreate( ['store_id' => $batch->store_id, 'external_settlement_id' => $this->required($row, 'external_settlement_id')], [ 'company_id' => $batch->company_id, 'period_start' => $this->date($row, 'period_start'), 'period_end' => $this->date($row, 'period_end'), 'settled_at' => $this->date($row, 'settled_at'), 'currency_code' => strtoupper($this->value($row, 'currency_code') ?: 'CNY'), 'gross_amount' => $this->money($row, 'gross_amount'), 'fee_amount' => $this->money($row, 'fee_amount'), 'refund_amount' => $this->money($row, 'refund_amount'), 'net_amount' => $this->money($row, 'net_amount'), 'status' => 'imported', 'payload' => $row, 'created_by' => $batch->created_by, 'created_from' => 'ecommerce-finance:import', ] ); if ($settlement->trashed()) { $settlement->restore(); } $item = SettlementLine::withoutGlobalScopes()->withTrashed()->updateOrCreate( ['settlement_id' => $settlement->id, 'external_line_id' => $this->required($row, 'external_line_id')], [ 'company_id' => $batch->company_id, 'line_type' => $this->required($row, 'line_type'), 'external_order_id' => $this->value($row, 'external_order_id'), 'amount' => $this->money($row, 'line_amount'), 'occurred_at' => $this->date($row, 'occurred_at'), 'payload' => $row, 'created_by' => $batch->created_by, 'created_from' => 'ecommerce-finance:import', ] ); if ($item->trashed()) { $item->restore(); } $settlementId = $settlement->id; }); $summary['settlement_ids'][] = $settlementId; ++$summary['success']; } catch (Throwable $exception) { $summary['errors'][] = ['line' => $line, 'message' => $exception->getMessage()]; } } $summary['settlement_ids'] = array_values(array_unique($summary['settlement_ids'])); return $summary; } private function rows(ImportBatch $batch, array $requiredHeaders): iterable { $stream = Storage::disk('uploads')->readStream($batch->source_file); if (! is_resource($stream)) { throw new RuntimeException('Unable to read the import source file.'); } try { $headers = fgetcsv($stream); if ($headers === false) { throw new RuntimeException('The import source file is empty.'); } $headers = array_map(function ($header) { return trim(preg_replace('/^\xEF\xBB\xBF/', '', (string) $header)); }, $headers); $missingHeaders = array_diff($requiredHeaders, $headers); if (! empty($missingHeaders)) { throw new RuntimeException('Missing CSV columns: ' . implode(', ', $missingHeaders)); } $line = 1; while (($values = fgetcsv($stream)) !== false) { ++$line; if ($values === [null] || $values === []) { continue; } $values = array_pad($values, count($headers), null); yield $line => array_combine($headers, array_slice($values, 0, count($headers))); } } finally { fclose($stream); } } private function required(array $row, string $field): string { $value = $this->value($row, $field); if ($value === null || $value === '') { throw new RuntimeException("Missing required value: {$field}"); } return $value; } private function value(array $row, string $field): ?string { $value = $row[$field] ?? null; return $value === null ? null : trim((string) $value); } private function money(array $row, string $field): string { $value = $this->value($row, $field); if ($value === null || $value === '') { return '0'; } if (! is_numeric($value)) { throw new RuntimeException("Invalid monetary value for {$field}"); } return $value; } private function number(array $row, string $field): string { $value = $this->required($row, $field); if (! is_numeric($value)) { throw new RuntimeException("Invalid numeric value for {$field}"); } return $value; } private function date(array $row, string $field): ?string { $value = $this->value($row, $field); if ($value === null || $value === '') { return null; } try { return Carbon::parse($value)->toDateTimeString(); } catch (Throwable) { throw new RuntimeException("Invalid datetime value for {$field}"); } } }