feat: Add IntegrationRepository and ShopProClient for managing integrations and fetching products from shopPRO API
This commit is contained in:
440
src/Modules/Products/ProductService.php
Normal file
440
src/Modules/Products/ProductService.php
Normal file
@@ -0,0 +1,440 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Products;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
final class ProductService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly ProductRepository $products,
|
||||
private readonly ProductValidator $validator
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @param array<string, mixed>|null $actor
|
||||
* @return array{ok:bool, errors:array<int, string>, id?:int}
|
||||
*/
|
||||
public function create(array $input, ?array $actor): array
|
||||
{
|
||||
$errors = $this->validator->validate($input, false);
|
||||
if ($errors !== []) {
|
||||
return ['ok' => false, 'errors' => $errors];
|
||||
}
|
||||
|
||||
$sku = trim((string) ($input['sku'] ?? ''));
|
||||
if ($sku !== '' && $this->products->existsSku($sku)) {
|
||||
return ['ok' => false, 'errors' => ['Podane SKU produktu jest juz zajete.']];
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeForSave($input);
|
||||
$updatePayload = $this->toUpdatePayload($normalized['product']);
|
||||
$actorId = isset($actor['id']) ? (int) $actor['id'] : null;
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
$productId = $this->products->create($normalized['product'], $normalized['translation']);
|
||||
$this->products->logChange($productId, $actorId, 'product_created', null, $normalized['audit']);
|
||||
$this->pdo->commit();
|
||||
|
||||
return ['ok' => true, 'errors' => [], 'id' => $productId];
|
||||
} catch (Throwable $exception) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
|
||||
return ['ok' => false, 'errors' => ['Nie udalo sie zapisac produktu: ' . $exception->getMessage()]];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @param array<string, mixed>|null $actor
|
||||
* @return array{ok:bool, errors:array<int, string>}
|
||||
*/
|
||||
public function update(int $id, array $input, ?array $actor): array
|
||||
{
|
||||
$existing = $this->products->findById($id, 'pl');
|
||||
if ($existing === null) {
|
||||
return ['ok' => false, 'errors' => ['Produkt nie istnieje.']];
|
||||
}
|
||||
|
||||
$errors = $this->validator->validate($input, true);
|
||||
if ($errors !== []) {
|
||||
return ['ok' => false, 'errors' => $errors];
|
||||
}
|
||||
|
||||
$sku = trim((string) ($input['sku'] ?? ''));
|
||||
if ($sku !== '' && $this->products->existsSku($sku, $id)) {
|
||||
return ['ok' => false, 'errors' => ['Podane SKU produktu jest juz zajete.']];
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeForSave($input);
|
||||
$updatePayload = $this->toUpdatePayload($normalized['product']);
|
||||
$actorId = isset($actor['id']) ? (int) $actor['id'] : null;
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
$this->products->update($id, $updatePayload, $normalized['translation']);
|
||||
|
||||
$criticalBefore = $this->extractCriticalFields($existing);
|
||||
$criticalAfter = $this->extractCriticalFields($normalized['audit']);
|
||||
if ($criticalBefore !== $criticalAfter) {
|
||||
$this->products->logChange($id, $actorId, 'product_updated', $criticalBefore, $criticalAfter);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
return ['ok' => true, 'errors' => []];
|
||||
} catch (Throwable $exception) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
|
||||
return ['ok' => false, 'errors' => ['Nie udalo sie zaktualizowac produktu: ' . $exception->getMessage()]];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $actor
|
||||
* @return array{ok:bool, errors:array<int, string>}
|
||||
*/
|
||||
public function delete(int $id, ?array $actor): array
|
||||
{
|
||||
$existing = $this->products->findById($id, 'pl');
|
||||
if ($existing === null) {
|
||||
return ['ok' => false, 'errors' => ['Produkt nie istnieje.']];
|
||||
}
|
||||
|
||||
$imagePaths = $this->findProductImageStoragePaths($id);
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
$deleted = $this->products->deleteById($id);
|
||||
if (!$deleted) {
|
||||
throw new \RuntimeException('Nie udalo sie usunac produktu.');
|
||||
}
|
||||
$this->pdo->commit();
|
||||
$this->deleteProductImageFiles($imagePaths);
|
||||
|
||||
return ['ok' => true, 'errors' => []];
|
||||
} catch (Throwable $exception) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
|
||||
return ['ok' => false, 'errors' => ['Nie udalo sie usunac produktu: ' . $exception->getMessage()]];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{product:array<string,mixed>, translation:array<string,mixed>, audit:array<string,mixed>}
|
||||
*/
|
||||
private function normalizeForSave(array $input): array
|
||||
{
|
||||
$vatRaw = trim((string) ($input['vat'] ?? ''));
|
||||
$vat = $vatRaw === '' ? null : round((float) $vatRaw, 2);
|
||||
|
||||
$pricePair = $this->resolvePricePair(
|
||||
trim((string) ($input['price_brutto'] ?? '')),
|
||||
trim((string) ($input['price_netto'] ?? '')),
|
||||
$vat,
|
||||
(string) ($input['price_input_mode'] ?? 'brutto')
|
||||
);
|
||||
|
||||
$promoPair = $this->resolvePricePair(
|
||||
trim((string) ($input['price_brutto_promo'] ?? '')),
|
||||
trim((string) ($input['price_netto_promo'] ?? '')),
|
||||
$vat,
|
||||
(string) ($input['price_input_mode'] ?? 'brutto'),
|
||||
true
|
||||
);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$product = [
|
||||
'uuid' => $this->uuidV4(),
|
||||
'type' => (string) ($input['type'] ?? 'simple'),
|
||||
'sku' => $this->nullableString($input['sku'] ?? null),
|
||||
'ean' => $this->nullableString($input['ean'] ?? null),
|
||||
'status' => (int) ($input['status'] ?? 1),
|
||||
'promoted' => (int) ($input['promoted'] ?? 0),
|
||||
'vat' => $vat,
|
||||
'weight' => $this->nullableFloat($input['weight'] ?? null, 3),
|
||||
'price_brutto' => $pricePair['brutto'] ?? 0.00,
|
||||
'price_brutto_promo' => $promoPair['brutto'],
|
||||
'price_netto' => $pricePair['netto'],
|
||||
'price_netto_promo' => $promoPair['netto'],
|
||||
'quantity' => round((float) ($input['quantity'] ?? 0), 3),
|
||||
'producer_id' => $this->nullableInt($input['producer_id'] ?? null),
|
||||
'product_unit_id' => $this->nullableInt($input['product_unit_id'] ?? null),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$translation = [
|
||||
'lang' => 'pl',
|
||||
'name' => trim((string) ($input['name'] ?? '')),
|
||||
'short_description' => $this->nullableString($input['short_description'] ?? null),
|
||||
'description' => $this->nullableString($input['description'] ?? null),
|
||||
'meta_title' => $this->nullableString($input['meta_title'] ?? null),
|
||||
'meta_description' => $this->nullableString($input['meta_description'] ?? null),
|
||||
'meta_keywords' => $this->nullableString($input['meta_keywords'] ?? null),
|
||||
'seo_link' => $this->nullableString($input['seo_link'] ?? null),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$audit = [
|
||||
'type' => $product['type'],
|
||||
'sku' => $product['sku'],
|
||||
'ean' => $product['ean'],
|
||||
'status' => $product['status'],
|
||||
'promoted' => $product['promoted'],
|
||||
'vat' => $product['vat'],
|
||||
'price_brutto' => $product['price_brutto'],
|
||||
'price_netto' => $product['price_netto'],
|
||||
'price_brutto_promo' => $product['price_brutto_promo'],
|
||||
'price_netto_promo' => $product['price_netto_promo'],
|
||||
'quantity' => $product['quantity'],
|
||||
'name' => $translation['name'],
|
||||
];
|
||||
|
||||
return [
|
||||
'product' => $product,
|
||||
'translation' => $translation,
|
||||
'audit' => $audit,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{brutto:float|null, netto:float|null}
|
||||
*/
|
||||
private function resolvePricePair(
|
||||
string $bruttoRaw,
|
||||
string $nettoRaw,
|
||||
?float $vat,
|
||||
string $mode,
|
||||
bool $allowEmpty = false
|
||||
): array {
|
||||
if ($allowEmpty && $bruttoRaw === '' && $nettoRaw === '') {
|
||||
return ['brutto' => null, 'netto' => null];
|
||||
}
|
||||
|
||||
$multiplier = 1 + (($vat ?? 0.0) / 100);
|
||||
|
||||
if ($mode === 'netto') {
|
||||
if ($nettoRaw === '' && $bruttoRaw !== '') {
|
||||
$brutto = round((float) $bruttoRaw, 2);
|
||||
$netto = $multiplier > 0 ? round($brutto / $multiplier, 2) : $brutto;
|
||||
return ['brutto' => $brutto, 'netto' => $netto];
|
||||
}
|
||||
|
||||
$netto = $nettoRaw === '' ? 0.0 : round((float) $nettoRaw, 2);
|
||||
$brutto = round($netto * $multiplier, 2);
|
||||
return ['brutto' => $brutto, 'netto' => $netto];
|
||||
}
|
||||
|
||||
if ($bruttoRaw === '' && $nettoRaw !== '') {
|
||||
$netto = round((float) $nettoRaw, 2);
|
||||
$brutto = round($netto * $multiplier, 2);
|
||||
return ['brutto' => $brutto, 'netto' => $netto];
|
||||
}
|
||||
|
||||
$brutto = $bruttoRaw === '' ? 0.0 : round((float) $bruttoRaw, 2);
|
||||
$netto = $multiplier > 0 ? round($brutto / $multiplier, 2) : $brutto;
|
||||
|
||||
return ['brutto' => $brutto, 'netto' => $netto];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function extractCriticalFields(array $row): array
|
||||
{
|
||||
return [
|
||||
'sku' => $row['sku'] ?? null,
|
||||
'ean' => $row['ean'] ?? null,
|
||||
'status' => $row['status'] ?? null,
|
||||
'promoted' => $row['promoted'] ?? null,
|
||||
'price_brutto' => $row['price_brutto'] ?? null,
|
||||
'price_netto' => $row['price_netto'] ?? null,
|
||||
'price_brutto_promo' => $row['price_brutto_promo'] ?? null,
|
||||
'price_netto_promo' => $row['price_netto_promo'] ?? null,
|
||||
'quantity' => $row['quantity'] ?? null,
|
||||
'name' => $row['name'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function nullableString(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function nullableInt(mixed $value): ?int
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
if ($text === '' || !is_numeric($text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $text;
|
||||
}
|
||||
|
||||
private function nullableFloat(mixed $value, int $precision = 2): ?float
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
if ($text === '' || !is_numeric($text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round((float) $text, $precision);
|
||||
}
|
||||
|
||||
private function uuidV4(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
||||
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
||||
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $product
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function toUpdatePayload(array $product): array
|
||||
{
|
||||
return [
|
||||
'type' => $product['type'] ?? 'simple',
|
||||
'sku' => $product['sku'] ?? null,
|
||||
'ean' => $product['ean'] ?? null,
|
||||
'status' => $product['status'] ?? 1,
|
||||
'promoted' => $product['promoted'] ?? 0,
|
||||
'vat' => $product['vat'] ?? null,
|
||||
'weight' => $product['weight'] ?? null,
|
||||
'price_brutto' => $product['price_brutto'] ?? 0,
|
||||
'price_brutto_promo' => $product['price_brutto_promo'] ?? null,
|
||||
'price_netto' => $product['price_netto'] ?? null,
|
||||
'price_netto_promo' => $product['price_netto_promo'] ?? null,
|
||||
'quantity' => $product['quantity'] ?? 0,
|
||||
'producer_id' => $product['producer_id'] ?? null,
|
||||
'product_unit_id' => $product['product_unit_id'] ?? null,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function findProductImageStoragePaths(int $productId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT storage_path FROM product_images WHERE product_id = :product_id');
|
||||
$stmt->execute(['product_id' => $productId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$paths = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = trim((string) ($row['storage_path'] ?? ''));
|
||||
if ($path !== '') {
|
||||
$paths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($paths));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $storagePaths
|
||||
*/
|
||||
private function deleteProductImageFiles(array $storagePaths): void
|
||||
{
|
||||
foreach ($storagePaths as $storagePath) {
|
||||
if ($this->storagePathHasOtherReferences($storagePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolvedFilePath = $this->resolveLocalImageFilePath($storagePath);
|
||||
if ($resolvedFilePath === null || !is_file($resolvedFilePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@unlink($resolvedFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
private function storagePathHasOtherReferences(string $storagePath): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT 1 FROM product_images WHERE storage_path = :storage_path LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['storage_path' => $storagePath]);
|
||||
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
private function resolveLocalImageFilePath(string $storagePath): ?string
|
||||
{
|
||||
$path = trim($storagePath);
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('#^https?://#i', $path) === 1 || str_starts_with($path, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
$projectRootReal = realpath($projectRoot);
|
||||
if ($projectRootReal === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = ltrim(str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path), DIRECTORY_SEPARATOR);
|
||||
$candidates = [];
|
||||
|
||||
if ($this->isAbsolutePath($path)) {
|
||||
$candidates[] = str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path);
|
||||
}
|
||||
|
||||
$candidates[] = $projectRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $trimmed;
|
||||
$candidates[] = $projectRoot . DIRECTORY_SEPARATOR . $trimmed;
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$real = realpath($candidate);
|
||||
if ($real === false || !is_file($real)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($real === $projectRootReal || str_starts_with($real, $projectRootReal . DIRECTORY_SEPARATOR)) {
|
||||
return $real;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isAbsolutePath(string $path): bool
|
||||
{
|
||||
if ($path === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1 || str_starts_with($path, DIRECTORY_SEPARATOR);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user