|
- <?php
-
- declare(strict_types=1);
-
- namespace App\Controllers;
-
- use App\Models\Customer;
- use App\Repositories\CustomerAuditRepository;
- use App\Repositories\CustomerRepository;
- use App\Repositories\CustomerTypeRepository;
- use App\Services\FileImportService;
- use App\ViewModels\CustomerViewModel;
- use Core\Controller;
- use Core\Request;
- use Core\Response;
-
- class CustomerController extends Controller
- {
- public function index(): Response
- {
- $request = Request::capture();
- $model = new CustomerViewModel();
- $model->saved = $request->input('saved') === '1';
- $model->deleted = $request->input('deleted') === '1';
-
- return $this->view('customers.index', [
- 'model' => $model,
- 'pageTitle' => $model->title,
- ]);
- }
-
- public function data(): Response
- {
- $rows = $this->repo()->allWithType();
-
- $data = array_map(static function (array $row): array {
- $attrValues = !empty($row['attribute_values'])
- ? (json_decode((string) $row['attribute_values'], true) ?? [])
- : [];
- $customerTypeAttributes = !empty($row['customer_type_attributes'])
- ? (json_decode((string) $row['customer_type_attributes'], true) ?? [])
- : [];
-
- $summary = implode(', ', array_map(
- static fn($k, $v) => "{$k}: {$v}",
- array_keys($attrValues),
- array_values($attrValues)
- ));
-
- return [
- 'id' => (int) $row['id'],
- 'customer_type_id' => (int) $row['customer_type_id'],
- 'customer_type_name' => (string) $row['customer_type_name'],
- 'customer_type_attributes' => $customerTypeAttributes,
- 'attribute_values' => $attrValues,
- 'attributes_summary' => $summary,
- 'created_at' => (string) $row['created_at'],
- ];
- }, $rows);
-
- return $this->json($data);
- }
-
- public function create(): Response
- {
- $model = new CustomerViewModel();
- $model->title = 'New Customer';
- $model->customerTypes = $this->loadCustomerTypes();
-
- return $this->view('customers.create', [
- 'model' => $model,
- 'pageTitle' => $model->title,
- ]);
- }
-
- public function store(): Response
- {
- $request = Request::capture();
- $model = new CustomerViewModel();
- $model->title = 'New Customer';
- $model->customerTypes = $this->loadCustomerTypes();
-
- [$form, $errors] = $this->validateForm($request, $model->customerTypes);
-
- if (!empty($errors)) {
- $model->form = $form;
- $model->errors = $errors;
-
- return $this->view('customers.create', [
- 'model' => $model,
- 'pageTitle' => $model->title,
- ]);
- }
-
- $encodedValues = json_encode($form['attribute_values'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
- $duplicate = $this->repo()->findDuplicate((int) $form['customer_type_id'], $encodedValues);
-
- if ($duplicate !== null) {
- $model->form = $form;
- $model->errors['_duplicate'] = [
- 'A customer with these exact values already exists: <a href="/customers/' . (int) $duplicate['id'] . '/edit">Customer #' . (int) $duplicate['id'] . ' (' . htmlspecialchars((string) $duplicate['customer_type_name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ')</a>.',
- ];
-
- return $this->view('customers.create', [
- 'model' => $model,
- 'pageTitle' => $model->title,
- ]);
- }
-
- $customer = new Customer();
- $customer->customerTypeId = (int) $form['customer_type_id'];
- $customer->attributeValues = $form['attribute_values'];
-
- $this->repo()->create($customer);
-
- $inserted = $this->repo()->findLatestByType($customer->customerTypeId);
- if ($inserted !== null) {
- $this->auditRepo()->log((int) $inserted['id'], 'I', $this->toAuditFields($inserted), $this->currentUsername());
- }
-
- return $this->redirect('/customers?saved=1');
- }
-
- public function edit(string $id): Response
- {
- $row = $this->repo()->findWithType((int) $id);
-
- if ($row === null) {
- return $this->redirect('/customers');
- }
-
- $storedValues = !empty($row['attribute_values'])
- ? (json_decode((string) $row['attribute_values'], true) ?? [])
- : [];
-
- $model = new CustomerViewModel();
- $model->title = 'Edit Customer';
- $model->customer = $row;
- $model->saved = Request::capture()->input('saved') === '1';
- $model->customerTypes = $this->loadCustomerTypes();
- $model->form = [
- 'customer_type_id' => (int) $row['customer_type_id'],
- 'attribute_values' => $storedValues,
- ];
-
- return $this->view('customers.edit', [
- 'model' => $model,
- 'pageTitle' => $model->title,
- ]);
- }
-
- public function update(string $id): Response
- {
- $before = $this->repo()->findWithType((int) $id);
-
- if ($before === null) {
- return $this->redirect('/customers');
- }
-
- $request = Request::capture();
- $model = new CustomerViewModel();
- $model->title = 'Edit Customer';
- $model->customer = $before;
- $model->customerTypes = $this->loadCustomerTypes();
-
- [$form, $errors] = $this->validateForm($request, $model->customerTypes);
-
- if (!empty($errors)) {
- $model->form = $form;
- $model->errors = $errors;
-
- return $this->view('customers.edit', [
- 'model' => $model,
- 'pageTitle' => $model->title,
- ]);
- }
-
- $encodedValues = json_encode($form['attribute_values'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
- $duplicate = $this->repo()->findDuplicate((int) $form['customer_type_id'], $encodedValues, (int) $id);
-
- if ($duplicate !== null) {
- $model->form = $form;
- $model->errors['_duplicate'] = [
- 'These values are identical to an existing customer: <a href="/customers/' . (int) $duplicate['id'] . '/edit">Customer #' . (int) $duplicate['id'] . ' (' . htmlspecialchars((string) $duplicate['customer_type_name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ')</a>.',
- ];
-
- return $this->view('customers.edit', [
- 'model' => $model,
- 'pageTitle' => $model->title,
- ]);
- }
-
- $customer = new Customer();
- $customer->id = (int) $id;
- $customer->customerTypeId = (int) $form['customer_type_id'];
- $customer->attributeValues = $form['attribute_values'];
-
- $this->repo()->update($customer);
-
- $after = $this->repo()->findWithType((int) $id);
- $this->auditRepo()->log((int) $id, 'U', [
- 'before' => $this->toAuditFields($before),
- 'after' => $this->toAuditFields($after ?? []),
- ], $this->currentUsername());
-
- return $this->redirect('/customers/' . $id . '/edit?saved=1');
- }
-
- public function destroy(string $id): Response
- {
- $row = $this->repo()->findWithType((int) $id);
-
- if ($row !== null) {
- $this->repo()->delete((int) $id);
- $this->auditRepo()->log((int) $row['id'], 'D', $this->toAuditFields($row), $this->currentUsername());
- }
-
- return $this->redirect('/customers?deleted=1');
- }
-
- // ── CSV Import ────────────────────────────────────────────────────────────
-
- public function importUpload(): Response
- {
- $request = Request::capture();
- if (!verify_csrf_token((string) $request->input('_token', ''))) {
- return Response::json(['error' => 'Session expired. Please refresh.'], 419);
- }
-
- $upload = $_FILES['csv_file'] ?? null;
- if ($upload === null || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
- return Response::json(['error' => 'No file was uploaded.'], 422);
- }
-
- try {
- $service = $this->fileImport();
- $filename = $service->store($upload);
- $data = $service->rows($filename, '0');
-
- return Response::json(['temp_name' => $filename, 'headers' => $data['headers']]);
- } catch (\Throwable $e) {
- return Response::json(['error' => $e->getMessage()], 422);
- }
- }
-
- public function importPreview(): Response
- {
- $request = Request::capture();
- if (!verify_csrf_token((string) $request->input('_token', ''))) {
- return Response::json(['error' => 'Session expired. Please refresh.'], 419);
- }
-
- $customerTypeId = (int) $request->input('customer_type_id', 0);
- if ($customerTypeId === 0) {
- return Response::json(['error' => 'Customer type is required.'], 422);
- }
-
- $tempName = basename((string) $request->input('temp_name', ''));
- if ($tempName === '') {
- return Response::json(['error' => 'No file uploaded.'], 422);
- }
-
- $mapping = (array) ($request->input('mapping') ?? []);
-
- try {
- $data = $this->fileImport()->rows($tempName, '0');
- $rows = [];
- $stats = ['total' => 0, 'ok' => 0, 'duplicate' => 0, 'empty' => 0];
-
- foreach ($data['rows'] as $i => $csvRow) {
- $stats['total']++;
- $attributeValues = $this->applyMapping($mapping, $csvRow);
-
- $hasValue = false;
- foreach ($attributeValues as $v) {
- if ($v !== '') { $hasValue = true; break; }
- }
-
- if (!$hasValue) {
- $stats['empty']++;
- $rows[] = ['index' => $i + 1, 'status' => 'empty', 'values' => $attributeValues, 'message' => 'Row is empty'];
- continue;
- }
-
- $encodedValues = json_encode($attributeValues, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
- $duplicate = $this->repo()->findDuplicate($customerTypeId, $encodedValues);
-
- if ($duplicate !== null) {
- $stats['duplicate']++;
- $rows[] = [
- 'index' => $i + 1,
- 'status' => 'duplicate',
- 'values' => $attributeValues,
- 'message' => 'Duplicate of Customer #' . (int) $duplicate['id'],
- ];
- } else {
- $stats['ok']++;
- $rows[] = ['index' => $i + 1, 'status' => 'ok', 'values' => $attributeValues, 'message' => null];
- }
- }
-
- return Response::json(['rows' => $rows, 'stats' => $stats]);
- } catch (\Throwable $e) {
- return Response::json(['error' => $e->getMessage()], 422);
- }
- }
-
- public function importApprove(): Response
- {
- $request = Request::capture();
- if (!verify_csrf_token((string) $request->input('_token', ''))) {
- return Response::json(['error' => 'Session expired. Please refresh.'], 419);
- }
-
- $customerTypeId = (int) $request->input('customer_type_id', 0);
- if ($customerTypeId === 0) {
- return Response::json(['error' => 'Customer type is required.'], 422);
- }
-
- $tempName = basename((string) $request->input('temp_name', ''));
- if ($tempName === '') {
- return Response::json(['error' => 'No file uploaded.'], 422);
- }
-
- $mapping = (array) ($request->input('mapping') ?? []);
-
- try {
- $service = $this->fileImport();
- $data = $service->rows($tempName, '0');
- $inserted = 0;
- $skipped = 0;
- $errors = [];
-
- foreach ($data['rows'] as $i => $csvRow) {
- $attributeValues = $this->applyMapping($mapping, $csvRow);
-
- $hasValue = false;
- foreach ($attributeValues as $v) {
- if ($v !== '') { $hasValue = true; break; }
- }
-
- if (!$hasValue) {
- $skipped++;
- continue;
- }
-
- try {
- $customer = new Customer();
- $customer->customerTypeId = $customerTypeId;
- $customer->attributeValues = $attributeValues;
- $this->repo()->create($customer);
-
- $insertedRow = $this->repo()->findLatestByType($customerTypeId);
- if ($insertedRow !== null) {
- $this->auditRepo()->log(
- (int) $insertedRow['id'],
- 'I',
- $this->toAuditFields($insertedRow),
- $this->currentUsername()
- );
- }
-
- $inserted++;
- } catch (\Throwable $e) {
- $errors[] = 'Row ' . ($i + 1) . ': ' . $e->getMessage();
- }
- }
-
- $service->delete($tempName);
-
- return Response::json(['inserted' => $inserted, 'skipped' => $skipped, 'errors' => $errors]);
- } catch (\Throwable $e) {
- return Response::json(['error' => $e->getMessage()], 422);
- }
- }
-
- // ── Helpers ───────────────────────────────────────────────────────────────
-
- private function loadCustomerTypes(): array
- {
- return array_map(static function (array $type): array {
- return [
- 'id' => (int) $type['id'],
- 'name' => (string) $type['name'],
- 'attributes' => json_decode((string) ($type['attributes'] ?? '[]'), true) ?? [],
- ];
- }, $this->ctRepo()->allOrderedByName());
- }
-
- private function attributesForType(int $typeId, array $types): array
- {
- foreach ($types as $type) {
- if ($type['id'] === $typeId) return $type['attributes'];
- }
- return [];
- }
-
- private function validateForm(Request $request, array $customerTypes): array
- {
- $customerTypeId = (int) $request->input('customer_type_id', 0);
- $submittedValues = (array) ($request->input('attribute_values') ?? []);
- $errors = [];
-
- if (!verify_csrf_token((string) $request->input('_token', ''))) {
- $errors['_token'][] = 'Your form session expired. Please refresh and try again.';
- }
-
- if ($customerTypeId === 0) {
- $errors['customer_type_id'][] = 'Please select a customer type.';
- }
-
- $typeAttributes = $this->attributesForType($customerTypeId, $customerTypes);
- $attributeValues = [];
- foreach ($typeAttributes as $attr) {
- $attributeValues[$attr['name']] = trim((string) ($submittedValues[$attr['name']] ?? ''));
- }
-
- return [
- ['customer_type_id' => $customerTypeId, 'attribute_values' => $attributeValues],
- $errors,
- ];
- }
-
- private function toAuditFields(array $row): array
- {
- $attrValues = [];
- if (!empty($row['attribute_values'])) {
- $raw = $row['attribute_values'];
- $attrValues = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw;
- }
-
- return [
- 'customer_type_id' => (int) ($row['customer_type_id'] ?? 0),
- 'customer_type_name' => (string) ($row['customer_type_name'] ?? ''),
- 'attribute_values' => $attrValues,
- 'created_at' => (string) ($row['created_at'] ?? ''),
- 'updated_at' => (string) ($row['updated_at'] ?? ''),
- ];
- }
-
- private function currentUsername(): string
- {
- return auth()->user()?->username ?? 'system';
- }
-
- private function applyMapping(array $mapping, array $csvRow): array
- {
- $attributeValues = [];
- foreach ($mapping as $attrName => $csvColumn) {
- $csvColumn = trim((string) $csvColumn);
- if ($csvColumn === '') continue;
- $attributeValues[trim((string) $attrName)] = trim((string) ($csvRow[$csvColumn] ?? ''));
- }
- return $attributeValues;
- }
-
- private function fileImport(): FileImportService
- {
- return new FileImportService();
- }
-
- private function repo(): CustomerRepository
- {
- return new CustomerRepository(database());
- }
-
- private function auditRepo(): CustomerAuditRepository
- {
- return new CustomerAuditRepository(database());
- }
-
- private function ctRepo(): CustomerTypeRepository
- {
- return new CustomerTypeRepository(database());
- }
- }
|