create wb-api parser
This commit is contained in:
commit
7a78dfc7aa
61 files changed
+11245
No files matched your search
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wb;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class WbApiClient
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $apiKey;
|
||||
private int $limit;
|
||||
private int $timeout;
|
||||
private int $requestSleepMs;
|
||||
private int $tooManyAttemptsSleepSeconds;
|
||||
private int $max429Retries;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = rtrim((string) config('wb_api.base_url'), '/');
|
||||
$this->apiKey = (string) config('wb_api.key');
|
||||
$this->limit = max(1, min((int) config('wb_api.limit', 100), 500));
|
||||
$this->timeout = (int) config('wb_api.timeout', 30);
|
||||
$this->requestSleepMs = (int) config('wb_api.request_sleep_ms', 1000);
|
||||
$this->tooManyAttemptsSleepSeconds = (int) config('wb_api.too_many_attempts_sleep_seconds', 30);
|
||||
$this->max429Retries = (int) config('wb_api.max_429_retries', 20);
|
||||
|
||||
if ($this->baseUrl === '') {
|
||||
throw new RuntimeException('WB_API_BASE_URL is empty. Check your .env file.');
|
||||
}
|
||||
|
||||
if ($this->apiKey === '') {
|
||||
throw new RuntimeException('WB_API_KEY is empty. Check your .env file.');
|
||||
}
|
||||
}
|
||||
|
||||
public function limit(): int
|
||||
{
|
||||
return $this->limit;
|
||||
}
|
||||
|
||||
public function fetchPage(string $endpoint, array $params, int $page): array
|
||||
{
|
||||
$url = $this->baseUrl . '/api/' . ltrim($endpoint, '/');
|
||||
|
||||
$query = array_filter(array_merge($params, [
|
||||
'key' => $this->apiKey,
|
||||
'limit' => $this->limit,
|
||||
'page' => $page,
|
||||
]), static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$attempt429 = 0;
|
||||
|
||||
while (true) {
|
||||
if ($this->requestSleepMs > 0) {
|
||||
usleep($this->requestSleepMs * 1000);
|
||||
}
|
||||
|
||||
$response = Http::timeout($this->timeout)
|
||||
->acceptJson()
|
||||
->get($url, $query);
|
||||
|
||||
if ($response->status() === 429) {
|
||||
$attempt429++;
|
||||
|
||||
if ($attempt429 > $this->max429Retries) {
|
||||
throw new RuntimeException("Too many 429 responses for {$endpoint} page {$page}");
|
||||
}
|
||||
|
||||
$retryAfter = $response->header('Retry-After');
|
||||
$sleepSeconds = is_numeric($retryAfter)
|
||||
? max(1, (int) $retryAfter)
|
||||
: $this->tooManyAttemptsSleepSeconds;
|
||||
|
||||
echo "{$endpoint} page={$page} got 429, sleeping {$sleepSeconds}s, retry {$attempt429}/{$this->max429Retries}\n";
|
||||
|
||||
sleep($sleepSeconds);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'API request failed: endpoint=%s page=%d http=%d body=%s',
|
||||
$endpoint,
|
||||
$page,
|
||||
$response->status(),
|
||||
mb_substr($response->body(), 0, 1000)
|
||||
));
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
if (!is_array($json)) {
|
||||
throw new RuntimeException("Unexpected non-array API response for {$endpoint}, page {$page}");
|
||||
}
|
||||
|
||||
$items = $json['data'] ?? $json;
|
||||
|
||||
if (!is_array($items)) {
|
||||
throw new RuntimeException("Unexpected API response shape for {$endpoint}, page {$page}");
|
||||
}
|
||||
|
||||
if ($items !== [] && array_keys($items) !== range(0, count($items) - 1)) {
|
||||
$items = [$items];
|
||||
}
|
||||
|
||||
return array_values(array_filter($items, 'is_array'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wb;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class WbSyncService
|
||||
{
|
||||
private const ENDPOINTS = ['sales', 'orders', 'incomes', 'stocks'];
|
||||
|
||||
public function __construct(private readonly WbApiClient $client)
|
||||
{
|
||||
}
|
||||
public function syncAll(string $from, string $to, string $stocksDate, ?string $endpoint = null, ?callable $progress = null): array
|
||||
{
|
||||
$endpoints = $endpoint ? [$endpoint] : self::ENDPOINTS;
|
||||
$result = [
|
||||
'sales' => 0,
|
||||
'orders' => 0,
|
||||
'incomes' => 0,
|
||||
'stocks' => 0,
|
||||
];
|
||||
|
||||
foreach ($endpoints as $currentEndpoint) {
|
||||
if (!in_array($currentEndpoint, self::ENDPOINTS, true)) {
|
||||
throw new \InvalidArgumentException("Unknown endpoint: {$currentEndpoint}");
|
||||
}
|
||||
|
||||
$params = match ($currentEndpoint) {
|
||||
'stocks' => ['dateFrom' => $stocksDate],
|
||||
default => ['dateFrom' => $from, 'dateTo' => $to],
|
||||
};
|
||||
|
||||
$result[$currentEndpoint] += $this->syncEndpoint($currentEndpoint, $params, $progress);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function syncEndpoint(string $endpoint, array $params, ?callable $progress = null): int
|
||||
{
|
||||
$page = 1;
|
||||
$total = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$items = $this->client->fetchPage($endpoint, $params, $page);
|
||||
$count = count($items);
|
||||
|
||||
if ($count === 0) {
|
||||
$this->log($endpoint, $page, 0, 'ok', 'Empty page, sync finished');
|
||||
$progress && $progress($endpoint, $page, 0, $total, true);
|
||||
break;
|
||||
}
|
||||
|
||||
$rows = array_map(fn (array $item) => $this->mapItem($endpoint, $item), $items);
|
||||
$this->upsertRows($this->tableName($endpoint), $rows);
|
||||
|
||||
$total += $count;
|
||||
$this->log($endpoint, $page, $count, 'ok', 'Page synced');
|
||||
$progress && $progress($endpoint, $page, $count, $total, false);
|
||||
|
||||
if ($count < $this->client->limit()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$page++;
|
||||
} catch (Throwable $e) {
|
||||
$this->log($endpoint, $page, 0, 'error', Str::limit($e->getMessage(), 1500));
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function tableName(string $endpoint): string
|
||||
{
|
||||
return match ($endpoint) {
|
||||
'sales' => 'wb_sales',
|
||||
'orders' => 'wb_orders',
|
||||
'incomes' => 'wb_incomes',
|
||||
'stocks' => 'wb_stocks',
|
||||
default => throw new \InvalidArgumentException("Unknown endpoint: {$endpoint}"),
|
||||
};
|
||||
}
|
||||
|
||||
private function mapItem(string $endpoint, array $item): array
|
||||
{
|
||||
return match ($endpoint) {
|
||||
'sales' => $this->mapSale($item),
|
||||
'orders' => $this->mapOrder($item),
|
||||
'incomes' => $this->mapIncome($item),
|
||||
'stocks' => $this->mapStock($item),
|
||||
default => throw new \InvalidArgumentException("Unknown endpoint: {$endpoint}"),
|
||||
};
|
||||
}
|
||||
|
||||
private function mapIncome(array $item): array
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$identity = [
|
||||
$this->intOrNull($item['income_id'] ?? null),
|
||||
$this->dateOrNull($item['date'] ?? null),
|
||||
$this->strOrNull($item['number'] ?? null),
|
||||
$this->intOrNull($item['barcode'] ?? null),
|
||||
$this->intOrNull($item['nm_id'] ?? null),
|
||||
$this->strOrNull($item['warehouse_name'] ?? null),
|
||||
$this->strOrNull($item['supplier_article'] ?? null),
|
||||
];
|
||||
|
||||
return [
|
||||
'source_uid' => $this->uid('income', $identity),
|
||||
'income_id' => $this->intOrNull($item['income_id'] ?? null),
|
||||
'number' => $this->strOrNull($item['number'] ?? null, 128),
|
||||
'income_date' => $this->dateOrNull($item['date'] ?? null),
|
||||
'date_close' => $this->dateOrNull($item['date_close'] ?? null),
|
||||
'last_change_date' => $this->dateTimeOrNull($item['last_change_date'] ?? null),
|
||||
'warehouse_name' => $this->strOrNull($item['warehouse_name'] ?? null),
|
||||
'supplier_article' => $this->strOrNull($item['supplier_article'] ?? null),
|
||||
'tech_size' => $this->strOrNull($item['tech_size'] ?? null, 128),
|
||||
'barcode' => $this->intOrNull($item['barcode'] ?? null),
|
||||
'nm_id' => $this->intOrNull($item['nm_id'] ?? null),
|
||||
'quantity' => $this->intOrNull($item['quantity'] ?? null),
|
||||
'total_price' => $this->decimalOrNull($item['total_price'] ?? null),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
private function mapOrder(array $item): array
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$identity = [
|
||||
$this->strOrNull($item['g_number'] ?? null),
|
||||
$this->strOrNull($item['odid'] ?? null),
|
||||
$this->dateTimeOrNull($item['date'] ?? null),
|
||||
$this->intOrNull($item['barcode'] ?? null),
|
||||
$this->intOrNull($item['nm_id'] ?? null),
|
||||
$this->strOrNull($item['warehouse_name'] ?? null),
|
||||
];
|
||||
|
||||
return [
|
||||
'source_uid' => $this->uid('order', $identity),
|
||||
'order_date' => $this->dateTimeOrNull($item['date'] ?? null),
|
||||
'last_change_date' => $this->dateTimeOrNull($item['last_change_date'] ?? null),
|
||||
'cancel_dt' => $this->dateTimeOrNull($item['cancel_dt'] ?? null),
|
||||
'odid' => $this->strOrNull($item['odid'] ?? null, 128),
|
||||
'g_number' => $this->strOrNull($item['g_number'] ?? null, 128),
|
||||
'income_id' => $this->intOrNull($item['income_id'] ?? null),
|
||||
'warehouse_name' => $this->strOrNull($item['warehouse_name'] ?? null),
|
||||
'supplier_article' => $this->strOrNull($item['supplier_article'] ?? null),
|
||||
'tech_size' => $this->strOrNull($item['tech_size'] ?? null, 128),
|
||||
'barcode' => $this->intOrNull($item['barcode'] ?? null),
|
||||
'nm_id' => $this->intOrNull($item['nm_id'] ?? null),
|
||||
'subject' => $this->strOrNull($item['subject'] ?? null),
|
||||
'category' => $this->strOrNull($item['category'] ?? null),
|
||||
'brand' => $this->strOrNull($item['brand'] ?? null),
|
||||
'oblast' => $this->strOrNull($item['oblast'] ?? null),
|
||||
'total_price' => $this->decimalOrNull($item['total_price'] ?? null),
|
||||
'discount_percent' => $this->intOrNull($item['discount_percent'] ?? null),
|
||||
'is_cancel' => $this->boolOrNull($item['is_cancel'] ?? null),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
private function mapSale(array $item): array
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$identity = $this->strOrNull($item['sale_id'] ?? null) ?: $this->uid('sale-fallback', [
|
||||
$this->strOrNull($item['g_number'] ?? null),
|
||||
$this->strOrNull($item['odid'] ?? null),
|
||||
$this->dateTimeOrNull($item['date'] ?? null),
|
||||
$this->intOrNull($item['barcode'] ?? null),
|
||||
$this->intOrNull($item['nm_id'] ?? null),
|
||||
$this->strOrNull($item['warehouse_name'] ?? null),
|
||||
$this->decimalOrNull($item['for_pay'] ?? null),
|
||||
]);
|
||||
|
||||
return [
|
||||
'source_uid' => $this->uid('sale', [$identity]),
|
||||
'sale_date' => $this->dateTimeOrNull($item['date'] ?? null),
|
||||
'last_change_date' => $this->dateTimeOrNull($item['last_change_date'] ?? null),
|
||||
'sale_id' => $this->strOrNull($item['sale_id'] ?? null, 128),
|
||||
'odid' => $this->strOrNull($item['odid'] ?? null, 128),
|
||||
'g_number' => $this->strOrNull($item['g_number'] ?? null, 128),
|
||||
'income_id' => $this->intOrNull($item['income_id'] ?? null),
|
||||
'warehouse_name' => $this->strOrNull($item['warehouse_name'] ?? null),
|
||||
'country_name' => $this->strOrNull($item['country_name'] ?? null),
|
||||
'oblast_okrug_name' => $this->strOrNull($item['oblast_okrug_name'] ?? null),
|
||||
'region_name' => $this->strOrNull($item['region_name'] ?? null),
|
||||
'supplier_article' => $this->strOrNull($item['supplier_article'] ?? null),
|
||||
'tech_size' => $this->strOrNull($item['tech_size'] ?? null, 128),
|
||||
'barcode' => $this->intOrNull($item['barcode'] ?? null),
|
||||
'nm_id' => $this->intOrNull($item['nm_id'] ?? null),
|
||||
'subject' => $this->strOrNull($item['subject'] ?? null),
|
||||
'category' => $this->strOrNull($item['category'] ?? null),
|
||||
'brand' => $this->strOrNull($item['brand'] ?? null),
|
||||
'total_price' => $this->decimalOrNull($item['total_price'] ?? null),
|
||||
'discount_percent' => $this->intOrNull($item['discount_percent'] ?? null),
|
||||
'spp' => $this->intOrNull($item['spp'] ?? null),
|
||||
'for_pay' => $this->decimalOrNull($item['for_pay'] ?? null),
|
||||
'finished_price' => $this->decimalOrNull($item['finished_price'] ?? null),
|
||||
'price_with_disc' => $this->decimalOrNull($item['price_with_disc'] ?? null),
|
||||
'promo_code_discount' => $this->decimalOrNull($item['promo_code_discount'] ?? null),
|
||||
'is_supply' => $this->boolOrNull($item['is_supply'] ?? null),
|
||||
'is_realization' => $this->boolOrNull($item['is_realization'] ?? null),
|
||||
'is_storno' => $this->boolOrNull($item['is_storno'] ?? null),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
private function mapStock(array $item): array
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$identity = [
|
||||
$this->dateOrNull($item['date'] ?? null),
|
||||
$this->strOrNull($item['warehouse_name'] ?? null),
|
||||
$this->intOrNull($item['barcode'] ?? null),
|
||||
$this->intOrNull($item['nm_id'] ?? null),
|
||||
$this->strOrNull($item['supplier_article'] ?? null),
|
||||
$this->strOrNull($item['tech_size'] ?? null),
|
||||
$this->intOrNull($item['sc_code'] ?? null),
|
||||
$this->boolOrNull($item['is_supply'] ?? null),
|
||||
$this->boolOrNull($item['is_realization'] ?? null),
|
||||
$this->intOrNull($item['in_way_to_client'] ?? null),
|
||||
$this->intOrNull($item['in_way_from_client'] ?? null),
|
||||
];
|
||||
|
||||
return [
|
||||
'source_uid' => $this->uid('stock', $identity),
|
||||
'stock_date' => $this->dateOrNull($item['date'] ?? null),
|
||||
'last_change_date' => $this->dateTimeOrNull($item['last_change_date'] ?? null),
|
||||
'warehouse_name' => $this->strOrNull($item['warehouse_name'] ?? null),
|
||||
'supplier_article' => $this->strOrNull($item['supplier_article'] ?? null),
|
||||
'tech_size' => $this->strOrNull($item['tech_size'] ?? null, 128),
|
||||
'barcode' => $this->intOrNull($item['barcode'] ?? null),
|
||||
'nm_id' => $this->intOrNull($item['nm_id'] ?? null),
|
||||
'subject' => $this->strOrNull($item['subject'] ?? null),
|
||||
'category' => $this->strOrNull($item['category'] ?? null),
|
||||
'brand' => $this->strOrNull($item['brand'] ?? null),
|
||||
'quantity' => $this->intOrNull($item['quantity'] ?? null),
|
||||
'quantity_full' => $this->intOrNull($item['quantity_full'] ?? null),
|
||||
'in_way_to_client' => $this->intOrNull($item['in_way_to_client'] ?? null),
|
||||
'in_way_from_client' => $this->intOrNull($item['in_way_from_client'] ?? null),
|
||||
'price' => $this->decimalOrNull($item['price'] ?? null),
|
||||
'discount' => $this->intOrNull($item['discount'] ?? null),
|
||||
'sc_code' => $this->intOrNull($item['sc_code'] ?? null),
|
||||
'is_supply' => $this->boolOrNull($item['is_supply'] ?? null),
|
||||
'is_realization' => $this->boolOrNull($item['is_realization'] ?? null),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
private function upsertRows(string $table, array $rows): void
|
||||
{
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updateColumns = array_values(array_diff(array_keys($rows[0]), ['source_uid', 'created_at']));
|
||||
|
||||
foreach (array_chunk($rows, 100) as $chunk) {
|
||||
DB::table($table)->upsert($chunk, ['source_uid'], $updateColumns);
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $endpoint, ?int $page, int $itemsCount, string $status, ?string $message = null): void
|
||||
{
|
||||
DB::table('wb_sync_logs')->insert([
|
||||
'endpoint' => $endpoint,
|
||||
'page' => $page,
|
||||
'items_count' => $itemsCount,
|
||||
'status' => $status,
|
||||
'message' => $message ? Str::limit($message, 1500) : null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function uid(string $prefix, array $parts): string
|
||||
{
|
||||
$normalized = array_map(static fn ($value) => $value === null ? '<null>' : (string) $value, $parts);
|
||||
return hash('sha256', $prefix . '|' . implode('|', $normalized));
|
||||
}
|
||||
|
||||
private function strOrNull(mixed $value, int $max = 255): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mb_substr((string) $value, 0, $max);
|
||||
}
|
||||
|
||||
private function intOrNull(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
private function decimalOrNull(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return str_replace(',', '.', (string) $value);
|
||||
}
|
||||
|
||||
private function boolOrNull(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 1 : 0;
|
||||
}
|
||||
|
||||
return ((int) $value) ? 1 : 0;
|
||||
}
|
||||
|
||||
private function dateOrNull(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new DateTimeImmutable((string) $value))->format('Y-m-d');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function dateTimeOrNull(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new DateTimeImmutable((string) $value))->format('Y-m-d H:i:s');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user