CaiWuXiTong/modules/EcommerceFinance/Jobs/ProcessImportBatch.php
WuYongJie 2b6c4030e5
Some checks failed
Tests / PHP 8.1 (push) Has been cancelled
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
chore: 补充财务与支付模块源码
2026-07-20 18:40:05 +08:00

296 lines
11 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Jobs;
use App\Abstracts\JobShouldQueue;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Modules\EcommerceFinance\Models\ImportBatch;
use Modules\EcommerceFinance\Models\Order;
use Modules\EcommerceFinance\Models\OrderItem;
use Modules\EcommerceFinance\Models\Settlement;
use Modules\EcommerceFinance\Models\SettlementLine;
use RuntimeException;
use Throwable;
class ProcessImportBatch extends JobShouldQueue
{
public function __construct(private readonly int $batchId)
{
}
public function handle(): void
{
$batch = ImportBatch::withoutGlobalScopes()->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}");
}
}
}