Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

477 рядки
17KB

  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Controllers;
  4. use App\Models\Customer;
  5. use App\Repositories\CustomerAuditRepository;
  6. use App\Repositories\CustomerRepository;
  7. use App\Repositories\CustomerTypeRepository;
  8. use App\Services\FileImportService;
  9. use App\ViewModels\CustomerViewModel;
  10. use Core\Controller;
  11. use Core\Request;
  12. use Core\Response;
  13. class CustomerController extends Controller
  14. {
  15. public function index(): Response
  16. {
  17. $request = Request::capture();
  18. $model = new CustomerViewModel();
  19. $model->saved = $request->input('saved') === '1';
  20. $model->deleted = $request->input('deleted') === '1';
  21. return $this->view('customers.index', [
  22. 'model' => $model,
  23. 'pageTitle' => $model->title,
  24. ]);
  25. }
  26. public function data(): Response
  27. {
  28. $rows = $this->repo()->allWithType();
  29. $data = array_map(static function (array $row): array {
  30. $attrValues = !empty($row['attribute_values'])
  31. ? (json_decode((string) $row['attribute_values'], true) ?? [])
  32. : [];
  33. $customerTypeAttributes = !empty($row['customer_type_attributes'])
  34. ? (json_decode((string) $row['customer_type_attributes'], true) ?? [])
  35. : [];
  36. $summary = implode(', ', array_map(
  37. static fn($k, $v) => "{$k}: {$v}",
  38. array_keys($attrValues),
  39. array_values($attrValues)
  40. ));
  41. return [
  42. 'id' => (int) $row['id'],
  43. 'customer_type_id' => (int) $row['customer_type_id'],
  44. 'customer_type_name' => (string) $row['customer_type_name'],
  45. 'customer_type_attributes' => $customerTypeAttributes,
  46. 'attribute_values' => $attrValues,
  47. 'attributes_summary' => $summary,
  48. 'created_at' => (string) $row['created_at'],
  49. ];
  50. }, $rows);
  51. return $this->json($data);
  52. }
  53. public function create(): Response
  54. {
  55. $model = new CustomerViewModel();
  56. $model->title = 'New Customer';
  57. $model->customerTypes = $this->loadCustomerTypes();
  58. return $this->view('customers.create', [
  59. 'model' => $model,
  60. 'pageTitle' => $model->title,
  61. ]);
  62. }
  63. public function store(): Response
  64. {
  65. $request = Request::capture();
  66. $model = new CustomerViewModel();
  67. $model->title = 'New Customer';
  68. $model->customerTypes = $this->loadCustomerTypes();
  69. [$form, $errors] = $this->validateForm($request, $model->customerTypes);
  70. if (!empty($errors)) {
  71. $model->form = $form;
  72. $model->errors = $errors;
  73. return $this->view('customers.create', [
  74. 'model' => $model,
  75. 'pageTitle' => $model->title,
  76. ]);
  77. }
  78. $encodedValues = json_encode($form['attribute_values'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
  79. $duplicate = $this->repo()->findDuplicate((int) $form['customer_type_id'], $encodedValues);
  80. if ($duplicate !== null) {
  81. $model->form = $form;
  82. $model->errors['_duplicate'] = [
  83. '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>.',
  84. ];
  85. return $this->view('customers.create', [
  86. 'model' => $model,
  87. 'pageTitle' => $model->title,
  88. ]);
  89. }
  90. $customer = new Customer();
  91. $customer->customerTypeId = (int) $form['customer_type_id'];
  92. $customer->attributeValues = $form['attribute_values'];
  93. $this->repo()->create($customer);
  94. $inserted = $this->repo()->findLatestByType($customer->customerTypeId);
  95. if ($inserted !== null) {
  96. $this->auditRepo()->log((int) $inserted['id'], 'I', $this->toAuditFields($inserted), $this->currentUsername());
  97. }
  98. return $this->redirect('/customers?saved=1');
  99. }
  100. public function edit(string $id): Response
  101. {
  102. $row = $this->repo()->findWithType((int) $id);
  103. if ($row === null) {
  104. return $this->redirect('/customers');
  105. }
  106. $storedValues = !empty($row['attribute_values'])
  107. ? (json_decode((string) $row['attribute_values'], true) ?? [])
  108. : [];
  109. $model = new CustomerViewModel();
  110. $model->title = 'Edit Customer';
  111. $model->customer = $row;
  112. $model->saved = Request::capture()->input('saved') === '1';
  113. $model->customerTypes = $this->loadCustomerTypes();
  114. $model->form = [
  115. 'customer_type_id' => (int) $row['customer_type_id'],
  116. 'attribute_values' => $storedValues,
  117. ];
  118. return $this->view('customers.edit', [
  119. 'model' => $model,
  120. 'pageTitle' => $model->title,
  121. ]);
  122. }
  123. public function update(string $id): Response
  124. {
  125. $before = $this->repo()->findWithType((int) $id);
  126. if ($before === null) {
  127. return $this->redirect('/customers');
  128. }
  129. $request = Request::capture();
  130. $model = new CustomerViewModel();
  131. $model->title = 'Edit Customer';
  132. $model->customer = $before;
  133. $model->customerTypes = $this->loadCustomerTypes();
  134. [$form, $errors] = $this->validateForm($request, $model->customerTypes);
  135. if (!empty($errors)) {
  136. $model->form = $form;
  137. $model->errors = $errors;
  138. return $this->view('customers.edit', [
  139. 'model' => $model,
  140. 'pageTitle' => $model->title,
  141. ]);
  142. }
  143. $encodedValues = json_encode($form['attribute_values'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
  144. $duplicate = $this->repo()->findDuplicate((int) $form['customer_type_id'], $encodedValues, (int) $id);
  145. if ($duplicate !== null) {
  146. $model->form = $form;
  147. $model->errors['_duplicate'] = [
  148. '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>.',
  149. ];
  150. return $this->view('customers.edit', [
  151. 'model' => $model,
  152. 'pageTitle' => $model->title,
  153. ]);
  154. }
  155. $customer = new Customer();
  156. $customer->id = (int) $id;
  157. $customer->customerTypeId = (int) $form['customer_type_id'];
  158. $customer->attributeValues = $form['attribute_values'];
  159. $this->repo()->update($customer);
  160. $after = $this->repo()->findWithType((int) $id);
  161. $this->auditRepo()->log((int) $id, 'U', [
  162. 'before' => $this->toAuditFields($before),
  163. 'after' => $this->toAuditFields($after ?? []),
  164. ], $this->currentUsername());
  165. return $this->redirect('/customers/' . $id . '/edit?saved=1');
  166. }
  167. public function destroy(string $id): Response
  168. {
  169. $row = $this->repo()->findWithType((int) $id);
  170. if ($row !== null) {
  171. $this->repo()->delete((int) $id);
  172. $this->auditRepo()->log((int) $row['id'], 'D', $this->toAuditFields($row), $this->currentUsername());
  173. }
  174. return $this->redirect('/customers?deleted=1');
  175. }
  176. // ── CSV Import ────────────────────────────────────────────────────────────
  177. public function importUpload(): Response
  178. {
  179. $request = Request::capture();
  180. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  181. return Response::json(['error' => 'Session expired. Please refresh.'], 419);
  182. }
  183. $upload = $_FILES['csv_file'] ?? null;
  184. if ($upload === null || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
  185. return Response::json(['error' => 'No file was uploaded.'], 422);
  186. }
  187. try {
  188. $service = $this->fileImport();
  189. $filename = $service->store($upload);
  190. $data = $service->rows($filename, '0');
  191. return Response::json(['temp_name' => $filename, 'headers' => $data['headers']]);
  192. } catch (\Throwable $e) {
  193. return Response::json(['error' => $e->getMessage()], 422);
  194. }
  195. }
  196. public function importPreview(): Response
  197. {
  198. $request = Request::capture();
  199. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  200. return Response::json(['error' => 'Session expired. Please refresh.'], 419);
  201. }
  202. $customerTypeId = (int) $request->input('customer_type_id', 0);
  203. if ($customerTypeId === 0) {
  204. return Response::json(['error' => 'Customer type is required.'], 422);
  205. }
  206. $tempName = basename((string) $request->input('temp_name', ''));
  207. if ($tempName === '') {
  208. return Response::json(['error' => 'No file uploaded.'], 422);
  209. }
  210. $mapping = (array) ($request->input('mapping') ?? []);
  211. try {
  212. $data = $this->fileImport()->rows($tempName, '0');
  213. $rows = [];
  214. $stats = ['total' => 0, 'ok' => 0, 'duplicate' => 0, 'empty' => 0];
  215. foreach ($data['rows'] as $i => $csvRow) {
  216. $stats['total']++;
  217. $attributeValues = $this->applyMapping($mapping, $csvRow);
  218. $hasValue = false;
  219. foreach ($attributeValues as $v) {
  220. if ($v !== '') { $hasValue = true; break; }
  221. }
  222. if (!$hasValue) {
  223. $stats['empty']++;
  224. $rows[] = ['index' => $i + 1, 'status' => 'empty', 'values' => $attributeValues, 'message' => 'Row is empty'];
  225. continue;
  226. }
  227. $encodedValues = json_encode($attributeValues, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
  228. $duplicate = $this->repo()->findDuplicate($customerTypeId, $encodedValues);
  229. if ($duplicate !== null) {
  230. $stats['duplicate']++;
  231. $rows[] = [
  232. 'index' => $i + 1,
  233. 'status' => 'duplicate',
  234. 'values' => $attributeValues,
  235. 'message' => 'Duplicate of Customer #' . (int) $duplicate['id'],
  236. ];
  237. } else {
  238. $stats['ok']++;
  239. $rows[] = ['index' => $i + 1, 'status' => 'ok', 'values' => $attributeValues, 'message' => null];
  240. }
  241. }
  242. return Response::json(['rows' => $rows, 'stats' => $stats]);
  243. } catch (\Throwable $e) {
  244. return Response::json(['error' => $e->getMessage()], 422);
  245. }
  246. }
  247. public function importApprove(): Response
  248. {
  249. $request = Request::capture();
  250. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  251. return Response::json(['error' => 'Session expired. Please refresh.'], 419);
  252. }
  253. $customerTypeId = (int) $request->input('customer_type_id', 0);
  254. if ($customerTypeId === 0) {
  255. return Response::json(['error' => 'Customer type is required.'], 422);
  256. }
  257. $tempName = basename((string) $request->input('temp_name', ''));
  258. if ($tempName === '') {
  259. return Response::json(['error' => 'No file uploaded.'], 422);
  260. }
  261. $mapping = (array) ($request->input('mapping') ?? []);
  262. try {
  263. $service = $this->fileImport();
  264. $data = $service->rows($tempName, '0');
  265. $inserted = 0;
  266. $skipped = 0;
  267. $errors = [];
  268. foreach ($data['rows'] as $i => $csvRow) {
  269. $attributeValues = $this->applyMapping($mapping, $csvRow);
  270. $hasValue = false;
  271. foreach ($attributeValues as $v) {
  272. if ($v !== '') { $hasValue = true; break; }
  273. }
  274. if (!$hasValue) {
  275. $skipped++;
  276. continue;
  277. }
  278. try {
  279. $customer = new Customer();
  280. $customer->customerTypeId = $customerTypeId;
  281. $customer->attributeValues = $attributeValues;
  282. $this->repo()->create($customer);
  283. $insertedRow = $this->repo()->findLatestByType($customerTypeId);
  284. if ($insertedRow !== null) {
  285. $this->auditRepo()->log(
  286. (int) $insertedRow['id'],
  287. 'I',
  288. $this->toAuditFields($insertedRow),
  289. $this->currentUsername()
  290. );
  291. }
  292. $inserted++;
  293. } catch (\Throwable $e) {
  294. $errors[] = 'Row ' . ($i + 1) . ': ' . $e->getMessage();
  295. }
  296. }
  297. $service->delete($tempName);
  298. return Response::json(['inserted' => $inserted, 'skipped' => $skipped, 'errors' => $errors]);
  299. } catch (\Throwable $e) {
  300. return Response::json(['error' => $e->getMessage()], 422);
  301. }
  302. }
  303. // ── Helpers ───────────────────────────────────────────────────────────────
  304. private function loadCustomerTypes(): array
  305. {
  306. return array_map(static function (array $type): array {
  307. return [
  308. 'id' => (int) $type['id'],
  309. 'name' => (string) $type['name'],
  310. 'attributes' => json_decode((string) ($type['attributes'] ?? '[]'), true) ?? [],
  311. ];
  312. }, $this->ctRepo()->allOrderedByName());
  313. }
  314. private function attributesForType(int $typeId, array $types): array
  315. {
  316. foreach ($types as $type) {
  317. if ($type['id'] === $typeId) return $type['attributes'];
  318. }
  319. return [];
  320. }
  321. private function validateForm(Request $request, array $customerTypes): array
  322. {
  323. $customerTypeId = (int) $request->input('customer_type_id', 0);
  324. $submittedValues = (array) ($request->input('attribute_values') ?? []);
  325. $errors = [];
  326. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  327. $errors['_token'][] = 'Your form session expired. Please refresh and try again.';
  328. }
  329. if ($customerTypeId === 0) {
  330. $errors['customer_type_id'][] = 'Please select a customer type.';
  331. }
  332. $typeAttributes = $this->attributesForType($customerTypeId, $customerTypes);
  333. $attributeValues = [];
  334. foreach ($typeAttributes as $attr) {
  335. $attributeValues[$attr['name']] = trim((string) ($submittedValues[$attr['name']] ?? ''));
  336. }
  337. return [
  338. ['customer_type_id' => $customerTypeId, 'attribute_values' => $attributeValues],
  339. $errors,
  340. ];
  341. }
  342. private function toAuditFields(array $row): array
  343. {
  344. $attrValues = [];
  345. if (!empty($row['attribute_values'])) {
  346. $raw = $row['attribute_values'];
  347. $attrValues = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw;
  348. }
  349. return [
  350. 'customer_type_id' => (int) ($row['customer_type_id'] ?? 0),
  351. 'customer_type_name' => (string) ($row['customer_type_name'] ?? ''),
  352. 'attribute_values' => $attrValues,
  353. 'created_at' => (string) ($row['created_at'] ?? ''),
  354. 'updated_at' => (string) ($row['updated_at'] ?? ''),
  355. ];
  356. }
  357. private function currentUsername(): string
  358. {
  359. return auth()->user()?->username ?? 'system';
  360. }
  361. private function applyMapping(array $mapping, array $csvRow): array
  362. {
  363. $attributeValues = [];
  364. foreach ($mapping as $attrName => $csvColumn) {
  365. $csvColumn = trim((string) $csvColumn);
  366. if ($csvColumn === '') continue;
  367. $attributeValues[trim((string) $attrName)] = trim((string) ($csvRow[$csvColumn] ?? ''));
  368. }
  369. return $attributeValues;
  370. }
  371. private function fileImport(): FileImportService
  372. {
  373. return new FileImportService();
  374. }
  375. private function repo(): CustomerRepository
  376. {
  377. return new CustomerRepository(database());
  378. }
  379. private function auditRepo(): CustomerAuditRepository
  380. {
  381. return new CustomerAuditRepository(database());
  382. }
  383. private function ctRepo(): CustomerTypeRepository
  384. {
  385. return new CustomerTypeRepository(database());
  386. }
  387. }

Powered by TurnKey Linux.