-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbackend.ts
More file actions
2234 lines (2135 loc) · 119 KB
/
Copy pathbackend.ts
File metadata and controls
2234 lines (2135 loc) · 119 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
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import type {
ApproveInput, CartResult, MappedOption, RejectInput, MappingResponse, PairingResult, PlanCreated, PlanStatus, OrderSheet, StepStatus,
} from "@/domain/types";
import {
toChickenStoreContext, toContextNormalizationInput, toProfileNormalizationInput, 우리말들,
type ContextNormalizationInput, type ProfileNormalizationInput,
type CanonicalProfile, type ChickenStoreSessionContext,
} from "@/api/canonical";
import { KioBridgeError, clearSheets, type KioBridgeApi } from "@/api/client";
import { STEPS } from "@/domain/catalog";
import { 연동기록, 팀백엔드모드 } from "@/api/devlog";
import { 접근성설정 } from "@/api/a11y";
import { 돈 } from "@/i18n/apply";
import { 가격한도 } from "@/api/budget";
import { 개인정보동의 } from "@/api/consent";
import { CHICKEN_STORE_PHOTOS } from "@/assets/menuPhotos";
import { 입력출처 } from "@/api/inputsource";
import { 알레르기설정 } from "@/api/allergy";
import { 사람식별자 } from "@/api/person";
/**
* 팀 API 명세서의 경로와 1:1 로 맞춘 계층.
*
* 화면이 쓰는 것(KioBridgeApi)은 네 개의 굵은 동작이고, 실제 백엔드는 그보다
* 잘게 나뉘어 있다. 그 조립을 화면에 떠넘기면 화면이 백엔드 사정을 알게 되므로
* 여기서 끝낸다. 백엔드는 아래 Backend 인터페이스만 구현하면 되고,
* 화면 코드는 한 줄도 바뀌지 않는다.
*
* 붙이는 방법:
* // src/api/client.ts 의 마지막 줄만 바꾼다
* export const api = createApi(createHttpBackend("https://<서버>"));
*/
// ─── 백엔드가 구현할 것 — 명세서의 경로와 1:1 ────────────────────────────────
export interface Backend {
/** POST /api/v1/sessions */
createSession(input: { environmentId: string; claimCode: string }): Promise<{
sessionId: string;
kioskName: string;
expiresAt: number;
/**
* 이 세션이 실제로 붙은 키오스크. 카탈로그를 정하는 값이다.
* 서버가 알려 주면 이후 단계에서 그 값을 쓴다 — 화면이 보낸 값을 다시 믿지 않는다.
*/
environmentId?: string;
}>;
/**
* POST /api/v1/candidate-filters — severity=BLOCK 위반 후보를 제외하고 생존 후보 반환
*
* profile 은 선택 항목이다. 서버가 주문표를 id 로 찾아 줄 수 있으면 필요 없지만,
* 팀 백엔드는 주문표 저장소가 없어서 내용을 그대로 받아야 한다.
*/
filterCandidates(input: { environmentId: string; profileId: string; profile?: OrderSheet }): Promise<{
survivingCandidateIds: string[];
/**
* menuName 은 품절·차단처럼 **display 에 못 실리는 후보**의 이름이다.
* 그런 후보는 이름찾기() 가 못 찾아서, 문장에 이름을 섞으면 영어 화면에서
* 통째로 못 옮긴다(#101 리뷰). 문장은 옮길 수 있는 고정문으로 두고
* 이름은 여기로 따로 나른다.
*/
excluded: { candidateId: string; reasonCode: string; explanation: string; menuName?: string }[];
/** 후보 표시 정보. 서버가 이름·가격을 함께 주면 여기 실린다. */
display?: Record<string, { displayName: string; priceText: string; price?: number; imageUrl?: string }>;
}>;
/** POST /api/v1/recommendations — 1순위 추천·이유·대안·제외 사유·확신도 */
recommend(input: {
environmentId: string;
/** 백엔드가 이 값을 profileId 라 부른다. 앱 쪽 이름(sheetId)은 경계에서 여기로 맞춘다. */
profileId: string;
survivingCandidateIds: string[];
profile?: OrderSheet;
}): Promise<RecommendationResult>;
/**
* POST /internal/simulation/pairing/bind — 이 연결에 쓸 주문 입력을 고정한다(팀 #108).
*
* 매핑에 쓴 정규화 결과를 서버가 pairing 에 붙들어 둔다. 승인할 때 같은 값이
* 아니면 서버가 거절한다 — 화면이 보여 준 조건과 실제로 실행되는 조건이
* 갈라지는 길을 막는 자리다.
*
* 선택 메서드로 둔다. 목(mockApi)에는 이 개념이 없고, 이 경로가 없는 옛
* 백엔드에서도 앱이 멈추면 안 된다.
*/
bindPairing?(input: { pairingId: string; profileId: string }): Promise<void>;
/** POST /api/v1/sessions/:sessionId/submission — 검증 X, 저장만 */
submit(sessionId: string, submission: unknown): Promise<void>;
/** POST /api/v1/sessions/:sessionId/validate */
validate(sessionId: string): Promise<{ valid: boolean; errors?: string[] }>;
/** POST /api/v1/sessions/:sessionId/execute — 검증 통과한 계획만 실행 */
execute(sessionId: string): Promise<{ planId: string }>;
/** GET /internal/simulation/evidence/{sessionId} */
getEvidence(sessionId: string): Promise<EvidenceSummary>;
/**
* 사용자가 승인하지 않겠다고 한 것을 서버에 남긴다.
*
* 킷 계약의 UserDecision.decision 이 APPROVE·REJECT·MODIFY 셋이다.
* 거절은 빈 실행계획으로 제출된다 — 검증까지만 가고 실행은 건너뛴다.
* 구현하지 않으면 화면은 그냥 되돌아간다. 사람을 붙잡지는 않는다.
*/
reject?(sessionId: string, payload: unknown): Promise<void>;
/**
* 이 세션에 남은 사용자 정보를 서버에서 지운다.
*
* 명세에 아직 경로가 없다. 자리를 비워 두면 백엔드 팀이 이 요구를 모른 채
* 가게 되므로 선택 메서드로 미리 만들어 둔다. 화면의 '이 기기에서 정보
* 지우기' 가 서버까지 닿으려면 이게 있어야 한다.
* 구현하지 않으면 이 계층이 들고 있는 것만 지워진다.
*/
forgetSession?(sessionId: string): Promise<void>;
}
/** recommendations 응답 중 화면이 쓰는 부분. */
export interface RecommendationResult {
recommendedCandidateId: string | null;
alternativeCandidateIds: string[];
// menuName 은 후보 필터가 실어 준 이름이다(Backend.filterCandidates 주석).
excludedCandidates: { candidateId: string; reasonCode: string; explanation: string; menuName?: string }[];
recommendationReasons: string[];
/**
* 서버가 맞추지 못한 조건.
*
* 제외 사유(excludedCandidates)와 다르다. 저건 "이 메뉴를 뺐다" 이고 이건
* "당신이 고른 조건 중 이건 못 맞췄다" 다. 담기는 담는데 한 축이 어긋난
* 경우가 여기 온다 - "선호하신 뼈/순살과 다릅니다".
*/
unmetConditions: string[];
confidence: number;
requiresReconfirmation: boolean;
/** 후보 표시 정보. 상품 ID 가 아니라 사람이 읽는 값이어야 한다. */
/**
* 후보를 화면에 보여 줄 값.
*
* priceText 는 이미 "8,000원" 으로 적어 둔 글자다. 화면이 그대로 쓰면 되지만
* **계산은 못 한다.** 한 개 값 한도를 넘는지 보려면 수량과 곱해야 하고, 그러려면
* 숫자가 있어야 한다. 글자에서 숫자를 도로 뽑아내는 것보다 둘 다 싣는 편이 낫다 —
* 글자를 파싱하면 통화 표기가 바뀌는 날 조용히 틀린다.
*/
display: Record<string, { displayName: string; priceText: string; price?: number; imageUrl?: string }>;
/** 사용자가 고른 조건이 반영됐는지 항목별로. 1순위 추천 기준이다. */
matchedOptions: { label: string; value: string; matched: boolean; note?: string }[];
/**
* 후보별로 어긋나는 축의 이름. 예: { "candidate-beta": ["형태"] }
*
* matchedOptions 는 1순위 하나에 대한 답이라, 대안 후보를 고른 사용자에게는
* 쓸 수 없다. 그걸 그대로 쓰면 '매운 뼈' 를 고른 사람에게
* "형태: 순살, 그대로예요" 라고 말하게 된다.
*
* 없으면 화면이 후보별 불일치를 표시하지 않는다. 짐작하지 않는다.
*/
unmatchedLabelsByCandidate?: Record<string, string[]>;
/**
* 서버가 점수를 매길 때 이 후보를 **밀어 준** 축들. 숫자는 안 싣는다.
*
* scoreBreakdown 을 그대로 띄우지 않고 양수인 칸의 이름만 남긴 것이다.
* 이유 문장(recommendationReasons)이 빠뜨리는 것이 여기 남는다 — 가격 한도를
* 정해도 서버의 이유 문장에는 가격 얘기가 한 줄도 안 나온다(실측).
*/
scoredAxes: string[];
}
/** evidence 중 화면이 쓰는 부분. 39개 필드 전부를 화면이 알 필요는 없다. */
export interface EvidenceSummary {
/** running | cart_ready | aborted 로 정규화해서 준다. */
state: "running" | "cart_ready" | "aborted";
reachedStep: number;
cart?: CartResult;
abort?: { code: string; title: string; message: string; userAction: string };
/**
* 서버가 증거를 읽어 만든 한 문장. 결과 화면에서 "왜 이걸 담았는지" 로 쓴다.
* 없으면 화면이 그 줄을 아예 그리지 않는다 — 지어내지 않는다.
*/
note?: string;
/**
* 서버가 매긴 상태 문장(#48 의 summary.status). 그대로 인용해서 보여 준다.
*
* 앱 문구로 옮기지 않는다. 이 줄의 쓸모가 "이 값이 서버에서 왔다" 를 보이는
* 것이라, 우리 말로 바꾸는 순간 서버가 준 것인지 앱이 지어낸 것인지 다시
* 구분할 수 없어진다. 문체가 다른 것은 인용이라고 밝혀서 푼다.
*/
serverStatus?: string;
/**
* 키오스크가 실제로 한 일을 순서대로 (#71 의 runSteps).
*
* 위의 reachedStep 은 '몇 번째까지 갔나' 뿐이라 무엇을 했는지는 모른다.
* 서버가 안 주면 없다 — 화면이 지어내지 않는다.
*/
한일?: { text: string; ok: boolean }[];
}
// ─── 확신도 경계 ─────────────────────────────────────────────────────────────
// 심사 필수 기준: "신뢰도 낮을 때 사용자 재확인 수행".
// 서버가 requiresReconfirmation 을 켜 주면 그걸 따르고, 안 켜 줘도
// 확신도가 이 값 아래면 화면이 스스로 재확인을 요구한다. 낮은 확신을
// 조용히 통과시키는 것보다 한 번 더 묻는 쪽이 안전하다.
export const LOW_CONFIDENCE = 0.7;
// ─── 조립 — 화면이 쓰는 네 동작을 위 호출들로 만든다 ──────────────────────────
/**
* 화면이 쓰는 API 를 백엔드 위에 조립한다.
*
* getSheet 은 주문표 id 로 실제 내용을 찾아 주는 함수다. 팀 백엔드는 주문표
* 저장소가 없어서 매번 내용을 함께 보내야 하고, 그 내용은 화면이 들고 있다.
* 서버가 id 로 찾아 줄 수 있게 되면 이 인자는 빼면 된다.
*/
export function createApi(
backend: Backend,
environmentId = "chicken-store",
getSheet?: (sheetId: string) => OrderSheet | undefined,
): KioBridgeApi {
// 세션 하나에 대해 서버가 뭐라고 답했는지. 승인 검사와 실행 조회의 기준이 된다.
// 페어링 만료 시각. 승인 때 끝난 연결인지 다시 보려면 필요하다.
const 만료 = new Map<string, number>();
/**
* 이미 다 쓴 연결. 승인·거절이 한 번 나가면 여기 들어온다(팀 #108).
*
* pairingId 는 일회용이다 — 서버가 승인 요청에서 그 값을 소모하므로, 성공했든
* 검증에 막혔든 그 뒤의 재시도는 전부 거절당한다. 화면이 "다시 시도" 를 내밀지
* 않도록 여기서 먼저 막고, QR 을 다시 찍으라고 말한다.
*/
const 연결끝남 = new Set<string>();
// 세션이 실제로 붙은 키오스크. 서버가 알려 주면 environmentId 인자 대신 이걸 쓴다.
const 환경 = new Map<string, string>();
/**
* 이 계층이 매핑과 승인 사이에 들고 있는 것.
*
* P0-1 은 상품 ID 를 앱이 다루거나 저장하지 말라고 한다. 화면(App.tsx)은
* 실제로 c1·c2·c3 과 사람이 읽는 값만 받는다 — 위 테스트가 응답 전체를
* 훑어서 잠가 두었다.
*
* 다만 이 Map 은 서버가 준 rec 를 그대로 들고 있고, 그 안에 서버의 후보
* 식별자가 들어 있다. 이 계층도 브라우저에서 돈다. 승인할 때 "사용자가 고른
* 그 후보" 를 서버에 되돌려 줘야 하는데, 지금 API 가 후보를 식별자로만
* 받기 때문이다.
*
* 없애려면 서버가 세션 안에서 후보를 기억하고 c1·c2·c3 같은 표식을 직접
* 발급해야 한다. docs/BACKEND_INTEGRATION.md 에 요청으로 적어 두었다.
* 그때 이 Map 은 expiresAt·result 만 남는다.
*/
const 세션 = new Map<string, {
rec: RecommendationResult;
result: MappingResponse["result"];
sheetId: string;
expiresAt: number;
executed?: boolean;
/*
* 거절한 세션. 지우지 않고 표시만 남긴다.
*
* 예전에는 reject 가 세션을 지웠다. 그러면 forgetAll 이 세션 목록으로
* 지울 대상을 찾을 때 이 페어링이 이미 없어서, 거절까지 갔던 사람의
* 정규화된 주문표(고른 알레르기·맵기 전부)가 메모리에 그대로 남았다.
* '이 기기에서 정보 지우기' 가 약속한 일이 실제로 안 일어난 것이다.
*/
rejected?: boolean;
}>();
const 판정 = (r: RecommendationResult): MappingResponse["result"] => {
if (!r.recommendedCandidateId) return "not_found";
if (r.alternativeCandidateIds.length > 0 && r.requiresReconfirmation) return "clarification";
// 못 맞춘 조건이 있으면 그걸 먼저 알린다. 확신도가 낮다고 low_confidence 로
// 먼저 빠지면, 확신도 낮고 조건도 못 맞춘 경우에 "무엇을 못 맞췄는지" 가
// 화면에서 사라진다. 가장 조심해야 할 상황에서 정보가 가장 적어진다.
// low_confidence 화면도 이제 확인 카드를 그리므로 changed 를 먼저 봐도 잃는 게 없다.
if (r.matchedOptions.some((o) => !o.matched)) return "changed";
if (r.requiresReconfirmation || r.confidence < LOW_CONFIDENCE) return "low_confidence";
return "exact";
};
return {
async claimPairing(claimCode) {
const s = await backend.createSession({ environmentId, claimCode });
만료.set(s.sessionId, s.expiresAt);
// 카탈로그는 QR 로 연결한 키오스크가 정한다. 서버가 알려 주면 그걸 쓴다.
// 예전에는 "chicken-store" 로 고정이라 병원에 붙어도 닭강정을 봤다.
환경.set(s.sessionId, s.environmentId ?? environmentId);
const out: PairingResult = { pairingId: s.sessionId, kioskName: s.kioskName, expiresAt: s.expiresAt };
return out;
},
async requestMapping(pairingId, sheetId) {
/*
* 이미 다 쓴 연결이면 여기서 먼저 접는다(팀 #146).
*
* 승인이든 거절이든 서버는 끝난 pairing 을 폐기한다(OrchestratorController
* 의 finally 에서 close). 그래서 취소하고 나온 뒤 다른 주문표로 들어가면
* 죽은 값으로 bind 를 시도하게 되고, 서버가 돌려주는 PAIRING_NOT_FOUND 가
* **"연결 정보를 찾을 수 없습니다"** 라는 개발자 말로 화면에 그대로 떴다.
* 무엇이 잘못됐는지도, 무엇을 해야 하는지도 알 수 없는 안내다.
*
* 승인 경로(approve)에는 이 검사가 이미 있었는데 매핑 경로에는 없었다.
* 화면은 매핑부터 부르므로, 정작 사용자가 먼저 닿는 쪽이 안 막혀 있었다.
*
* 되돌릴 수 있는 오류로 던진다(true) — 화면이 QR 다시 찍기로 안내한다.
*/
if (연결끝남.has(pairingId)) {
throw new KioBridgeError("CLAIM_EXPIRED", "이 연결은 이미 사용했어요. QR 을 다시 찍어 주세요", true);
}
// 서버에 주문표 저장소가 없으면 내용을 함께 보내야 한다. 있으면 id 만으로 충분하다.
const profile = getSheet?.(sheetId);
const env = 환경.get(pairingId) ?? environmentId;
const filtered = await backend.filterCandidates({ environmentId: env, profileId: sheetId, ...(profile ? { profile } : {}) });
const rec = await backend.recommend({
environmentId: env, profileId: sheetId, survivingCandidateIds: filtered.survivingCandidateIds,
...(profile ? { profile } : {}),
});
// 이름·가격을 추천이 안 주면 후보 필터가 준 것으로 채운다.
// 둘 다 없으면 화면에 보여 줄 게 없어서 그 후보는 빠진다.
if (filtered.display) {
rec.display = { ...filtered.display, ...rec.display };
}
const result = 판정(rec);
세션.set(pairingId, {
rec, result, sheetId,
// 페어링을 안 거치고 바로 매핑을 부르는 경우는 없어야 하지만,
// 없으면 만료를 알 수 없으므로 0 으로 두어 승인에서 막힌다.
expiresAt: 만료.get(pairingId) ?? 0,
});
/*
* 이 연결에 쓸 주문 입력을 서버에 고정한다(팀 #108).
*
* 정규화 결과는 백엔드 계층이 쥐고 있어서 그쪽에 맡긴다(createTeamBackend
* 의 bindPairing). 승인할 때 서버가 bind 당시 값과 같은지 비교하므로,
* 매핑에 쓴 것과 같은 값이어야 한다 — 같은 캐시를 쓰는 이유다.
*
* 실패하면 여기서 멈춘다.
*
* 처음에는 삼켰다. "화면은 이미 추천을 받았으니 보여 주고, 막히면 승인
* 시점에 말하면 된다" 고 적어 두었는데, 그 자리가 가장 나쁜 자리였다.
* 서버의 reserveForExecution 은 고정 안 된 연결을 PAIRING_INPUT_NOT_BOUND
* 로 거절하고, 승인이 한 번 실패하면 이 연결은 끝난 것이 된다(연결끝남).
* 즉 삼키면 사용자는 추천을 다 읽고 승인까지 누른 뒤에 QR 부터 다시
* 찍으라는 말을 듣는다 — 되돌릴 수 없는 자리에서 처음 알게 된다.
*
* 지금 던지면 연결은 아직 살아 있다. bind 는 실패해도 pairing 을 소모하지
* 않고 같은 입력이면 멱등해서(PairingRegistry.bindInput), 다시 시도가
* 실제로 통한다. 만료·소실이면 서버가 recoverable=false 로 알려 준다.
*/
/*
* profile 이 있는지는 보지 않는다.
*
* 그건 **백엔드 사정**이다. 팀 백엔드는 주문표 저장소가 없어서 내용을 함께
* 받아야 하지만(위 getSheet 주석), 서버가 id 로 찾아 주는 구현은 그럴 필요가
* 없다 — 그런 구현에게 "화면이 주문표를 들고 있지 않으니 고정도 건너뛴다"
* 고 정해 주는 것은 이 층이 할 말이 아니다.
*
* 여기서 걸러 버리면 그 구현은 매핑까지 다 해 놓고 pairing 만 안 고정된
* 채로 승인에 들어간다. 방금 없앤 것과 똑같은 조용한 구멍이 하나 더 생기는
* 셈이다. 무엇이 필요한지는 각 bindPairing 이 스스로 보고 못 하면 던진다.
*
* 열쇠도 sheetId 로 보낸다. 바로 위 filterCandidates·recommend 가 이미
* profileId 로 그 값을 쓰고 있어서, 여기만 profile.id 를 쓸 이유가 없다.
*/
if (backend.bindPairing) {
try {
await backend.bindPairing({ pairingId, profileId: sheetId });
} catch (e) {
// 고정 안 된 연결의 추천을 들고 있어 봐야 승인에서 막힌다.
// 지워 두면 다시 매핑할 때 bind 부터 새로 한다.
세션.delete(pairingId);
throw e;
}
}
// 무엇을 왜 뺐는지는 후보 필터와 추천 양쪽에서 온다. 둘 다 사용자에게 보여 준다.
//
// 같은 후보를 양쪽이 다 빼면 같은 문장이 두 번 나온다. 실제로 그랬다 —
// "[PEANUT] 알레르기와 겹쳐서 제외됐어요." 가 나란히 두 줄로 보였다.
// 사용자에게 같은 말을 두 번 하지 않는다.
const 본후보 = new Set<string>();
const 제외: typeof rec.excludedCandidates = [];
for (const e of [...filtered.excluded, ...rec.excludedCandidates]) {
if (본후보.has(e.candidateId)) continue;
본후보.add(e.candidateId);
제외.push(e);
}
/*
* 세 종류를 한 목록에 담는다. 화면이 제목을 붙여 나눠 보여 준다.
*
* used 이 조건을 써서 골랐다
* unmet 이 조건은 못 맞췄다 <- 담기는 담는데 한 축이 어긋남
* excluded 이 조건 때문에 메뉴를 뺐다
*
* unmet 을 버리고 있었다. 서버가 "선호하신 뼈/순살과 다릅니다" 라고 알려
* 주는데 타입에 선언만 해 두고 화면까지 오지 않았다. 못 맞춘 것을 감추면
* 사용자는 자기가 고른 조건이 다 반영된 줄 알고 승인한다.
*/
/*
* 세 종류를 한 목록에 담는다. 화면이 제목을 붙여 나눠 보여 준다.
*
* used 이 조건을 써서 골랐다
* unmet 이 조건은 못 맞췄다 <- 담기는 담는데 한 축이 어긋남
* excluded 이 조건 때문에 메뉴를 뺐다
*
* unmet 을 버리고 있었다. 서버가 "선호하신 뼈/순살과 다릅니다" 라고 알려
* 주는데 타입에 선언만 해 두고 화면까지 오지 않았다. 못 맞춘 것을 감추면
* 사용자는 자기가 고른 조건이 다 반영된 줄 알고 승인한다.
*
* 셋 다 서버가 준 문장이라 결제 표현 거르기를 여기서 한 번에 건다.
* 실서버 경로에만 걸면 목에서는 안 걸리는데, 화면으로 나가는 길은 여기
* 하나다. 막는 자리는 나가는 자리여야 한다.
*/
/*
* 서버 문장에 **무엇에 대한 얘기인지**를 붙인다.
*
* 서버는 "지금은 품절이라 제외됐어요" 라고만 한다. 어느 메뉴가 품절인지는
* 말해 주지 않는다. 후보가 여럿일 때 사용자는 무엇이 빠졌는지 알 수 없고,
* 화면은 이유를 보여 주면서 정작 대상을 감추는 셈이 된다.
*
* 추천 쪽도 같다. "선호하신 맵기와 맞는 메뉴라" 는 어느 맵기를 말하는지
* 안 밝힌다. 자기가 고른 값이 무엇이었는지는 사용자가 기억해야 했다.
*
* **서버 문장은 그대로 둔다.** 앞뒤에 우리가 아는 사실만 덧댄다 — 문장을
* 고쳐 쓰면 그건 인용이 아니라 우리가 지어낸 말이 된다.
*/
const 이름찾기 = (id: string): string | undefined => rec.display[id]?.displayName;
const 이름붙이기 = (id: string, 글: string): string => {
const 이름 = 이름찾기(id);
return 이름 ? `${이름} — ${글}` : 글;
};
/*
* 고른 값을 이유 문장에 붙이지 않는다.
*
* 예전에는 서버 문장에 축 이름이 들어 있으면("컵", "맵기") 그 축에서 사용자가
* 고른 값을 찾아 "(고르신 값: 종이컵)" 으로 이어 붙였다. 부분 문자열이라
* **선택 근거가 아닌 문장에도 붙었다** — 서버가 재고 안내에서 "컵" 을 한 번
* 쓰기만 해도, 사용자가 고른 컵이 추천 근거인 것처럼 화면에 떴다(#101 리뷰).
*
* 정확히 붙이려면 그 문장이 어느 축 얘기인지 알아야 하는데, 계약에 그 값이
* 없다. `recommendationReasons` 는 `string[]` 이고 킷 계약의 정의도
* "<사용자가 읽을 이유>" 라 축 식별자가 없다(킷 5.1.6 examples/ 의 표준 문장은
* "사용자 조건에 맞는 항목입니다." 로 축 단어가 아예 없다). 알려진 문장과
* 정확 일치로 바꾸는 길도 비교할 문장 표가 없어서 못 쓴다.
*
* 그래서 **짐작하지 않는다.** 이유 문장은 서버가 준 그대로 두고, 사용자가 고른
* 값은 조건표가 따로 보여 준다. 틀린 근거를 보여 주는 것보다 근거를 덜 보여
* 주는 쪽이 낫다 — 이 앱이 말 안 한 것을 고르지 않는 것과 같은 판단이다.
*
* 서버가 이유마다 축 식별자(킷의 SERVICE_TYPE·SPICY_LEVEL·BONE_TYPE·CUP)를
* 같이 주면 그때 다시 붙일 수 있다. RecommendationReason 의 고른값 칸이 그
* 자리다. docs/BACKEND_INTEGRATION.md 에 요청으로 적어 두었다.
*/
const reasons: MappingResponse["reasons"] = [
...rec.recommendationReasons.map((text) => ({
kind: "used" as const,
text: rec.recommendedCandidateId
? 이름붙이기(rec.recommendedCandidateId, text)
: text,
문장: text,
...(rec.recommendedCandidateId && 이름찾기(rec.recommendedCandidateId)
? { 메뉴: 이름찾기(rec.recommendedCandidateId) }
: {}),
})),
...rec.unmetConditions.map((text) => ({
kind: "unmet" as const,
text,
문장: text,
})),
/*
* 사람이 읽는 문장은 reasonText 다. explanation 은 규칙 추적용 문자열이라
* ("ruleId=..., sourceValue=[PEANUT]") 그대로 내보내면 화면에 규칙 식별자가
* 뜬다. 같은 파일의 사유문장() 이 이미 그 자리를 알고 있었는데 여기만
* explanation 을 읽고 있었다(#101 리뷰).
*
* reasonText 가 비면 그 줄을 아예 안 만든다. 빈 문장에 메뉴 이름만 붙이면
* "순살 닭강정 — " 이 되고, 이유를 말한다면서 아무 이유도 안 적는 셈이다.
*/
...제외
.map((e) => ({ e, 글: 뺀사유(e) }))
.filter(({ 글 }) => 글 !== "")
.map(({ e, 글 }) => {
// 품절·차단 후보는 display 에 없어서 이름찾기() 가 못 찾는다.
// 후보 필터가 menuName 으로 실어 준 이름이 그 빈자리를 채운다(#101 리뷰).
const 메뉴 = 이름찾기(e.candidateId) ?? e.menuName;
return {
kind: "excluded" as const,
text: 메뉴 ? `${메뉴} — ${글}` : 글,
문장: 글,
...(메뉴 ? { 메뉴 } : {}),
};
}),
].filter((r) => 보여도되나(r.text));
/*
* 서버가 점수를 매길 때 본 축들. 담을 것이 정해진 뒤에만 뜻이 있어서
* not_found 에는 싣지 않는다 — 고른 메뉴가 없는데 "이걸 보고 골랐어요" 는
* 말이 안 된다.
*/
const 점수본것 = rec.scoredAxes.length > 0 ? { scoredAxes: rec.scoredAxes } : {};
// 서버가 display 를 빠뜨리면 이름 없는 후보가 화면에 뜬다.
// 빈칸을 보여 주느니 그 후보를 빼는 게 낫다.
const 보이기 = (id: string) => rec.display[id];
const 보일수있나 = (id: string) => Boolean(rec.display[id]?.displayName);
const 고름 = rec.recommendedCandidateId ? 보이기(rec.recommendedCandidateId) : undefined;
if (result === "not_found") {
return { result, reasons, message: "담을 수 있는 메뉴가 없어요" };
}
if (result === "clarification") {
// 이름 없는 후보를 걸러내고 나면 하나도 안 남을 수 있다. 그대로 내보내면
// 화면은 "비슷한 메뉴가 여러 개예요" 라고 말하면서 고를 것을 하나도
// 못 보여 준다. 승인은 CANDIDATE_REQUIRED 를 요구하는데 고를 방법이 없으니
// 사용자는 그 화면에서 빠져나갈 수 없다. 담을 게 없다고 답한다.
const 보일후보 = [rec.recommendedCandidateId!, ...rec.alternativeCandidateIds].filter(보일수있나);
if (보일후보.length === 0) {
return { result: "not_found", reasons, message: "담을 수 있는 메뉴가 없어요" };
}
return {
result, reasons, ...점수본것,
reason: "비슷한 메뉴가 여러 개예요",
// 목에만 넣고 여기를 빼면, 실서버로 바꾸는 순간 조건표가 다시 통째로
// 사라진다. 사용자는 포장인지 종이컵인지 못 보고 승인하게 된다.
// '맞았는지' 는 판단하지 않는다 — 어느 후보를 고르느냐에 따라 달라진다.
// 서버가 준 matched 를 그대로 쓴다. 전부 true 로 덮으면
// 어느 후보를 고르든 안 맞는 축이 있다는 사실이 사라진다.
sheetOptions: rec.matchedOptions,
// 상품 ID 를 화면으로 내보내지 않는다. 이번 응답 안에서만 쓰는 표식으로 바꾼다.
candidates: 보일후보
.map((id, i) => ({
candidateId: `c${i + 1}`,
...보이기(id),
// 서버가 후보별 불일치를 알려 주면 그대로 싣는다. 안 주면 비워 둔다.
// 화면은 비어 있으면 아무것도 표시하지 않는다 — 이름을 뜯어보고
// 짐작하는 것보다 조용한 편이 낫다.
...(rec.unmatchedLabelsByCandidate?.[id]
? { unmatchedLabels: rec.unmatchedLabelsByCandidate[id] }
: {}),
})),
};
}
// 이름 없는 후보는 화면에 그릴 수 없다. item 없이 exact 를 보내면
// 사용자는 빈 화면 앞에서 승인 버튼을 누르게 된다. 담을 게 없다고 답한다.
if (!고름?.displayName) {
return { result: "not_found", reasons, message: "담을 수 있는 메뉴가 없어요" };
}
return {
result, reasons, ...점수본것,
...(result === "changed" ? { diffNote: "저장하신 주문과 달라진 점이 있어요. 이대로 진행할까요?" } : {}),
item: { ...고름, options: rec.matchedOptions },
};
},
// P0-4: 실행 계획은 이 안에서만 만들어진다.
async approve(input: ApproveInput): Promise<PlanCreated> {
const s = 세션.get(input.pairingId);
if (!s) throw new KioBridgeError("MAPPING_REQUIRED", "메뉴를 먼저 찾아야 해요", false);
// 아니라고 한 것을 뒤에서 되살리지 않는다. 담으려면 메뉴를 처음부터 다시 찾는다.
if (s.rejected) throw new KioBridgeError("MAPPING_REQUIRED", "메뉴를 먼저 찾아야 해요", false);
// 이미 쓴 연결이면 서버가 어차피 거절한다. 여기서 먼저, 사람 말로 막는다.
if (연결끝남.has(input.pairingId)) {
throw new KioBridgeError("CLAIM_EXPIRED", "이 연결은 이미 사용했어요. QR 을 다시 찍어 주세요", true);
}
// client.ts 와 같은 검사를 여기서도 한다. 한쪽만 막으면 구현을 바꿀 때 샌다.
if (s.sheetId !== input.sheetId) {
throw new KioBridgeError("PROFILE_MISMATCH", "메뉴를 다시 찾아 주세요", true);
}
if (s.expiresAt <= Date.now()) {
throw new KioBridgeError("CLAIM_EXPIRED", "연결 시간이 지났어요", true);
}
// 승인 조건은 서버가 답한 내용을 기준으로 본다. 클라이언트가 보낸 값을 믿지 않는다.
if (s.result === "not_found") throw new KioBridgeError("MENU_NOT_FOUND", "담을 수 있는 메뉴가 없어요", false);
if (s.result === "clarification" && !input.candidateId)
throw new KioBridgeError("CANDIDATE_REQUIRED", "메뉴를 선택해 주세요", true);
if (s.result === "changed" && !input.acknowledgedDiff)
throw new KioBridgeError("DIFF_NOT_ACKNOWLEDGED", "달라진 내용을 확인해 주세요", true);
if (s.result === "low_confidence" && !input.confirmedLowConfidence)
throw new KioBridgeError("CONFIRMATION_REQUIRED", "이 메뉴가 맞는지 확인해 주세요", true);
// 후보를 고르는 화면이 아닌데 candidateId 가 오면 무언가 어긋난 것이다.
// client.ts 는 이미 이 경우를 막는데 여기만 열려 있으면, 붙이는 구현을
// 바꾸는 것만으로 사용자가 고른 적 없는 메뉴가 담긴다.
if (input.candidateId && s.result !== "clarification") {
throw new KioBridgeError("CANDIDATE_UNEXPECTED", "메뉴를 다시 찾아 주세요", true);
}
// 사용자가 고른 표식(c1·c2·c3)을 서버가 아는 실제 후보로 되돌린다.
// 우리가 준 표식인지 반드시 확인한다. 예전에는 숫자로 바꾸기만 해서
// c99 는 undefined 를 제출했고, cabc·c0 는 조용히 1순위로 되돌아갔다.
// 사용자가 고르지 않은 메뉴가 담긴다는 뜻이다.
// 응답에서 걸러낸 후보(display 없음)를 빼고 센다.
// 화면이 본 c1·c2·c3 은 걸러진 뒤의 순서라, 여기서 원본 순서로 세면
// 사용자가 고르지 않은 메뉴가 제출된다.
const 후보목록 = [s.rec.recommendedCandidateId!, ...s.rec.alternativeCandidateIds]
.filter((id) => Boolean(s.rec.display[id]?.displayName));
let candidateId = s.rec.recommendedCandidateId;
if (input.candidateId) {
const m = /^c(\d+)$/.exec(input.candidateId);
const 순번 = m ? Number(m[1]) - 1 : -1;
if (순번 < 0 || 순번 >= 후보목록.length) {
throw new KioBridgeError("CANDIDATE_UNKNOWN", "선택한 메뉴를 찾을 수 없어요", true);
}
candidateId = 후보목록[순번];
}
// 실행은 세션당 한 번이다. 두 번 나가면 키오스크에 두 번 담긴다.
//
// 검사와 확정 사이에 await 가 있으면 안 된다. 예전에는 submit·validate 를
// 기다린 뒤에 검사해서, 동시에 들어온 승인 두 건이 모두 통과하고 둘 다
// 실행됐다. 사용자는 한 번 승인하고 두 개를 받는다.
// 첫 await 전에 표시하고, 실패하면 되돌린다.
if (s.executed) {
throw new KioBridgeError("ALREADY_APPROVED", "이미 담았어요", false);
}
s.executed = true;
let planId: string;
try {
// 제출 → 검증 → 실행. 어느 단계에서 멈췄는지 구분해서 알린다.
// 주문표 내용을 함께 넘긴다. 팀 백엔드는 주문표 저장소가 없어서
// 승인 때도 내용을 다시 받아야 제출물을 조립할 수 있다.
const profile = getSheet?.(input.sheetId);
await backend.submit(input.pairingId, { ...input, candidateId, ...(profile ? { profile } : {}) });
const v = await backend.validate(input.pairingId);
if (!v.valid) {
// 이유를 여러 줄 준 경우 전부 넘긴다. 하나만 보여 주면 그것을
// 고쳐도 또 막히고, 사용자는 왜 막히는지 끝까지 모른다.
throw new KioBridgeError(
"VALIDATION_FAILED",
v.errors?.[0] ?? "계획을 검증하지 못했어요",
false,
v.errors,
);
}
({ planId } = await backend.execute(input.pairingId));
} catch (e) {
/*
* 되돌리지 않는다 — pairingId 는 한 번 쓰면 끝이다(팀 #108).
*
* 예전에는 s.executed 를 false 로 되돌려 같은 연결로 다시 승인하게 했다.
* 지금은 승인 요청이 서버에서 pairing 을 소모하므로 그 재시도가 무조건
* 거절당한다 — 되돌려 두면 사용자는 눌러도 안 되는 버튼을 계속 누른다.
*
* 이 연결을 끝난 것으로 표시하고 넘긴다. 다시 누르면 위의 연결끝남 검사에
* 걸려서 "이 연결은 이미 사용했어요. QR 을 다시 찍어 주세요" 가 뜬다.
*
* (recoverable=false 를 같이 붙이지만, **지금 이 값을 읽는 화면은 없다.**
* 처음에 여기 "화면이 recoverable 을 보고 QR 로 되돌린다" 고 적어 두었는데
* 사실이 아니었다. 실제로 되돌리는 것은 위의 연결끝남 검사다.)
*/
연결끝남.add(input.pairingId);
if (e instanceof KioBridgeError) {
throw new KioBridgeError(e.code, e.message, false, e.details);
}
/*
* KioBridgeError 가 아닌 것은 그대로 올리지 않는다.
*
* 화면은 잡은 것을 KioBridgeError 로 보고 e.message 를 그대로 띄운다
* (App.tsx 의 approve). 그물을 빠져나온 것이 fetch 의 TypeError 면
* "Failed to fetch" 가, JSON 오류면 "Unexpected token '<'" 가 어르신
* 화면에 뜬다 — 부르기() 에서 이미 한 번 막아 둔 것과 같은 종류다.
*
* 여기서 사람 말로 바꾼다. 무엇이 터졌는지는 연동 기록에 남아 있다.
*/
throw new KioBridgeError(
"APPROVE_FAILED",
"주문을 담지 못했어요. QR 을 다시 찍어 주세요",
false,
);
}
// 성공해도 이 연결은 끝난다. 같은 pairingId 로 한 번 더 담을 수 없다.
연결끝남.add(input.pairingId);
// 실행 조회는 이 값을 기준으로 하므로 화면이 들고 다닐 값에 함께 실어 둔다.
return { planId: `${input.pairingId}::${planId}` };
},
/**
* 승인하지 않겠다는 결정을 서버에 남긴다.
*
* 실패해도 던지지 않는다. 사용자는 이미 "그만두겠다" 고 했고, 그 뒤에
* 오류 화면을 띄우면 나가려는 사람을 붙잡는 셈이다. 기록은 서버 사정이지
* 사용자가 감당할 일이 아니다.
*/
async reject(input) {
const s = 세션.get(input.pairingId);
// 매핑도 안 한 상태면 거절할 대상이 없다. 조용히 끝낸다.
if (!s || s.sheetId !== input.sheetId) return;
// 지우지 않고 표시만 한다. 지우면 forgetAll 이 이 페어링을 못 찾아서,
// 거절까지 갔던 사람의 정규화된 주문표가 '정보 지우기' 뒤에도 남는다.
s.rejected = true;
// 거절도 승인과 같은 경로로 나가 pairing 을 소모한다(팀 #108).
// 다시 담으려면 QR 부터 다시 찍어야 한다.
연결끝남.add(input.pairingId);
if (!backend.reject) return;
try {
const profile = getSheet?.(input.sheetId);
await backend.reject(input.pairingId, { ...input, ...(profile ? { profile } : {}) });
} catch {
// 기록에 실패해도 화면은 되돌아간다.
}
},
/**
* '이 기기에서 정보 지우기'.
*
* 이 계층이 들고 있는 것을 전부 비운다. 서버에 지우기 경로가 생기면 여기서
* 함께 부른다 — 지금은 백엔드에 그 경로가 없어서, 이미 올라간 주문표와
* 승인·거절 기록은 남는다. 그 사실은 개인정보 안내 화면이 그대로 말한다.
* 지운 척하지 않는 것이 여기서 할 수 있는 전부다.
*
* 붙인 구현이 들고 있는 것도 함께 비운다. 세션이 하나도 없을 때도 부른다 —
* 예전에는 세션 목록으로만 돌아서, 거절해서 세션이 비었거나 매핑 전에
* 지운 경우에 정규화된 주문표가 그대로 남았다.
*/
async forgetAll() {
const ids = [...세션.keys()];
세션.clear();
만료.clear();
환경.clear();
/*
* 다 쓴 연결 목록도 지운다. 이 PR 에서 만들어 놓고 여기 빠뜨렸다.
*
* pairingId 는 키오스크를 움직일 수 있는 열쇠다. '모두 지워요' 를 누른 뒤에도
* 이 계층이 그 값들을 들고 있으면 화면이 한 말이 사실이 아니게 된다 — 바로
* 위 세션·기록을 지우는 것과 같은 이유다. 안 지우면 쓸수록 늘기만 한다.
*
* 지워도 일회용 보장은 안 깨진다. 그건 서버가 한다(PairingRegistry) —
* 이 Set 은 서버가 어차피 거절할 요청을 사람 말로 먼저 막는 자리일 뿐이고,
* 어차피 세션도 방금 비웠으니 승인은 그 앞에서 걸린다.
*/
연결끝남.clear();
// 화면이 주문에 쓰라고 등록해 둔 주문표 사본. 여기 남으면 '모두 지워요' 가
// 사실이 아니다. 목(mockApi)은 이미 지우고 있었고 이 경로만 빠져 있었다.
clearSheets();
// 오간 기록에는 요청.응답 본문이 통째로 들어 있다 — 고른 조건도 거기 있다.
// 화면에서 지웠다고 말해 놓고 구석 패널에 그대로 남으면 그 말이 거짓이 된다.
연동기록.비우기();
if (backend.forgetSession) {
await Promise.all((ids.length > 0 ? ids : [""]).map((id) => backend.forgetSession!(id)));
}
},
async getPlanStatus(planId): Promise<PlanStatus> {
const sessionId = planId.split("::")[0];
const e = await backend.getEvidence(sessionId);
/*
* reachedStep 은 '실행한 동작 수' 이고 STEPS 는 다섯 칸이다. 단위가 다르다.
*
* 백엔드가 만드는 동작은 9~10개다(select_service · select_menu ·
* select_option x4 · confirm_option x2 · open_cart_review · verify_cart).
* 그래서 중단됐을 때 reachedStep 이 거의 항상 5 이상이 되고, 다섯 칸이
* 전부 i < reachedStep 에 걸려 **모두 '완료'** 로 칠해졌다.
*
* 화면 위쪽은 "안전을 위해 멈췄어요" 인데 아래 단계는 다 끝난 것처럼
* 보인다. 같은 화면이 정반대 말을 한다.
*
* 마지막 칸을 넘지 않게 자른다. 실패 칸이 적어도 제자리 근처에는 찍힌다.
* 정확한 자리는 서버가 준 실행 내역(한일)이 아래에서 그대로 보여 준다 -
* 개수가 맞는 것은 그쪽이라 그 자리에 맡긴다.
*/
const 멈춘칸 = Math.min(e.reachedStep, STEPS.length - 1);
const steps: StepStatus[] =
e.state === "aborted"
? STEPS.map((_, i) => (i < 멈춘칸 ? "done" : i === 멈춘칸 ? "failed" : "waiting"))
: e.state === "cart_ready"
? STEPS.map(() => "done")
: STEPS.map((_, i) => (i < e.reachedStep ? "done" : i === e.reachedStep ? "active" : "waiting"));
// 어디서 멈췄든 무엇까지 했는지는 알려 준다. 중단됐을 때가 오히려
// 더 알고 싶은 자리다 — 어디까지 갔는지 눈으로 봐야 다음을 정한다.
const 한일 = e.한일 ? { done: e.한일 } : {};
if (e.state === "aborted") {
return {
state: "aborted", steps, ...한일,
abort: { ...(e.abort ?? { code: "UNKNOWN", title: "안전을 위해 중단되었습니다", message: "예상하지 못한 화면이 감지되어 작동을 멈췄어요.", userAction: "직원 초기화를 기다려 주세요" }), recoverable: false },
// 중단됐을 때야말로 "이게 키오스크가 한 말이다" 를 보여 줄 자리다.
// 잘 된 경우에만 싣고 여기서 빠뜨리면, 정작 확인이 필요한 쪽이 비어 있다.
...(e.serverStatus ? { serverStatus: e.serverStatus } : {}),
};
}
if (e.state === "cart_ready") {
return {
state: "cart_ready", steps, cart: e.cart, ...한일,
...(e.note ? { note: e.note } : {}),
...(e.serverStatus ? { serverStatus: e.serverStatus } : {}),
};
}
return { state: "running", steps, ...한일 };
},
};
}
// ─── HTTP 구현 — 서버 주소만 넣으면 된다 ─────────────────────────────────────
/**
* 팀 백엔드가 실제로 구현한 경로에 맞춘 구현.
*
* 명세서와 다른 점이 있어서 그대로 옮기면 안 붙는다. 확인한 것:
*
* 명세서 실제 구현
* POST /api/v1/sessions POST /internal/simulation/session
* submission → validate → execute POST /internal/simulation/submit-and-run (일괄)
* POST /api/v1/candidate-filters 아직 없음
* POST /api/v1/recommendations 아직 없음
*
* 그래서 지금 붙일 수 있는 건 세션 생성과 실행뿐이다. 추천 계열이 생기면
* filterCandidates·recommend 만 채우면 된다.
*
* 기본 주소는 /api/bff 다. 이 앱의 서버 함수가 백엔드로 대신 보내 주므로
* 브라우저는 같은 출처로만 요청하고 CORS 가 발생하지 않는다.
* 백엔드 주소는 Vercel 환경변수 KIOBRIDGE_API_BASE 로 준다.
*
* 백엔드를 직접 부르고 싶으면 주소를 넘기면 된다. 그 경우에는
* kiobridge.cors.allowed-origin 을 이 앱 주소로 맞춰야 한다.
*/
/** 킷 드라이버가 verify 단계에서 만드는 읽기 전용 값. 환경마다 키가 다르다. */
interface ReviewSnapshot {
cartItems?: { name?: string; price?: number; quantity?: number }[];
total?: number;
[label: string]: unknown;
}
/**
* POST /internal/simulation/submit-and-run 응답.
* 백엔드 `ExecuteResult` 레코드와 같은 모양이다.
* 제출·검증·실행·증거가 이 한 응답에 전부 들어 있다.
*/
interface ExecuteResult {
valid: boolean;
validation?: { valid: boolean; errors?: { path?: string; code?: string; message: string }[] };
run?: { terminalState?: string; stopType?: string; stopReason?: string };
evidence?: {
runId?: string;
result?: "PASS" | "FAIL";
stopType?: "NORMAL_BOUNDARY_STOP" | "SAFETY_STOP" | "NONE";
stopReason?: string;
executedActions?: unknown[];
reviewSnapshot?: ReviewSnapshot;
};
}
/**
* POST /internal/orchestrator/approve 응답 (#48 이후).
*
* 예전에는 ExecuteResult 가 그대로 왔고, 지금은 한 겹 감싸여 온다.
* 기존: { valid, run, evidence, validation }
* 이후: { valid, summary, raw } ← raw 안에 위의 것이 그대로 있다
*
* 둘 다 받는다. #48 이 머지되기 전에도 뒤에도 같은 코드로 돈다.
*/
interface ApprovalResult {
valid: boolean;
/** 서버가 증거를 읽어 만든 한 줄 요약. 화면은 recommendation 만 쓴다. */
summary?: { status?: string; recommendation?: string; stopReason?: string };
/**
* 검증에 걸린 이유를 사람이 읽는 문장으로 옮긴 것 (#66).
*
* 서버가 킷 오류 코드(REQUIRED_FIELD_MISSING 등)를 표에서 찾아 바꿔 준다.
* 성공하면 빈 배열로 온다 — 있고 없고로 실패를 판단하면 안 된다. valid 를 본다.
*
* 선택 필드로 둔다. #66 이전 백엔드에는 이 필드가 아예 없고, 그때는
* 예전처럼 raw.validation.errors 의 message 를 쓴다.
*/
validationMessages?: string[];
/**
* 키오스크에서 실제로 한 동작을 순서대로 (#71).
*
* 지금까지 화면은 우리가 정해 둔 다섯 단계를 '실행한 동작 수' 로 채웠다.
* 개수만 보고 그린 것이라 몇 번째까지 갔는지만 맞고 무엇을 했는지는 몰랐다.
* 이건 서버가 실제로 한 일이다.
*/
runSteps?: RunStep[];
/**
* 내부 sessionId 를 걷어낸 최소 실행 결과 (팀 #108).
*
* 예전의 raw(ExecuteResult 통째)를 대신한다. 실제 sessionId 처럼 브라우저가
* 알 필요 없는 것이 빠지고, 화면이 결과를 그리는 데 쓰는 값만 온다.
* executedActions 배열 대신 개수만 오므로 진행도는 그 수로 센다.
*/
execution?: {
runId?: string;
result?: string;
stopType?: string;
stopReason?: string;
executedActionCount?: number;
reviewSnapshot?: ReviewSnapshot;
};
/** #108 이전 백엔드. 둘 다 받는다 — 옛 배포본에서도 앱이 돌아야 한다. */
raw?: ExecuteResult;
}
/** ApprovalResult.runSteps 의 한 칸. 킷의 ExecutedAction 을 추린 것이다. */
interface RunStep {
actionIndex: number;
/** select_option, confirm_option, open_cart_review ... */
action: string;
/**
* 킷의 resolvedLabel. 고른 값이면 사람 말이고("종이컵"),
* 화면 이동이면 코드다("CART_REVIEW"). 아래 한일한줄 참고.
*/
label: string;
success: boolean;
}
/*
* 한 동작을 사람이 읽는 한 줄로 옮긴다.
*
* label 을 그대로 쓸 수 없다. 규칙이 이렇게 갈린다.
*
* select_service 포장하기 <- 고른 값이라 사람 말
* select_menu 매운 뼈 닭강정
* select_option 매운맛 · 뼈 · 종이컵 · 1개
* confirm_option OPTION_CONFIRM <- 화면 이름이라 코드
* open_cart_review CART_REVIEW
* verify_cart CART_REVIEW
*
* 그래서 문장은 action 에서 만들고, label 은 사람 말일 때만 끼워 넣는다.
* 모르는 action 에 코드 label 만 있으면 무슨 일이 있었는지 지어내지 않는다.
*/
/*
* 고른 값을 담아 오는 동작들.
*
* 이 셋에서는 label 이 사용자가 실제로 고른 값이다. 대문자든 한글이든 그대로
* 보여 준다 - 모양으로 코드인지 아닌지 가리면 안 된다.
*
* 처음에는 모든 동작에 /^[A-Z][A-Z0-9_]*$/ 를 걸어 대문자면 코드로 봤다.
* 그러면 `ICE`·`HOT`·`Q1` 같은 **진짜 고른 값**이 코드로 몰려 "하나 골랐어요"
* 로 뭉개진다. 카페 주문표에는 이미 ICE 가 들어 있다.
*
* 사용자가 고른 것을 화면에서 지우는 셈이라, 대신 눌러 주는 앱에서 가장 하면
* 안 되는 쪽이다. 판별은 아래 아는화면 처럼 **자리를 아는 곳에서만** 한다.
*/
const 고른값을담는동작 = new Set(["select_service", "select_menu", "select_option"]);
/*
* 서버가 준 label 을 화면에 올려도 되는지.
*
* label 은 검증되지 않은 서버 입력이다. 여기를 그대로 통과시키면 서버가 무엇을
* 담아 보내든 결과 화면에 뜬다 - 상품 ID(candidate-alpha) · 화면 좌표 · 결제 문구
* 전부. 우리 실격 요건 둘을 정면으로 건드린다.
*
* 그런데 우리는 **사용자가 무엇을 골랐는지 이미 안다.** 주문표의 선택값과
* 화면에 띄우고 있는 후보 이름이 그것이다. 그 목록에 있는 값만 통과시킨다.
*
* 흰 목록이라 새는 길이 없다. 모르는 값이면 값을 지우고 동작만 말한다 -
* 무엇을 골랐는지 한 줄 잃지만, 지어낸 값이나 새면 안 되는 값을 띄우는 것보다 낫다.
*
* 앞서 ICE 를 살리려고 모양 검사를 없앴는데, 그때 '고른 값 자리는 안전하다' 고
* 전제했다. 그 전제에 근거가 없었다. 모양이 아니라 **아는 값인지**로 가른다.
*/
const 아는값인가 = (label: string, 아는값: Set<string>): boolean =>
아는값.has(label) && 보여도되나(label);
/*
* 주문표에 고른 값과 **이번에 담기로 한 메뉴 하나**의 이름.
*
* 후보 전체를 넣으면 안 된다. 대안 후보 이름까지 흰 목록에 들어가서, 서버가
* 사용자가 고르지 않은 후보 이름을 보내도 "OO 골랐어요" 로 뜬다. 고른 적 없는
* 메뉴를 골랐다고 말하는 셈이라, 대신 눌러 주는 앱에서 가장 나쁜 거짓말이다.
*
* 찾지 못하면 이름을 아예 넣지 않는다. 그때는 "하나 골랐어요" 로 물러난다.
*/
const 아는값모으기 = (
profile: OrderSheet | undefined,
고른후보: KitCandidate | undefined,
): Set<string> => {
const s = new Set<string>();
for (const 값들 of Object.values(profile?.selections ?? {})) {
for (const v of 값들) if (v) s.add(v);
}
if (고른후보?.name) s.add(고른후보.name);
return s;
};
/*
* 아는 화면 코드만 갈라 준다.
*
* confirm_option 이 두 번 온다 — 옵션을 확정할 때와 메뉴를 장바구니로 넘길 때다.
* action 만 보면 같은 문장이 두 줄 나란히 서서 앱이 헛돈 것처럼 읽힌다.
* 킷은 label 로 둘을 구분하고 있으니 그것만 받아 쓴다.
*
* 모르는 코드는 여기 없다. 그때는 아래 action 문장으로 물러난다 — 코드 이름을
* 보고 무슨 화면인지 짐작해서 지어내지 않는다.
*/
const 아는화면: Record<string, string> = {
OPTION_CONFIRM: "옵션을 확정했어요",
MENU_SELECTION_WITH_CART: "메뉴를 장바구니로 넘겼어요",
};
const 동작말 = (action: string, label: string, 아는값: Set<string>): string => {
// 고른 값 자리라도 우리가 아는 값일 때만 그대로 쓴다. ICE 는 주문표에 있으니
// "ICE 골랐어요" 가 되고, candidate-alpha 은 없으니 "하나 골랐어요" 가 된다.
if (고른값을담는동작.has(action)) {
return 아는값인가(label, 아는값) ? `${label} 골랐어요` : "하나 골랐어요";
}
if (아는화면[label]) return 아는화면[label];
switch (action) {
case "confirm_option":
return "고른 것을 확정했어요";
case "open_cart_review":
return "장바구니를 열었어요";
case "verify_cart":