Files
orderPRO/src/Modules/Products/ProductService.php

441 lines
15 KiB
PHP

<?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);
}
}