$input * @param array|null $actor * @return array{ok:bool, errors:array, 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 $input * @param array|null $actor * @return array{ok:bool, errors:array} */ 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|null $actor * @return array{ok:bool, errors:array} */ 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 $input * @return array{product:array, translation:array, audit:array} */ 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 $row * @return array */ 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 $product * @return array */ 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 */ 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 $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); } }