2
0

RelationController.php 12 KB

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