forked from cbdb-project/cbdb-online-main-server
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAbstractPersonSubresourceMutationHandler.php
More file actions
620 lines (530 loc) · 27.2 KB
/
Copy pathAbstractPersonSubresourceMutationHandler.php
File metadata and controls
620 lines (530 loc) · 27.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
<?php
namespace App\Services\Mutations;
use App\Models\Operation;
use App\Repositories\OperationRepository;
use App\Services\AuditLogService;
use App\Support\CompositePrimaryKey;
use App\Support\VariantEquivalentLookup;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* 人物子資源 mutation handler 共用基底類
*
* 抽象出 repeated-form update 的共通邏輯:
* - 驗證 composite PK
* - 驗證 person_id 一致性
* - 查原始 row
* - 驗證 allowed update fields
* - 判斷是否有有效變更
* - direct 更新(含 transaction + operation + audit_log)
* - proposal 寫 operation
* - 統一 response shape
*/
abstract class AbstractPersonSubresourceMutationHandler extends AbstractMutationHandler {
use \App\Services\Mutations\Concerns\RecordsAiFillSubmission;
use \App\Services\Mutations\Concerns\AppliesVariantReplacement;
protected OperationRepository $operationRepository;
protected AuditLogService $auditLogService;
public function __construct(
OperationRepository $operationRepository,
AuditLogService $auditLogService
) {
$this->operationRepository = $operationRepository;
$this->auditLogService = $auditLogService;
}
// ── 子類必須實作 ─────────────────────────────────────────
/** 資源名稱(回傳用),例如 'addresses' */
abstract protected function resourceName(): string;
/** 資料表名稱,例如 'BIOG_ADDR_DATA' */
abstract protected function tableName(): string;
/** 顯示名稱(proposal meta 用),例如 '地址' */
abstract protected function displayName(): string;
/** 可接受的 resource alias 列表(含主名),例如 ['addresses', 'address', 'biog_addr_data'] */
abstract protected function resourceAliases(): array;
/** 允許更新的欄位白名單 */
abstract protected function allowedFields(): array;
/** __key_columns(proposal meta 用),通常與 CompositePrimaryKey SCHEMAS 一致 */
abstract protected function keyColumns(): array;
/** person_id 在主鍵中的欄位名,預設 'c_personid' */
protected function personIdColumn(): string {
return 'c_personid';
}
// ── supports ─────────────────────────────────────────────
public function supports(string $resource, string $mode, string $operation): bool {
return in_array($resource, $this->resourceAliases(), true)
&& in_array($mode, ['direct', 'proposal'], true)
&& $operation === 'update';
}
// ── handle ───────────────────────────────────────────────
public function handle(string $resource, string $mode, string $operation, int $personId, array $targetPk, array $changes, array $meta = []): JsonResponse {
// 異體字落地替換的通知一律在此統一掛上:涵蓋成功、409(替換後撞既有 PK)與
// 422(替換後無實際變更)三類回應——使用者必須知道「我輸入的字被正規化了」,
// 尤其是在被擋下來的時候,否則錯誤訊息看起來毫無道理。
$this->resetVariantReplaced();
return $this->withVariantNotices(
$this->handleAfterVariantReset($resource, $mode, $operation, $personId, $targetPk, $changes, $meta)
);
}
/** handle() 的原始流程;異體字通知由 handle() 統一掛上。 */
protected function handleAfterVariantReset(string $resource, string $mode, string $operation, int $personId, array $targetPk, array $changes, array $meta = []): JsonResponse {
// 1. 授權
$authorizationError = $mode === 'proposal' ? $this->authorizeProposal() : $this->authorizeDirect();
if ($authorizationError) {
return $authorizationError;
}
// 2. 驗證 PK 格式
try {
CompositePrimaryKey::validateOrFail($targetPk, $this->tableName());
} catch (\Throwable $e) {
return $this->errorResponse('主鍵格式不正確', 422, ['pk' => [$e->getMessage()]]);
}
// 3. changes 不可為空
if (empty($changes)) {
return $this->errorResponse('changes 不可為空', 422, ['changes' => ['empty']]);
}
// 4. person_id 與 PK 一致性(子類可覆寫跳過此步驟)
$pkValidationError = $this->validatePersonIdInPk($personId, $targetPk);
if ($pkValidationError) {
return $pkValidationError;
}
// 5. 查原始記錄
$original = $this->findOriginalRow($targetPk);
if (!$original) {
return $this->errorResponse($this->tableName() . ' 記錄不存在', 404);
}
// 6. 驗證 person_id 與記錄一致性
$rowValidationError = $this->validatePersonIdInRow($personId, $original);
if ($rowValidationError) {
return $rowValidationError;
}
// 7. 拒絕白名單外的欄位
$disallowedFields = array_diff(array_keys($changes), $this->allowedFields());
if (!empty($disallowedFields)) {
return $this->errorResponse('包含不允許更新的欄位', 422, [
'changes' => ['disallowed_fields: ' . implode(', ', $disallowedFields)],
]);
}
// 8. 過濾出可更新欄位
$updateData = array_intersect_key($changes, array_flip($this->allowedFields()));
if (empty($updateData)) {
return $this->errorResponse('changes 至少需包含一個可更新欄位', 422, [
'changes' => ['no_supported_fields'],
]);
}
// 9. 欄位值驗證(子類可覆寫)
$validationErrors = $this->validateFields($updateData);
if (!empty($validationErrors)) {
return $this->errorResponse('參數校驗失敗', 422, $validationErrors);
}
// 9.5 異體字落地替換(型別驅動;見 Concerns\AppliesVariantReplacement)。
// 必須早於 preprocessUpdateData()、hasEffectiveChanges() 與 buildNewPk():
// 「只把變體形改成參考形」不能被當成無變更擋掉,替換後的值也要進新 PK。
$updateData = $this->applyVariantReplacement($updateData);
// 10. 前處理(子類可覆寫,如 -999 → 0 轉換)
$updateData = $this->preprocessUpdateData($updateData);
// 11. 檢查是否有實際變更
$originalArray = $this->auditLogService->normalizeRow($original);
if (!$this->hasEffectiveChanges($originalArray, $updateData)) {
return $this->errorResponse('未偵測到任何修改內容', 422, [
'changes' => ['no_effective_changes'],
]);
}
// 11.5 D7「兩形並存」查重(改鍵時)。DB 唯一鍵只擋得住**同字形**的碰撞,
// 而既有列可能存變體形(D6 不做回溯校正):把某列的文本型 PK 成員改成
// 「歸一後等於另一既有變體形列」的值時,精確比對查不到、唯一鍵也不衝突,
// 就會落成兩列語義相同、字形不同的資料。與 create 側同一個缺口。
if ($this->findVariantEquivalentPkConflict($targetPk, $updateData, $originalArray) !== null) {
return $this->errorResponse('變更後的主鍵與現有記錄重複(異體字歸一後相同)', 409, [
'target.pk' => ['conflict'],
]);
}
$comment = is_string($meta['comment'] ?? null) ? trim($meta['comment']) : '';
// 12. 分派到 direct / proposal
if ($mode === 'proposal') {
return $this->handleProposal($personId, $targetPk, $updateData, $originalArray, $comment);
}
$response = $this->handleDirect($personId, $targetPk, $updateData, $originalArray, $comment);
// AI 智能識別:direct 更新成功後回寫 ai_fill_logs(見 RecordsAiFillSubmission)。
// 提交資料=更新後 PK + 使用者實際變更欄位($updateData 尚未注入 c_modified_*,那是 handleDirect 內的本地副本);
// 僅 aiFillCategory() 非 null 的資源實際回寫。
if ($response->getStatusCode() === 200) {
$this->recordAiFillSubmission(
$meta,
$personId,
array_merge($this->buildNewPk($targetPk, $updateData), $updateData)
);
}
return $response;
}
/**
* 驗證 person_id 與 PK 中的 c_personid 一致性
*
* 當 PK 不含 c_personid(如 POSSESSION_DATA、POSTED_TO_OFFICE_DATA)時,
* 子類可覆寫此方法回傳 null 以跳過此檢查。
*/
protected function validatePersonIdInPk(int $personId, array $targetPk): ?JsonResponse {
$pkPersonId = $targetPk[$this->personIdColumn()] ?? null;
if ((string) $pkPersonId !== (string) $personId) {
return $this->errorResponse('person_id 與 target.pk.' . $this->personIdColumn() . ' 不一致', 422, [
'person_id' => ['mismatch'],
]);
}
return null;
}
/**
* 驗證 person_id 與原始記錄的 c_personid 一致性
*
* 預設檢查 $original->{personIdColumn()} 是否與 $personId 相同。
*/
protected function validatePersonIdInRow(int $personId, object $original): ?JsonResponse {
if ((string) ($original->{$this->personIdColumn()} ?? '') !== (string) $personId) {
return $this->errorResponse('person_id 與目標記錄不一致', 422, ['person_id' => ['mismatch']]);
}
return null;
}
// ── Direct Update ────────────────────────────────────────
protected function handleDirect(int $personId, array $targetPk, array $updateData, array $originalArray, string $comment): JsonResponse {
// 對齊 legacy ToolsRepository::timestamp()(update 分支):direct 更新一律於主列蓋更新者/
// 更新時間,並移除建檔欄位以免覆寫原始建檔資訊。修正 v2 子資源直改未寫 c_modified_* 的稽核/
// 對齊缺口(11/12 子資源原本不刷新;source 走 BiogSourceRepository 另已處理)。
// 須在 transaction 閉包捕獲 $updateData 前注入;有效變更判斷已在 handle() 以未注入前的 changes 完成,
// 故此注入不影響「無變更」攔截。
$updateData['c_modified_by'] = \App\Support\AuditActor::currentName();
$updateData['c_modified_date'] = Carbon::now();
unset($updateData['c_created_by'], $updateData['c_created_date']);
$operationId = (string) Str::ulid();
/** @var Operation|null $operation */
$operation = null;
$newArray = [];
try {
DB::transaction(function () use ($personId, $targetPk, $updateData, $originalArray, $comment, $operationId, &$operation, &$newArray) {
// 更新資料表(子類 performUpdate() 可能在 PK 衝突時拋出 InvalidArgumentException)
$this->performUpdate($targetPk, $updateData);
// 讀回更新後的資料
$updatedRow = $this->findUpdatedRow($targetPk, $updateData);
$newArray = $this->auditLogService->normalizeRow($updatedRow);
// 計算新 PK
$newPk = $this->buildNewPk($targetPk, $updateData);
$resourceId = CompositePrimaryKey::buildStoredResourceId($newPk);
// 寫 operation
$resourceData = array_merge($newArray, ['__operation_id' => $operationId]);
if ($comment !== '') {
$resourceData['__note'] = $comment;
}
$operation = $this->operationRepository->store(
Auth::id(),
$personId,
Operation::TYPE_UPDATE,
$this->tableName(),
$resourceId,
$resourceData,
$originalArray
);
// 寫 audit_log
$this->auditLogService->write(
$this->tableName(),
'UPDATE',
$newPk,
$originalArray,
$newArray,
'user',
(string) Auth::id(),
$operation ? (string) $operation->id : null
);
// #66:把「正向編輯前的舊值」暫存,供子類 afterDirectUpdate 的鏡像衝突偵測作「真分歧」基準。
$this->directForwardOld = $originalArray;
// 子類在同一交易內的後續處理(例如任官地址副表同步),保證原子性
$this->afterDirectUpdate($personId, $targetPk, $updateData, $newArray, $operation);
});
} catch (\InvalidArgumentException $e) {
// performUpdate() 明確拋出的 PK 衝突(如 AltnameMutationHandler、AddressMutationHandler)
return $this->errorResponse($e->getMessage(), 409, ['target.pk' => ['conflict']]);
} catch (\Illuminate\Database\QueryException $e) {
// 資料庫唯一性約束衝突(未覆寫 performUpdate() 的 handler,PK 欄位更新時由 DB 層報錯)
if ($this->isUniqueConstraintViolation($e)) {
return $this->errorResponse('資料更新導致主鍵衝突', 409, ['target.pk' => ['conflict']]);
}
throw $e;
} catch (MirrorConflictException $e) {
// #66:對面鏡像列已有不同內容 → 整筆交易已回滾(含正向列),回 409 + 衝突明細 + 對面鏡像 PK,
// 供前端彈警告 + 可點連結跳對面 edit-v2 + 提供「強制覆寫」(meta.force) 重送。
return $this->errorResponse($e->getMessage(), 409, [
'mirror_conflict' => [
'table' => $e->mirrorTable,
'pk' => $e->mirrorPk,
'fields' => $e->conflicts,
],
]);
} catch (MirrorIntegrityException $e) {
// #70:鏡像同步資料完整性 fail-closed(缺配對碼/無權威反向碼可收斂)→ 整筆已回滾,回結構化 422,
// 而非裸 RuntimeException 漏成 500。
return $this->errorResponse($e->getMessage(), 422, ['mirror_integrity' => ['fail_closed']]);
} catch (MirrorSuspectedException $e) {
// #70:嚴格定位(碼∈合法反向集)落空、但放寬查到對面有疑似同一關係的列(碼已漂移)→ 整筆已回滾,
// 回 409 + 疑似列 PK 清單 + 權威反向碼,供前端彈「對面無匹配反向碼/N 條疑似」警告 + 跳對面連結 + 強制收斂。
return $this->errorResponse($e->getMessage(), 409, [
'mirror_suspected' => [
'table' => $e->mirrorTable,
'candidates' => $e->candidates,
'authoritative_code' => $e->authoritativeCode,
'count' => $e->count(),
],
]);
}
return response()->json([
'ok' => true,
'resource' => $this->resourceName(),
'mode' => 'direct',
'operation' => 'update',
'result' => [
'pk' => $this->buildNewPk($targetPk, $updateData),
// updated_fields 只反映使用者實際變更,排除自動蓋的稽核欄(c_modified_*);
// 刷新後的稽核欄由 result.row 提供給前端。
'updated_fields' => array_values(array_diff(array_keys($updateData), ['c_modified_by', 'c_modified_date'])),
'operation_id' => $operation?->id,
'row' => $newArray,
],
]);
}
/**
* 改鍵後的新 PK 是否與**另一**既有列「異體字歸一後相同」。
*
* 回傳該衝突列,或 null(沒衝突/候選列就是正在編輯的這一列)。
* 排除自己是必要的:例如 `BIOG_SOURCE_DATA` 只改 `c_pages` 時,其餘 PK 欄與原列
* 完全相同,`VariantEquivalentLookup` 會把原列自己撈回來,誤報 409。
*
* @param array<string,mixed> $targetPk
* @param array<string,mixed> $updateData
*/
protected function findVariantEquivalentPkConflict(array $targetPk, array $updateData, array $originalArray) {
$newPk = $this->buildNewPk($targetPk, $updateData);
// 排除「正在編輯的那一列」交給 lookup 內部處理,**不能**拿回傳值在外面判斷:
// 候選集可以有多列同時歸一成同一個值(`愼齋` 與 `慬齋` 都歸一成 `慎齋`),
// 外部判斷只看得到第一筆,是自己就誤判成沒衝突,真正衝突的另一列被漏掉。
//
// 排除用的 PK 取自 $originalArray(**實際命中那一列的值**)而不是 $targetPk:
// payload 的 PK 可能帶哨兵別名(sources 的 c_textid=-999 實際落庫是 0),
// 拿別名比會把原列自己當成別列而回假 409。
$selfPk = array_intersect_key($originalArray, array_flip($this->keyColumns()));
// **只在真的改鍵時才檢查**。既有資料可能早就有兩列歸一後相同(D6 不做回溯校正),
// 此時使用者只改某列的 c_notes 這種非 PK 欄是**合法操作**:它既沒有新增、也沒有
// 把任何列改造成語義重複。無條件檢查會把那些歷史資料變成「任何更新都做不了」。
if (!$this->pkDiffers($newPk, $selfPk)) {
return null;
}
return VariantEquivalentLookup::findExistingRow(
$this->tableName(),
$this->keyColumns(),
$newPk,
[$selfPk]
);
}
/**
* 兩組主鍵值是否不同(逐欄字串比對,與其他 PK 比對處一致)。
*
* @param array<string,mixed> $left
* @param array<string,mixed> $right
*/
protected function pkDiffers(array $left, array $right): bool {
foreach ($this->keyColumns() as $column) {
if ((string) ($left[$column] ?? '') !== (string) ($right[$column] ?? '')) {
return true;
}
}
return false;
}
// ── Proposal Update ──────────────────────────────────────
protected function handleProposal(int $personId, array $targetPk, array $updateData, array $originalArray, string $comment): JsonResponse {
$newPk = $this->buildNewPk($targetPk, $updateData);
$resourceId = CompositePrimaryKey::buildStoredResourceId($newPk);
// 檢查 1:若 PK 欄位有變動,確認新 PK 對應的記錄不已存在
$pkChanged = false;
foreach ($this->keyColumns() as $col) {
if ((string) ($newPk[$col]) !== (string) ($targetPk[$col] ?? '')) {
$pkChanged = true;
break;
}
}
if ($pkChanged && $this->findOriginalRow($newPk) !== null) {
return $this->errorResponse('目標主鍵已存在,無法建立提案', 409, [
'target.pk' => ['conflict'],
]);
}
// 檢查 2:相同 resource_id 不得已有待審核的更新提案
if ($this->operationRepository->hasPendingUpdateProposal($this->tableName(), $resourceId)) {
return $this->errorResponse('相同主鍵已有待審核提案', 409, [
'target.pk' => ['pending_proposal_exists'],
]);
}
$proposalData = array_merge($originalArray, $updateData, [
'__proposal_meta' => [
'action' => 'update',
'resource_type' => $this->resourceName(),
'table' => $this->tableName(),
'display_name' => $this->displayName(),
'submitted_by' => Auth::user()->name ?? Auth::id(),
'submitted_by_id' => Auth::id(),
'submitted_at' => Carbon::now()->format('Y-m-d H:i:s'),
'comment' => $comment,
],
'__review_status' => 'pending',
'__key_columns' => $this->keyColumns(),
]);
// 子類可附加副表提案資料(例如任官地址 c_addr,核准時由 applyOfficeProposal 套用)
$auxiliaryPayload = $this->proposalAuxiliaryPayload();
if ($auxiliaryPayload !== []) {
$proposalData['__proposal_aux'] = $auxiliaryPayload;
}
$operation = $this->operationRepository->store(
Auth::id(),
$personId,
Operation::TYPE_PROPOSAL_UPDATE,
$this->tableName(),
$resourceId,
$proposalData,
$originalArray
);
return response()->json([
'ok' => true,
'resource' => $this->resourceName(),
'mode' => 'proposal',
'operation' => 'update',
'result' => [
'pk' => $newPk,
'updated_fields' => array_keys($updateData),
'status' => 'proposal_updated',
'operation_id' => $operation?->id,
],
]);
}
// ── 可覆寫的 helper ──────────────────────────────────────
/** 查詢原始記錄(子類可覆寫以處理特殊查詢邏輯) */
protected function findOriginalRow(array $pk): ?object {
$query = DB::table($this->tableName());
foreach ($this->keyColumns() as $col) {
$query->where($col, $pk[$col] ?? null);
}
return $query->first();
}
/** 執行資料表更新 */
protected function performUpdate(array $targetPk, array $updateData): void {
$query = DB::table($this->tableName());
foreach ($this->keyColumns() as $col) {
$query->where($col, $targetPk[$col] ?? null);
}
$query->update($updateData);
}
/** 讀回更新後的記錄(考慮 PK 可能因更新而改變) */
protected function findUpdatedRow(array $targetPk, array $updateData): ?object {
$newPk = $this->buildNewPk($targetPk, $updateData);
$query = DB::table($this->tableName());
foreach ($this->keyColumns() as $col) {
$query->where($col, $newPk[$col] ?? null);
}
return $query->first();
}
/** 根據 targetPk 和 updateData 計算新 PK */
protected function buildNewPk(array $targetPk, array $updateData): array {
$newPk = [];
foreach ($this->keyColumns() as $col) {
$newPk[$col] = $updateData[$col] ?? $targetPk[$col];
}
return $newPk;
}
/** #66:本次 direct 更新「正向編輯前的舊值」(normalizeRow 後);handleDirect 於呼叫 afterDirectUpdate 前設定,供鏡像衝突偵測作真分歧基準。 */
protected array $directForwardOld = [];
/**
* direct 更新成功後、仍在同一交易內的後續處理鉤子(預設無動作)。
* 子類可覆寫以在原子交易內同步副表(例如任官 PostingMutationHandler 同步 POSTED_TO_ADDR_DATA)。
*/
protected function afterDirectUpdate(int $personId, array $targetPk, array $updateData, array $newArray, ?Operation $operation): void {
}
/**
* proposal 更新時附加的副表提案資料(預設空)。
* 子類可覆寫以把副表(例如任官地址 c_addr)寫入 __proposal_aux,核准時套用。
*/
protected function proposalAuxiliaryPayload(): array {
return [];
}
/** 欄位值驗證(預設無驗證,子類可覆寫) */
protected function validateFields(array $data): array {
return [];
}
/** 前處理更新資料(預設無處理,子類可覆寫) */
protected function preprocessUpdateData(array $data): array {
return $data;
}
/**
* 將指定欄位中的 -999 轉換為 '0'
*
* CBDB 前端編輯頁面以 -999 表示「未選擇」或「不適用」,
* 在存入資料庫前需統一轉換為 '0'。
*
* @param array $data 待處理的資料陣列
* @param array $fields 需要轉換的欄位名稱列表
*/
protected function normalizeSentinelValues(array $data, array $fields): array {
foreach ($fields as $field) {
if (array_key_exists($field, $data) && ($data[$field] === -999 || $data[$field] === '-999')) {
$data[$field] = '0';
}
}
return $data;
}
/**
* 碼/FK 欄「完全規範化」:把所有空表示(null / '' / -999)一律→'0'(等同 emptyToSentinel)。
*
* 用於「legacy 哨兵語義 0=Unknown」的非 PK 碼/FK 欄(如 c_source):DDL 雖為 nullable,但 legacy 資料流
* 一律把空碼落 0(見 BasicInformationPossessionController `?? '0'`、Entry `emptyToSentinel`),故 v2 對齊=
* 達成 sentinel 完全幂等:0 / null / '' / -999 落庫皆為 0、永不寫 null/''、來回重送不翻,與「表單送 0」的 legacy 一致。
* normalizeSentinelValues 只做 -999→0(漏 null/''),故對這類欄需改用本法(前端中介層常把空欄轉 null,送 null 是真實場景)。
*
* @param array $data 待處理資料
* @param array $fields 需完全規範化的碼/FK 欄名(限 legacy 哨兵 0=Unknown 語義者;真正 nullable FK(如 basic_info 空→null)勿用)
*/
protected function normalizeEmptyCodeFields(array $data, array $fields): array {
foreach ($fields as $field) {
if (array_key_exists($field, $data)) {
$v = $data[$field];
if ($v === null || $v === '' || (int) $v === -999) {
$data[$field] = '0';
}
}
}
return $data;
}
/** 判斷是否有實際有效變更 */
protected function hasEffectiveChanges(array $originalArray, array $updateData): bool {
foreach ($updateData as $field => $value) {
$originalValue = $originalArray[$field] ?? null;
// 統一以字串比對(處理 int/string 混合問題)
if ($this->normalizeForComparison($originalValue) !== $this->normalizeForComparison($value)) {
return true;
}
}
return false;
}
/** 統一值的比對格式 */
protected function normalizeForComparison($value): ?string {
if ($value === null) {
return null;
}
return (string) $value;
}
/**
* 判斷 QueryException 是否為唯一性約束衝突
*
* 涵蓋 MySQL(error code 1062)與 SQLite(error code 19 = SQLITE_CONSTRAINT)。
*/
private function isUniqueConstraintViolation(\Illuminate\Database\QueryException $e): bool {
$code = (int) ($e->errorInfo[1] ?? 0);
if (in_array($code, [1062, 19], true)) {
return true;
}
$msg = $e->getMessage();
return str_contains($msg, 'UNIQUE') || str_contains($msg, 'Duplicate entry');
}
}