選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

703 行
22KB

  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Controllers;
  4. use App\Models\Job;
  5. use App\Repositories\CampaignRepository;
  6. use App\Repositories\JobAuditRepository;
  7. use App\Repositories\JobRepository;
  8. use App\Repositories\JobTypeRepository;
  9. use App\Services\FileImportService;
  10. use App\Services\GoogleSheetImportService;
  11. use App\ViewModels\JobViewModel;
  12. use Core\Controller;
  13. use Core\Request;
  14. use Core\Response;
  15. use Core\Validator;
  16. use Throwable;
  17. class JobController extends Controller
  18. {
  19. public function index(): Response
  20. {
  21. $request = Request::capture();
  22. $model = new JobViewModel();
  23. $model->saved = $request->input('saved') === '1';
  24. $model->deleted = $request->input('deleted') === '1';
  25. return $this->view('jobs.index', [
  26. 'model' => $model,
  27. 'pageTitle' => $model->title,
  28. ]);
  29. }
  30. public function data(): Response
  31. {
  32. return $this->json($this->formatJobRows($this->repo()->allWithDetails()));
  33. }
  34. public function dataForCampaign(string $campaignId): Response
  35. {
  36. return $this->json($this->formatJobRows(
  37. $this->repo()->allWithDetailsForCampaign((int) $campaignId)
  38. ));
  39. }
  40. public function export(): Response
  41. {
  42. return $this->csvResponse(
  43. $this->exportRowsFromRequest(),
  44. 'jobs-' . date('Y-m-d-His') . '.csv'
  45. );
  46. }
  47. public function exportForCampaign(string $campaignId): Response
  48. {
  49. $campaign = $this->campaignRepo()->findWithType((int) $campaignId);
  50. if ($campaign === null) {
  51. return $this->redirect('/campaigns');
  52. }
  53. return $this->csvResponse(
  54. $this->exportRowsFromRequest(),
  55. 'campaign-' . (int) $campaignId . '-jobs-' . date('Y-m-d-His') . '.csv'
  56. );
  57. }
  58. public function campaign(string $campaignId): Response
  59. {
  60. $campaign = $this->campaignRepo()->findWithType((int) $campaignId);
  61. if ($campaign === null) {
  62. return $this->redirect('/campaigns');
  63. }
  64. return $this->view('jobs.campaign', [
  65. 'campaign' => $campaign,
  66. 'jobTypes' => $this->loadJobTypes(),
  67. 'pageTitle' => 'Campaign #' . $campaignId . ' Jobs',
  68. ]);
  69. }
  70. public function googleSheetsList(string $campaignId): Response
  71. {
  72. if ($this->campaignRepo()->find((int) $campaignId) === null) {
  73. return Response::json(['error' => 'Campaign not found.'], 404);
  74. }
  75. $request = Request::capture();
  76. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  77. return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419);
  78. }
  79. $url = trim((string) $request->input('sheet_url', ''));
  80. if ($url === '') {
  81. return Response::json(['error' => 'Enter a Google Sheets URL.'], 422);
  82. }
  83. try {
  84. return Response::json($this->googleSheets()->sheets($url));
  85. } catch (\Throwable $e) {
  86. return Response::json(['error' => $e->getMessage()], 422);
  87. }
  88. }
  89. public function importGoogleSheet(string $campaignId): Response
  90. {
  91. if ($this->campaignRepo()->find((int) $campaignId) === null) {
  92. return Response::json(['error' => 'Campaign not found.'], 404);
  93. }
  94. $request = Request::capture();
  95. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  96. return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419);
  97. }
  98. $url = trim((string) $request->input('sheet_url', ''));
  99. $gid = trim((string) $request->input('sheet_gid', ''));
  100. $jobTypeId = (int) $request->input('job_type_id', 0);
  101. $jobType = $this->jtRepo()->find($jobTypeId);
  102. if ($url === '' || $gid === '' || $jobType === null) {
  103. return Response::json(['error' => 'Select a Google Sheets file, sheet, and job type.'], 422);
  104. }
  105. try {
  106. $sheet = $this->googleSheets()->rows($url, $gid);
  107. $attributes = json_decode((string) ($jobType['attributes'] ?? '[]'), true) ?? [];
  108. $result = $this->importRows(
  109. (int) $campaignId,
  110. $jobTypeId,
  111. $attributes,
  112. $sheet['headers'],
  113. $sheet['rows']
  114. );
  115. return Response::json($result);
  116. } catch (\Throwable $e) {
  117. return Response::json(['error' => $e->getMessage()], 422);
  118. }
  119. }
  120. public function create(): Response
  121. {
  122. $model = new JobViewModel();
  123. $model->title = 'New Job';
  124. $model->campaigns = $this->loadCampaigns();
  125. $model->jobTypes = $this->loadJobTypes();
  126. return $this->view('jobs.create', [
  127. 'model' => $model,
  128. 'pageTitle' => $model->title,
  129. ]);
  130. }
  131. public function store(): Response
  132. {
  133. $request = Request::capture();
  134. $model = new JobViewModel();
  135. $model->title = 'New Job';
  136. $model->campaigns = $this->loadCampaigns();
  137. $model->jobTypes = $this->loadJobTypes();
  138. [$form, $errors] = $this->validateForm($request, $model->jobTypes);
  139. if (!empty($errors)) {
  140. $model->form = $form;
  141. $model->errors = $errors;
  142. return $this->view('jobs.create', [
  143. 'model' => $model,
  144. 'pageTitle' => $model->title,
  145. ]);
  146. }
  147. $job = new Job();
  148. $job->campaignId = (int) $form['campaign_id'];
  149. $job->jobTypeId = (int) $form['job_type_id'];
  150. $job->attributeValues = $form['attribute_values'];
  151. $this->repo()->create($job);
  152. $inserted = $this->repo()->findLatestByCampaignAndType($job->campaignId, $job->jobTypeId);
  153. if ($inserted !== null) {
  154. $this->auditRepo()->log((int) $inserted['id'], 'I', $this->toAuditFields($inserted), $this->currentUsername());
  155. }
  156. return $this->redirect('/jobs?saved=1');
  157. }
  158. public function edit(string $id): Response
  159. {
  160. $row = $this->repo()->findWithDetails((int) $id);
  161. if ($row === null) {
  162. return $this->redirect('/jobs');
  163. }
  164. $storedValues = !empty($row['attribute_values'])
  165. ? (json_decode((string) $row['attribute_values'], true) ?? [])
  166. : [];
  167. $model = new JobViewModel();
  168. $model->title = 'Edit Job';
  169. $model->job = $row;
  170. $model->saved = Request::capture()->input('saved') === '1';
  171. $model->campaigns = $this->loadCampaigns();
  172. $model->jobTypes = $this->loadJobTypes();
  173. $model->form = [
  174. 'campaign_id' => (int) $row['campaign_id'],
  175. 'job_type_id' => (int) $row['job_type_id'],
  176. 'attribute_values' => $storedValues,
  177. ];
  178. return $this->view('jobs.edit', [
  179. 'model' => $model,
  180. 'pageTitle' => $model->title,
  181. ]);
  182. }
  183. public function update(string $id): Response
  184. {
  185. $before = $this->repo()->findWithDetails((int) $id);
  186. if ($before === null) {
  187. return $this->redirect('/jobs');
  188. }
  189. $request = Request::capture();
  190. $model = new JobViewModel();
  191. $model->title = 'Edit Job';
  192. $model->job = $before;
  193. $model->campaigns = $this->loadCampaigns();
  194. $model->jobTypes = $this->loadJobTypes();
  195. [$form, $errors] = $this->validateForm($request, $model->jobTypes);
  196. if (!empty($errors)) {
  197. $model->form = $form;
  198. $model->errors = $errors;
  199. return $this->view('jobs.edit', [
  200. 'model' => $model,
  201. 'pageTitle' => $model->title,
  202. ]);
  203. }
  204. $job = new Job();
  205. $job->id = (int) $id;
  206. $job->campaignId = (int) $form['campaign_id'];
  207. $job->jobTypeId = (int) $form['job_type_id'];
  208. $job->attributeValues = $form['attribute_values'];
  209. $this->repo()->update($job);
  210. $after = $this->repo()->findWithDetails((int) $id);
  211. $this->auditRepo()->log((int) $id, 'U', [
  212. 'before' => $this->toAuditFields($before),
  213. 'after' => $this->toAuditFields($after ?? []),
  214. ], $this->currentUsername());
  215. return $this->redirect('/jobs/' . $id . '/edit?saved=1');
  216. }
  217. public function destroy(string $id): Response
  218. {
  219. $row = $this->repo()->findWithDetails((int) $id);
  220. if ($row !== null) {
  221. $this->repo()->delete((int) $id);
  222. $this->auditRepo()->log((int) $row['id'], 'D', $this->toAuditFields($row), $this->currentUsername());
  223. }
  224. return $this->redirect('/jobs?deleted=1');
  225. }
  226. // ── Helpers ───────────────────────────────────────────────────────────────
  227. private function loadCampaigns(): array
  228. {
  229. return $this->campaignRepo()->allWithType();
  230. }
  231. private function loadJobTypes(): array
  232. {
  233. return array_map(static function (array $t): array {
  234. return [
  235. 'id' => (int) $t['id'],
  236. 'name' => (string) $t['name'],
  237. 'attributes' => json_decode((string) ($t['attributes'] ?? '[]'), true) ?? [],
  238. ];
  239. }, $this->jtRepo()->allOrderedByName());
  240. }
  241. private function attributesForType(int $typeId, array $types): array
  242. {
  243. foreach ($types as $type) {
  244. if ($type['id'] === $typeId) return $type['attributes'];
  245. }
  246. return [];
  247. }
  248. /**
  249. * @param list<array{name?: string, type?: string, order?: int}> $attributes
  250. * @param list<string> $headers
  251. * @param list<array<string, string>> $rows
  252. * @return array{imported: int, skipped: int, matched_attributes: list<string>}
  253. */
  254. private function importRows(int $campaignId, int $jobTypeId, array $attributes, array $headers, array $rows): array
  255. {
  256. $headersByName = [];
  257. foreach ($headers as $header) {
  258. $normalized = $this->normalizeImportHeader($header);
  259. if ($normalized !== '') {
  260. $headersByName[$normalized] = $header;
  261. }
  262. }
  263. $matchedAttributes = [];
  264. foreach ($attributes as $attribute) {
  265. $name = trim((string) ($attribute['name'] ?? ''));
  266. if ($name === '') {
  267. continue;
  268. }
  269. $header = $headersByName[$this->normalizeImportHeader($name)] ?? null;
  270. if ($header !== null) {
  271. $matchedAttributes[$name] = $header;
  272. }
  273. }
  274. if ($matchedAttributes === []) {
  275. throw new \RuntimeException('No sheet headers matched the selected job type attributes.');
  276. }
  277. $imported = 0;
  278. $skipped = 0;
  279. foreach ($rows as $row) {
  280. $attributeValues = [];
  281. $hasValue = false;
  282. foreach ($matchedAttributes as $attributeName => $header) {
  283. $value = trim((string) ($row[$header] ?? ''));
  284. $attributeValues[$attributeName] = $value;
  285. $hasValue = $hasValue || $value !== '';
  286. }
  287. if (!$hasValue) {
  288. $skipped++;
  289. continue;
  290. }
  291. $job = new Job();
  292. $job->campaignId = $campaignId;
  293. $job->jobTypeId = $jobTypeId;
  294. $job->attributeValues = $attributeValues;
  295. $this->repo()->create($job);
  296. $inserted = $this->repo()->findLatestByCampaignAndType($campaignId, $jobTypeId);
  297. if ($inserted !== null) {
  298. $this->auditRepo()->log(
  299. (int) $inserted['id'],
  300. 'I',
  301. $this->toAuditFields($inserted),
  302. $this->currentUsername()
  303. );
  304. }
  305. $imported++;
  306. }
  307. return [
  308. 'imported' => $imported,
  309. 'skipped' => $skipped,
  310. 'matched_attributes' => array_keys($matchedAttributes),
  311. ];
  312. }
  313. private function normalizeImportHeader(string $value): string
  314. {
  315. $value = strtolower(trim($value));
  316. $value = preg_replace('/[^a-z0-9]+/', ' ', $value) ?? '';
  317. return trim(preg_replace('/\s+/', ' ', $value) ?? '');
  318. }
  319. /**
  320. * @return list<array<string, mixed>>
  321. */
  322. private function exportRowsFromRequest(): array
  323. {
  324. $token = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
  325. if (!verify_csrf_token($token)) {
  326. return [];
  327. }
  328. $rawBody = file_get_contents('php://input');
  329. if (!is_string($rawBody) || trim($rawBody) === '') {
  330. return [];
  331. }
  332. try {
  333. $payload = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
  334. } catch (Throwable) {
  335. return [];
  336. }
  337. $rows = $payload['rows'] ?? [];
  338. $columns = $payload['columns'] ?? [];
  339. if (!is_array($rows) || !is_array($columns)) {
  340. return [];
  341. }
  342. $visibleColumns = [];
  343. foreach ($columns as $column) {
  344. if (!is_array($column)) {
  345. continue;
  346. }
  347. $field = trim((string) ($column['field'] ?? ''));
  348. if ($field === '' || $field === 'edit_url') {
  349. continue;
  350. }
  351. $visibleColumns[] = [
  352. 'field' => $field,
  353. 'title' => trim((string) ($column['title'] ?? $field)),
  354. ];
  355. }
  356. if ($visibleColumns === []) {
  357. return [];
  358. }
  359. $exportRows = [];
  360. foreach ($rows as $row) {
  361. if (!is_array($row)) {
  362. continue;
  363. }
  364. $exportRow = [];
  365. foreach ($visibleColumns as $column) {
  366. $exportRow[$column['title']] = $this->csvCellValue($row[$column['field']] ?? '');
  367. }
  368. $exportRows[] = $exportRow;
  369. }
  370. return $exportRows;
  371. }
  372. /**
  373. * @param list<array<string, mixed>> $rows
  374. */
  375. private function csvResponse(array $rows, string $filename): Response
  376. {
  377. $handle = fopen('php://temp', 'r+');
  378. if ($handle === false) {
  379. return Response::make('Unable to create CSV export.', 500);
  380. }
  381. try {
  382. fwrite($handle, "\xEF\xBB\xBF");
  383. if ($rows !== []) {
  384. fputcsv($handle, array_keys($rows[0]));
  385. foreach ($rows as $row) {
  386. fputcsv($handle, array_values($row));
  387. }
  388. }
  389. rewind($handle);
  390. $csv = stream_get_contents($handle);
  391. } finally {
  392. fclose($handle);
  393. }
  394. if ($csv === false) {
  395. return Response::make('Unable to create CSV export.', 500);
  396. }
  397. return new Response($csv, 200, [
  398. 'Content-Type' => 'text/csv; charset=UTF-8',
  399. 'Content-Disposition' => 'attachment; filename="' . $filename . '"',
  400. 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
  401. ]);
  402. }
  403. private function csvCellValue(mixed $value): string
  404. {
  405. if ($value === null) {
  406. return '';
  407. }
  408. if (is_array($value) || is_object($value)) {
  409. try {
  410. return json_encode($value, JSON_THROW_ON_ERROR) ?: '';
  411. } catch (Throwable) {
  412. return '';
  413. }
  414. }
  415. return (string) $value;
  416. }
  417. private function googleSheets(): GoogleSheetImportService
  418. {
  419. return new GoogleSheetImportService();
  420. }
  421. private function fileImport(): FileImportService
  422. {
  423. return new FileImportService();
  424. }
  425. // ── File upload import ────────────────────────────────────────────────────
  426. public function fileSheetsList(string $campaignId): Response
  427. {
  428. if ($this->campaignRepo()->find((int) $campaignId) === null) {
  429. return Response::json(['error' => 'Campaign not found.'], 404);
  430. }
  431. $request = Request::capture();
  432. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  433. return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419);
  434. }
  435. $upload = $_FILES['import_file'] ?? null;
  436. if ($upload === null || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
  437. return Response::json(['error' => 'No file was uploaded.'], 422);
  438. }
  439. try {
  440. $service = $this->fileImport();
  441. $filename = $service->store($upload);
  442. $sheets = $service->sheets($filename);
  443. return Response::json(['temp_file' => $filename, 'sheets' => $sheets]);
  444. } catch (\Throwable $e) {
  445. return Response::json(['error' => $e->getMessage()], 422);
  446. }
  447. }
  448. public function importFile(string $campaignId): Response
  449. {
  450. if ($this->campaignRepo()->find((int) $campaignId) === null) {
  451. return Response::json(['error' => 'Campaign not found.'], 404);
  452. }
  453. $request = Request::capture();
  454. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  455. return Response::json(['error' => 'Your form session expired. Please refresh and try again.'], 419);
  456. }
  457. $tempFile = basename(trim((string) $request->input('temp_file', '')));
  458. $gid = trim((string) $request->input('sheet_gid', '0'));
  459. $jobTypeId = (int) $request->input('job_type_id', 0);
  460. $jobType = $this->jtRepo()->find($jobTypeId);
  461. if ($tempFile === '' || $jobType === null) {
  462. return Response::json(['error' => 'Select a file, sheet, and job type.'], 422);
  463. }
  464. try {
  465. $service = $this->fileImport();
  466. $sheet = $service->rows($tempFile, $gid);
  467. $attributes = json_decode((string) ($jobType['attributes'] ?? '[]'), true) ?? [];
  468. $result = $this->importRows(
  469. (int) $campaignId,
  470. $jobTypeId,
  471. $attributes,
  472. $sheet['headers'],
  473. $sheet['rows']
  474. );
  475. $service->delete($tempFile);
  476. return Response::json($result);
  477. } catch (\Throwable $e) {
  478. return Response::json(['error' => $e->getMessage()], 422);
  479. }
  480. }
  481. /**
  482. * @param list<array<string, mixed>> $rows
  483. * @return list<array<string, mixed>>
  484. */
  485. private function formatJobRows(array $rows): array
  486. {
  487. return array_map(static function (array $row): array {
  488. $attrValues = !empty($row['attribute_values'])
  489. ? (json_decode((string) $row['attribute_values'], true) ?? [])
  490. : [];
  491. $jobTypeAttributes = !empty($row['job_type_attributes'])
  492. ? (json_decode((string) $row['job_type_attributes'], true) ?? [])
  493. : [];
  494. $summary = implode(', ', array_map(
  495. static fn($k, $v) => "{$k}: {$v}",
  496. array_keys($attrValues),
  497. array_values($attrValues)
  498. ));
  499. return [
  500. 'id' => (int) $row['id'],
  501. 'campaign_id' => (int) $row['campaign_id'],
  502. 'campaign_type_name' => (string) $row['campaign_type_name'],
  503. 'job_type_id' => (int) $row['job_type_id'],
  504. 'job_type_name' => (string) $row['job_type_name'],
  505. 'job_type_attributes' => $jobTypeAttributes,
  506. 'attribute_values' => $attrValues,
  507. 'attributes_summary' => $summary,
  508. 'created_at' => (string) $row['created_at'],
  509. 'updated_at' => (string) ($row['updated_at'] ?? ''),
  510. ];
  511. }, $rows);
  512. }
  513. private function validateForm(Request $request, array $jobTypes): array
  514. {
  515. $campaignId = (int) $request->input('campaign_id', 0);
  516. $jobTypeId = (int) $request->input('job_type_id', 0);
  517. $submittedValues = (array) ($request->input('attribute_values') ?? []);
  518. $errors = [];
  519. if (!verify_csrf_token((string) $request->input('_token', ''))) {
  520. $errors['_token'][] = 'Your form session expired. Please refresh and try again.';
  521. }
  522. if ($campaignId === 0) {
  523. $errors['campaign_id'][] = 'Please select a campaign.';
  524. }
  525. if ($jobTypeId === 0) {
  526. $errors['job_type_id'][] = 'Please select a job type.';
  527. }
  528. $typeAttributes = $this->attributesForType($jobTypeId, $jobTypes);
  529. $attributeValues = [];
  530. foreach ($typeAttributes as $attr) {
  531. $attributeValues[$attr['name']] = trim((string) ($submittedValues[$attr['name']] ?? ''));
  532. }
  533. return [
  534. ['campaign_id' => $campaignId, 'job_type_id' => $jobTypeId, 'attribute_values' => $attributeValues],
  535. $errors,
  536. ];
  537. }
  538. private function toAuditFields(array $row): array
  539. {
  540. $attrValues = [];
  541. if (!empty($row['attribute_values'])) {
  542. $raw = $row['attribute_values'];
  543. $attrValues = is_string($raw) ? (json_decode($raw, true) ?? []) : (array) $raw;
  544. }
  545. return [
  546. 'campaign_id' => (int) ($row['campaign_id'] ?? 0),
  547. 'campaign_type_name' => (string) ($row['campaign_type_name'] ?? ''),
  548. 'job_type_id' => (int) ($row['job_type_id'] ?? 0),
  549. 'job_type_name' => (string) ($row['job_type_name'] ?? ''),
  550. 'attribute_values' => $attrValues,
  551. 'created_at' => (string) ($row['created_at'] ?? ''),
  552. 'updated_at' => (string) ($row['updated_at'] ?? ''),
  553. ];
  554. }
  555. private function currentUsername(): string
  556. {
  557. return auth()->user()?->username ?? 'system';
  558. }
  559. private function repo(): JobRepository
  560. {
  561. return new JobRepository(database());
  562. }
  563. private function auditRepo(): JobAuditRepository
  564. {
  565. return new JobAuditRepository(database());
  566. }
  567. private function campaignRepo(): CampaignRepository
  568. {
  569. return new CampaignRepository(database());
  570. }
  571. private function jtRepo(): JobTypeRepository
  572. {
  573. return new JobTypeRepository(database());
  574. }
  575. }

Powered by TurnKey Linux.