2
0

RelationController.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. <?php
  2. namespace App\Http\Controllers;
  3. use App\Http\Resources\RelationResource;
  4. use App\Models\Relation;
  5. use App\Services\AuthService;
  6. use Illuminate\Http\JsonResponse;
  7. use Illuminate\Http\Request;
  8. use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
  9. use Illuminate\Support\Facades\Cache;
  10. use PhpOffice\PhpSpreadsheet\Spreadsheet;
  11. use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
  12. class RelationController extends Controller
  13. {
  14. /**
  15. * Vocabulary cache key — shared across requests.
  16. */
  17. private const VOCABULARY_CACHE_KEY = 'relation-vocabulary';
  18. /**
  19. * Display a listing of the resource.
  20. *
  21. * Supports optional filters: case, search, name, from, to, match, category.
  22. * When the `vocabulary` parameter is present, returns the full unfiltered
  23. * list from cache (suitable for populating UI dropdowns / autocomplete).
  24. */
  25. public function index(Request $request): JsonResponse
  26. {
  27. if ($request->boolean('vocabulary')) {
  28. return $this->ok(
  29. Cache::remember(
  30. self::VOCABULARY_CACHE_KEY,
  31. config('mint.cache.expire'),
  32. fn () => $this->buildVocabularyPayload()
  33. )
  34. );
  35. }
  36. return $this->ok($this->buildFilteredPayload($request));
  37. }
  38. /**
  39. * Store a newly created resource in storage.
  40. *
  41. * Requires authentication. Invalidates the vocabulary cache on success.
  42. */
  43. public function store(Request $request): JsonResponse
  44. {
  45. $user = AuthService::current($request);
  46. if (! $user) {
  47. return $this->error(__('auth.failed'), [], 401);
  48. }
  49. $validated = $request->validate([
  50. 'name' => 'required',
  51. ]);
  52. $relation = new Relation;
  53. $relation->name = $validated['name'];
  54. $relation->case = $request->input('case');
  55. $relation->category = $request->input('category');
  56. $relation->from = $this->encodeJsonField($request, 'from');
  57. $relation->to = $this->encodeJsonField($request, 'to');
  58. $relation->match = $this->encodeJsonField($request, 'match');
  59. $relation->editor_id = $user['user_uid'];
  60. $relation->save();
  61. Cache::forget(self::VOCABULARY_CACHE_KEY);
  62. return $this->ok(new RelationResource($relation));
  63. }
  64. /**
  65. * Display the specified resource.
  66. */
  67. public function show(Relation $relation): JsonResponse
  68. {
  69. return $this->ok(new RelationResource($relation));
  70. }
  71. /**
  72. * Update the specified resource in storage.
  73. *
  74. * Requires authentication. Invalidates the vocabulary cache on success.
  75. */
  76. public function update(Request $request, Relation $relation): JsonResponse
  77. {
  78. $user = AuthService::current($request);
  79. if (! $user) {
  80. return $this->error(__('auth.failed'), [], 401);
  81. }
  82. $relation->name = $request->input('name');
  83. $relation->case = $request->input('case');
  84. $relation->category = $request->input('category');
  85. $relation->from = $this->encodeJsonField($request, 'from');
  86. $relation->to = $this->encodeJsonField($request, 'to');
  87. $relation->match = $this->encodeJsonField($request, 'match');
  88. $relation->editor_id = $user['user_uid'];
  89. $relation->save();
  90. Cache::forget(self::VOCABULARY_CACHE_KEY);
  91. return $this->ok(new RelationResource($relation));
  92. }
  93. /**
  94. * Remove the specified resource from storage.
  95. *
  96. * Requires authentication. Invalidates the vocabulary cache on success.
  97. */
  98. public function destroy(Request $request, Relation $relation): JsonResponse
  99. {
  100. $user = AuthService::current($request);
  101. if (! $user) {
  102. return $this->error(__('auth.failed'), [], 401);
  103. }
  104. $deleted = $relation->delete();
  105. Cache::forget(self::VOCABULARY_CACHE_KEY);
  106. return $this->ok($deleted);
  107. }
  108. /**
  109. * Export all relations as an XLSX file download.
  110. *
  111. * Streams the spreadsheet directly to the browser via php://output.
  112. * Columns: id, name, from, to, match, category.
  113. */
  114. public function export(): void
  115. {
  116. $spreadsheet = new Spreadsheet;
  117. $activeWorksheet = $spreadsheet->getActiveSheet();
  118. $activeWorksheet->fromArray(['id', 'name', 'from', 'to', 'match', 'category'], null, 'A1');
  119. $currLine = 2;
  120. foreach (Relation::cursor() as $row) {
  121. $activeWorksheet->fromArray(
  122. [$row->id, $row->name, $row->from, $row->to, $row->match, $row->category],
  123. null,
  124. "A{$currLine}"
  125. );
  126. $currLine++;
  127. }
  128. $writer = new Xlsx($spreadsheet);
  129. header('Content-Type: application/vnd.ms-excel');
  130. header('Content-Disposition: attachment; filename="relation.xlsx"');
  131. $writer->save('php://output');
  132. }
  133. /**
  134. * Import relations from an uploaded XLSX file.
  135. *
  136. * Requires authentication. Reads the file path from the `filename` request
  137. * parameter. Existing records are matched by id and updated in place;
  138. * rows without a matching id are inserted as new records.
  139. * Invalidates the vocabulary cache on completion.
  140. */
  141. public function import(Request $request): JsonResponse
  142. {
  143. $user = AuthService::current($request);
  144. if (! $user) {
  145. return $this->error(__('auth.failed'), [], 401);
  146. }
  147. $filename = $request->input('filename');
  148. $reader = new \PhpOffice\PhpSpreadsheet\Reader\Xlsx;
  149. $reader->setReadDataOnly(true);
  150. $spreadsheet = $reader->load($filename);
  151. $activeWorksheet = $spreadsheet->getActiveSheet();
  152. $currLine = 2;
  153. $countFail = 0;
  154. $error = '';
  155. while (true) {
  156. $name = $activeWorksheet->getCell("B{$currLine}")->getValue();
  157. if (empty($name)) {
  158. break;
  159. }
  160. $id = $activeWorksheet->getCell("A{$currLine}")->getValue();
  161. $from = $activeWorksheet->getCell("C{$currLine}")->getValue();
  162. $to = $activeWorksheet->getCell("D{$currLine}")->getValue();
  163. $match = $activeWorksheet->getCell("E{$currLine}")->getValue();
  164. $category = $activeWorksheet->getCell("F{$currLine}")->getValue();
  165. $row = (! empty($id) ? Relation::find($id) : null) ?? new Relation;
  166. $row->name = $name;
  167. $row->from = empty($from) ? null : $from;
  168. $row->to = $to;
  169. $row->match = $match;
  170. $row->category = $category;
  171. $row->editor_id = $user['user_uid'];
  172. $row->save();
  173. $currLine++;
  174. }
  175. Cache::forget(self::VOCABULARY_CACHE_KEY);
  176. $success = $currLine - 2 - $countFail;
  177. return $this->ok(['success' => $success, 'fail' => $countFail], $error);
  178. }
  179. // -------------------------------------------------------------------------
  180. // Private helpers
  181. // -------------------------------------------------------------------------
  182. /**
  183. * Build the full vocabulary payload for caching.
  184. *
  185. * ResourceCollection is resolved to a plain PHP array via resolve() before
  186. * being stored, preventing __PHP_Incomplete_Class_Name deserialization
  187. * errors when using Redis or file-based cache drivers. RelationResource is
  188. * responsible for keeping the nested values object-free.
  189. *
  190. * @return array{rows: array<int, array<string, mixed>>, count: int}
  191. */
  192. private function buildVocabularyPayload(): array
  193. {
  194. $rows = Relation::select([
  195. 'id',
  196. 'name',
  197. 'case',
  198. 'from',
  199. 'to',
  200. 'category',
  201. 'editor_id',
  202. 'match',
  203. 'updated_at',
  204. 'created_at',
  205. ])->orderBy('updated_at', 'desc')->get();
  206. return [
  207. 'rows' => RelationResource::collection($rows)->resolve(),
  208. 'count' => $rows->count(),
  209. ];
  210. }
  211. /**
  212. * Build a filtered and paginated payload for standard index requests.
  213. *
  214. * Supported query parameters:
  215. * - case (comma-separated) filter by case values
  216. * - search (string) prefix match on name
  217. * - name (string) exact match on name
  218. * - from (string) JSON contains on from->case
  219. * - to (string) JSON contains on to
  220. * - match (string) JSON contains on match
  221. * - category (string) exact match on category
  222. * - order (string) column to sort by (default: updated_at)
  223. * - dir (asc|desc) sort direction (default: desc)
  224. * - offset (int) skip N rows (default: 0)
  225. * - limit (int) max rows returned (default: 1000)
  226. *
  227. * @return array{rows: AnonymousResourceCollection, count: int}
  228. */
  229. private function buildFilteredPayload(Request $request): array
  230. {
  231. $query = Relation::select([
  232. 'id',
  233. 'name',
  234. 'case',
  235. 'from',
  236. 'to',
  237. 'category',
  238. 'editor_id',
  239. 'match',
  240. 'updated_at',
  241. 'created_at',
  242. ]);
  243. if ($request->filled('case')) {
  244. $query->whereIn('case', explode(',', $request->input('case')));
  245. }
  246. if ($request->filled('search')) {
  247. $query->where('name', 'like', $request->input('search').'%');
  248. }
  249. if ($request->filled('name')) {
  250. $query->where('name', $request->input('name'));
  251. }
  252. if ($request->filled('from')) {
  253. $query->whereJsonContains('from->case', $request->input('from'));
  254. }
  255. if ($request->filled('to')) {
  256. $query->whereJsonContains('to', $request->input('to'));
  257. }
  258. if ($request->filled('match')) {
  259. $query->whereJsonContains('match', $request->input('match'));
  260. }
  261. if ($request->filled('category')) {
  262. $query->where('category', $request->input('category'));
  263. }
  264. $query->orderBy($request->input('order', 'updated_at'), $request->input('dir', 'desc'));
  265. $count = $query->count();
  266. $rows = $query->skip($request->input('offset', 0))
  267. ->take($request->input('limit', 1000))
  268. ->get();
  269. return [
  270. 'rows' => RelationResource::collection($rows),
  271. 'count' => $count,
  272. ];
  273. }
  274. /**
  275. * Encode a request field as a JSON string.
  276. *
  277. * Returns null when the field is absent from the request, preserving the
  278. * semantic distinction between "not provided" and an explicit empty value.
  279. */
  280. private function encodeJsonField(Request $request, string $field): ?string
  281. {
  282. return $request->has($field)
  283. ? json_encode($request->input($field), JSON_UNESCAPED_UNICODE)
  284. : null;
  285. }
  286. }