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
296 lines
11 KiB
PHP
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}");
|
|
}
|
|
}
|
|
}
|