441 lines
15 KiB
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);
|
|
}
|
|
}
|