create wb-api parser

This commit is contained in:
Fiden committed 2026-07-01 03:28:17 +03:00
commit 7a78dfc7aa
61 files changed
+11245

No files matched your search

@@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use App\Services\Wb\WbSyncService;
use Illuminate\Console\Command;
class SyncWbDataCommand extends Command
{
protected $signature = 'wb:sync
{--endpoint= : Endpoint: sales, orders, incomes, stocks. Empty means all.}
{--from= : Period start date YYYY-MM-DD for sales/orders/incomes.}
{--to= : Period end date YYYY-MM-DD for sales/orders/incomes.}
{--stocks-date= : Date YYYY-MM-DD for stocks.}';
protected $description = 'Load WB API data into MySQL tables';
public function handle(WbSyncService $syncService): int
{
$from = (string) ($this->option('from') ?: '2024-01-01');
$to = (string) ($this->option('to') ?: now()->toDateString());
$stocksDate = (string) ($this->option('stocks-date') ?: now()->toDateString());
$endpoint = $this->option('endpoint') ? (string) $this->option('endpoint') : null;
$this->info('WB API sync started');
$this->line("Period: {$from} - {$to}");
$this->line("Stocks date: {$stocksDate}");
$this->newLine();
$progress = function (string $endpoint, int $page, int $items, int $total, bool $finished): void {
if ($finished) {
$this->line(sprintf('%s page=%d empty, total=%d, finished', $endpoint, $page, $total));
return;
}
$this->line(sprintf('%s page=%d items=%d total=%d', $endpoint, $page, $items, $total));
};
$result = $syncService->syncAll($from, $to, $stocksDate, $endpoint, $progress);
$this->newLine();
$this->info('WB API sync finished');
foreach ($result as $name => $count) {
$this->line("{$name}: {$count} item(s) processed");
}
return self::SUCCESS;
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
+110
View File
@@ -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'));
}
}
}
+362
View File
@@ -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;
}
}
}