@simplysm/sd-claude 14.0.84 → 14.0.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/claude/references/sd-simplysm14/README.md +7 -1
  2. package/claude/references/sd-simplysm14/manuals/client-app-structure.md +140 -0
  3. package/claude/references/sd-simplysm14/manuals/client-component.md +6 -5
  4. package/claude/references/sd-simplysm14/manuals/client-orm.md +62 -0
  5. package/claude/references/sd-simplysm14/manuals/client-service.md +96 -0
  6. package/claude/references/sd-simplysm14/manuals/client-shared-data.md +146 -0
  7. package/claude/references/sd-simplysm14/manuals/client-system-log.md +96 -0
  8. package/claude/references/sd-simplysm14/manuals/data-log.md +209 -0
  9. package/claude/references/sd-simplysm14/manuals/event.md +135 -0
  10. package/claude/rules/sd-design-rules.md +8 -0
  11. package/claude/sd-system-prompt.md +35 -14
  12. package/claude/skills/sd-config/SKILL.md +1 -0
  13. package/claude/skills/sd-demo/SKILL.md +1 -1
  14. package/claude/skills/sd-dev/SKILL.md +1 -1
  15. package/claude/skills/sd-docs/SKILL.md +1 -1
  16. package/claude/skills/sd-impl/SKILL.md +15 -10
  17. package/claude/skills/sd-impl/evals/fixtures/case-a-new-screen/.specs/260513120000_warehouse/spec.md +1 -1
  18. package/claude/skills/sd-impl/evals/fixtures/case-b-update-with-demo/.specs/260513120000_warehouse/spec.md +1 -1
  19. package/claude/skills/sd-impl/evals/fixtures/case-c-new-cross/.specs/260513120000_warehouse/spec.md +2 -2
  20. package/claude/skills/sd-impl/evals/fixtures/case-d-spec-modify/.specs/260513120000_warehouse/spec.md +1 -1
  21. package/claude/skills/sd-impl/evals/golden.jsonl +1 -1
  22. package/claude/skills/sd-manual/SKILL.md +51 -0
  23. package/claude/skills/sd-manual/evals/fixtures/new-manual/src/notification.ts +25 -0
  24. package/claude/skills/sd-manual/evals/fixtures/update-manual/.claude/references/sd-simplysm14/manuals/notification.md +14 -0
  25. package/claude/skills/sd-manual/evals/fixtures/update-manual/src/notification.ts +37 -0
  26. package/claude/skills/sd-manual/evals/golden.jsonl +2 -0
  27. package/claude/skills/sd-review/SKILL.md +3 -3
  28. package/claude/skills/sd-skill/SKILL.md +1 -1
  29. package/claude/skills/sd-spec/SKILL.md +65 -67
  30. package/claude/skills/sd-spec/evals/fixtures/case-a-split//355/232/214/354/235/230/353/241/235.md +20 -0
  31. package/claude/skills/sd-spec/evals/fixtures/case-b-detail/.specs/260513120000_warehouse/spec.md +95 -0
  32. package/claude/skills/sd-spec/evals/golden.jsonl +2 -0
  33. package/claude/skills/sd-spec/references/example-spec.md +14 -47
  34. package/claude/skills/sd-unpack/SKILL.md +1 -1
  35. package/claude/skills/sd-use/SKILL.md +1 -0
  36. package/package.json +1 -1
  37. package/claude/references/sd-simplysm14/manuals/client-setup.md +0 -154
@@ -30,10 +30,16 @@ ORM 호출, 파일 변환, 비즈니스 로직 등은 위 두 경우에 해당
30
30
  | `sd-crud-list` / `sd-crud-detail` 채택한 목록·단건 화면 | [client-crud.md](./manuals/client-crud.md) |
31
31
  | 클라이언트 데모 컴포넌트 작성 | [client-demo.md](./manuals/client-demo.md) |
32
32
  | `<sd-tab>` 사용 | [client-tab.md](./manuals/client-tab.md) |
33
- | 부트스트랩 또는 서비스·마스터 데이터 추가 | [client-setup.md](./manuals/client-setup.md) |
33
+ | 앱에서 서버 서비스·이벤트 호출 (provider 정의·항목 추가) | [client-service.md](./manuals/client-service.md) |
34
+ | 앱에서 ORM(DB) 사용 (AppOrmProvider 정의) | [client-orm.md](./manuals/client-orm.md) |
35
+ | 앱에서 공유 마스터 데이터 사용 (provider 정의·항목 추가) | [client-shared-data.md](./manuals/client-shared-data.md) |
36
+ | 클라이언트·서버 간 실시간 이벤트 정의·발생·구독 | [event.md](./manuals/event.md) |
37
+ | 앱 메뉴 구조·권한 정의 추가/수정 | [client-app-structure.md](./manuals/client-app-structure.md) |
34
38
  | DB 스키마 정의 또는 ORM 쿼리 작성 | [orm.md](./manuals/orm.md) |
35
39
  | 이종 엔티티를 한 목록으로 합쳐 표시 (UNION) | [orm-union.md](./manuals/orm-union.md) |
40
+ | CRUD 처리에 데이터 변경 이력 적재·조회 (누가·언제·무엇을 변경) | [data-log.md](./manuals/data-log.md) |
36
41
  | 콘솔 로깅 코드 작성/수정 (모든 패키지) | [logging.md](./manuals/logging.md) |
42
+ | 클라이언트 시스템 에러·로그를 DB 등 외부에 적재·조회 | [client-system-log.md](./manuals/client-system-log.md) |
37
43
  | 패키지 테스트·통합 테스트 작성/추가 | [test.md](./manuals/test.md) |
38
44
 
39
45
  ## 패키지 인덱스
@@ -0,0 +1,140 @@
1
+ # 앱 구조(AppStructure) 매뉴얼
2
+
3
+ 앱의 메뉴·권한·기능 모듈을 한 군데서 정의하는 방법. 메뉴 트리, 화면 접근 권한, 모듈별 on/off 가 모두 이 구조 하나에서 나옴. 새 화면을 메뉴에 올리거나 권한을 거는 작업 시 참조.
4
+
5
+ ## 1. 앱 구조 정의 위치
6
+
7
+ common 패키지에 클라이언트별 `AppStructureItem[]` 상수를 두고, 앱 부트스트랩에서 `SdAppStructureProvider.initialize(items)` 로 연결함.
8
+
9
+ ```ts
10
+ // common/src/app-structure-items.ts
11
+ import type { AppStructureItem } from "@simplysm/service-common";
12
+ import { tablerBox } from "@ng-icons/tabler-icons"; // 아이콘은 @ng-icons 의 SVG 문자열 상수
13
+
14
+ export const adminAppStructureItems: AppStructureItem[] = [
15
+ /* ... */
16
+ ];
17
+ ```
18
+
19
+ ```ts
20
+ // 앱 부트스트랩 (main.ts)
21
+ provideAppInitializer(async () => {
22
+ const appService = inject(AppServiceProvider);
23
+ const sdAppStructure = inject(SdAppStructureProvider);
24
+
25
+ await appService.connectAsync();
26
+ sdAppStructure.initialize(adminAppStructureItems); // 동기 — await 불필요
27
+ });
28
+ ```
29
+
30
+ - 한 서버가 여러 앱(admin·pda 등)을 서비스해도, **클라이언트마다 자기 배열만** 정의해 import.
31
+ - 서버에 등록하지 않음 — common 에서 클라이언트가 직접 import 함.
32
+
33
+ ## 2. 메뉴 추가
34
+
35
+ 배열에 항목을 추가하면 메뉴에 올라감. **그룹**(하위 메뉴를 묶음)과 **화면**(실제 라우팅 대상)으로 나뉨.
36
+
37
+ ```ts
38
+ export const adminAppStructureItems: AppStructureItem[] = [
39
+ {
40
+ title: "재고관리", // 그룹: children 보유
41
+ code: "inventory",
42
+ icon: tablerBox,
43
+ children: [
44
+ { title: "품목별 재고", code: "goods-inventory", perms: ["use"] }, // 화면(leaf)
45
+ { title: "재고 실사", code: "stock-take", perms: ["use", "edit"] },
46
+ ],
47
+ },
48
+ ];
49
+ ```
50
+
51
+ - `code` 는 부모부터 dot 으로 이어져 화면을 식별함 (위 예: `inventory.goods-inventory`). 라우팅 경로·권한 키가 모두 이 코드 기준.
52
+ - 그룹은 `children` 만 두고 `perms`·`url` 을 두지 않음. 표시 가능한 자식이 하나도 없으면 그룹도 메뉴에서 자동으로 빠짐.
53
+ - 외부 링크 화면은 `url` 지정.
54
+
55
+ | 필드 | 위치 | 용도 |
56
+ | ---------- | --------- | ------------------------------------------ |
57
+ | `title` | 그룹·화면 | 메뉴에 표시할 이름 |
58
+ | `code` | 그룹·화면 | 항목 코드 (부모와 dot 으로 이어 화면 식별) |
59
+ | `icon` | 그룹·화면 | 메뉴 아이콘 |
60
+ | `children` | 그룹 | 하위 항목 배열 |
61
+ | `url` | 화면 | 외부 링크 등 이동 경로 |
62
+
63
+ ## 3. 메뉴에 안 띄우고 화면만 두기
64
+
65
+ 라우팅·내부 이동용이라 사이드 메뉴에는 노출하고 싶지 않은 화면은 `isNotMenu: true`.
66
+
67
+ ```ts
68
+ export const adminAppStructureItems: AppStructureItem[] = [
69
+ { title: "메인메뉴", code: "main", isNotMenu: true }, // 홈/메인 화면
70
+ { title: "내 정보 수정", code: "my-info", isNotMenu: true }, // 사용자 본인 정보 화면
71
+
72
+ { title: "재고관리", code: "inventory", children: [ /* ... */ ] }, // 이하 실제 메뉴 그룹
73
+ ];
74
+ ```
75
+
76
+ - 메뉴에서만 숨고, 화면(라우팅 대상) 자체는 그대로 존재함.
77
+ - 홈(`main`)·내 정보 수정처럼 메뉴를 거치지 않고 직접 진입하는 화면은 배열 **맨 앞**에 root-level leaf(그룹·`children` 없이)로 모아두는 게 관례. 권한을 걸지 않으므로 `perms` 도 두지 않음.
78
+
79
+ ## 4. 권한으로 접근 제한
80
+
81
+ 권한을 걸려면 화면에 `perms` 를 정의함. 그러면 ① 권한 관리 페이지에 체크 항목으로 나오고 ② 권한 없는 사용자에게는 메뉴가 자동으로 숨겨지고 ③ 화면 안에서 권한 보유 여부를 체크할 수 있음.
82
+
83
+ ```ts
84
+ {
85
+ title: "입고지시",
86
+ code: "inbound-instruction",
87
+ perms: ["use", "edit"],
88
+ subPerms: [
89
+ { code: "document", title: "문서작업", perms: ["edit"] }, // 화면 내 세부 권한
90
+ ],
91
+ },
92
+ ```
93
+
94
+ - `perms`: 부여할 권한 종류. `"use"`(조회) / `"edit"`(편집). `perms` 를 지정한 화면만 권한 페이지·권한 체크 대상이 됨.
95
+ - `subPerms`: 한 화면 안의 세부 기능 권한.
96
+
97
+ **권한을 사용자에게 부여** — 권한 관리 화면은 `getPermissionsByStructure(items)` 결과를 `<sd-permission-table>` 에 넘김. 저장한 결과는 사용자별 권한 레코드로 서버에 저장됨.
98
+
99
+ ```ts
100
+ permissions = computed(() =>
101
+ this._sdAppStructure.getPermissionsByStructure(this._sdAppStructure.items()),
102
+ );
103
+ // template: <sd-permission-table [items]="permissions()" [(value)]="data" />
104
+ ```
105
+
106
+ **로그인 시 권한 연결** — 인증 후 사용자의 권한 레코드를 `permRecord` 에 set 하면 메뉴 필터·권한 체크에 반영됨.
107
+
108
+ ```ts
109
+ this._sdAppStructure.permRecord.set(this.authInfo()!.user.permissionRecord);
110
+ ```
111
+
112
+ **화면 안에서 권한 체크** — `injectPermsSignal` 로 현재 사용자의 활성 권한을 읽음.
113
+
114
+ ```ts
115
+ perms = injectPermsSignal(["base.user-permission"], ["use", "edit"]);
116
+ canEdit = computed(() => this.perms().includes("edit"));
117
+ // → 권한 없으면 빈 배열. 예: ["use"]
118
+ ```
119
+
120
+ - 첫 인자는 화면의 fullCode(들), 둘째 인자는 확인할 권한 종류.
121
+ - `perms` 를 정의하지 않은 화면은 제약이 없으므로 항상 모든 권한이 활성으로 나옴.
122
+
123
+ ## 5. 기능 모듈로 메뉴 on/off
124
+
125
+ 계약·라이선스 등으로 앱마다 켜고 끄는 기능 묶음이 있으면 `modules`/`requiredModules` 로 조건을 걸고, 앱에서 활성 모듈을 `usableModules` 에 set 함.
126
+
127
+ ```ts
128
+ { title: "스케쥴링", code: "scheduling", modules: ["scheduling"], children: [ /* ... */ ] },
129
+ { title: "고급분석", code: "advanced", requiredModules: ["analytics", "pro"] },
130
+ ```
131
+
132
+ ```ts
133
+ // 앱 초기화 시 활성 모듈 지정
134
+ this._sdAppStructure.usableModules.set(["scheduling"]);
135
+ ```
136
+
137
+ - `modules`: 나열한 모듈 중 **하나라도** 활성이면 표시(OR).
138
+ - `requiredModules`: 나열한 모듈이 **모두** 활성이어야 표시(AND).
139
+ - 조건을 건 항목은 해당 모듈이 `usableModules` 에 없으면 메뉴·권한에서 빠짐. 조건이 없는 항목은 모듈 설정과 무관하게 표시됨.
140
+ - 모듈 기능을 쓰지 않는 앱은 `usableModules.set([])` 로 둠.
@@ -245,7 +245,7 @@ if (!result) return;
245
245
  // result 처리
246
246
  ```
247
247
 
248
- - **`type`** — `SdModal` 구현(상속) 컴포넌트 클래스.
248
+ - **`type`** — `SdModalContentDef<O>` 를 구현한 컴포넌트 클래스 (`initialized` 시그널 + `close` output 보유. `O` 는 close 페이로드 타입). `SdModal` 라이브러리 모달 셸 컴포넌트이므로 상속 대상이 아님.
249
249
  - **`title`** — 모달 헤더 제목.
250
250
  - **`inputs`** — 모달 컴포넌트가 받을 input 시그널 값. 없으면 `{}`.
251
251
  - **반환값** — 모달 컴포넌트가 close 시 emit 한 페이로드. 사용자가 닫기(X)·취소로 닫으면 `undefined`.
@@ -629,10 +629,11 @@ label 과 입력 그룹을 묶는 전용 클래스 3종:
629
629
  private readonly _appService = inject(AppServiceProvider);
630
630
 
631
631
  await this._appService.user.someMethod(...);
632
- this._appService.authInfoEvent.subscribe(...);
632
+
633
+ const listenerKey = await this._appService.authInfoEvent.addListener(info, async (data) => { ... });
633
634
  ```
634
635
 
635
- Provider 정의·서비스 추가 컨벤션은 [client-setup.md#appserviceprovider](./client-setup.md#appserviceprovider) 참조.
636
+ Provider 정의·서비스·이벤트 호출 추가 컨벤션은 [client-service.md](./client-service.md) 참조.
636
637
 
637
638
  ## ORM 호출 (`AppOrmProvider`)
638
639
 
@@ -645,7 +646,7 @@ await this._appOrm.connectAsync(async (db) => {
645
646
  ```
646
647
 
647
648
  - 기본은 `connectAsync` (트랜잭션). `connectWithoutTransAsync` 는 트랜잭션 안에서 동작하지 않는 작업용 헬퍼.
648
- - 쿼리 작성법은 [orm.md](./orm.md), Provider 정의 컨벤션은 [client-setup.md#appormprovider](./client-setup.md#appormprovider) 참조.
649
+ - 쿼리 작성법은 [orm.md](./orm.md), Provider 정의 컨벤션은 [client-orm.md](./client-orm.md) 참조.
649
650
 
650
651
  ## 공유 데이터 (`useSharedSignal`)
651
652
 
@@ -662,7 +663,7 @@ sharedCustomers = useSharedSignal("고객사");
662
663
  <sd-shared-data-select [items]="sharedCustomers.items()" [(value)]="data().customerId" ... />
663
664
  ```
664
665
 
665
- 마스터 데이터 등록·Provider 정의 컨벤션은 [client-setup.md#appshareddataprovider](./client-setup.md#appshareddataprovider) 참조.
666
+ Provider 정의·새 마스터 데이터 등록 컨벤션은 [client-shared-data.md](./client-shared-data.md) 참조.
666
667
 
667
668
  ## 레이아웃·유틸 클래스
668
669
 
@@ -0,0 +1,62 @@
1
+ # 앱에서 ORM(DB) 사용 매뉴얼
2
+
3
+ 앱에서 ORM(Object-Relational Mapping) 으로 DB(데이터베이스)에 접근하려면 `AppOrmProvider` 가 필요. `AppServiceProvider.orm` 위에 앱별 DB 설정(DbContext·데이터베이스명·스키마명)을 고정해 둔 root provider 로, 화면·프로바이더는 DB 옵션을 매번 적지 않고 `connectAsync` 한 번으로 쿼리를 실행.
4
+
5
+ - 전제: `AppServiceProvider` 가 먼저 있어야 함 ([client-service.md](./client-service.md) — `orm` getter 가 이 provider 의 기반).
6
+ - 쿼리 작성법(스키마 정의·`Queryable` 체이닝·`expr`)은 [orm.md](./orm.md) 참조.
7
+
8
+ ## AppOrmProvider 를 정의하려면 (새 앱 1회성)
9
+
10
+ 앱의 DbContext·DB명·스키마를 한 곳에 고정하고, 트랜잭션 유무별 진입 메서드를 제공.
11
+
12
+ ```ts
13
+ @Injectable({ providedIn: "root" })
14
+ export class AppOrmProvider {
15
+ private readonly _appService = inject(AppServiceProvider);
16
+
17
+ connectAsync<R>(callback: (db: MainDbContext) => Promise<R>): Promise<R> {
18
+ return this._appService.orm.connect(
19
+ {
20
+ DbClass: MainDbContext,
21
+ connOpt: { configName: "MAIN" },
22
+ dbContextOpt: { database: "...", schema: "dbo" },
23
+ },
24
+ callback,
25
+ );
26
+ }
27
+
28
+ connectWithoutTransAsync<R>(callback: (db: MainDbContext) => Promise<R>): Promise<R> {
29
+ return this._appService.orm.connectWithoutTransaction(
30
+ { /* 같은 옵션 */ },
31
+ callback,
32
+ );
33
+ }
34
+ }
35
+ ```
36
+
37
+ **약속**:
38
+
39
+ - `@Injectable({ providedIn: "root" })`.
40
+ - DbContext 는 앱별로 정의 (예: `@adtek/db-main` 의 `MainDbContext`). 스키마 정의는 [orm.md](./orm.md).
41
+ - 기본 메서드는 `connectAsync` (트랜잭션 포함). `connectWithoutTransAsync` 는 initialize 등 트랜잭션 안에서 동작하지 않는 작업 전용 헬퍼.
42
+ - 콜백의 반환값이 그대로 메서드의 반환값이 됨.
43
+
44
+ ## 화면·프로바이더에서 쿼리를 실행하려면
45
+
46
+ `AppOrmProvider` 를 inject 하고 `connectAsync` 콜백 안에서 쿼리.
47
+
48
+ ```ts
49
+ private readonly _appOrm = inject(AppOrmProvider);
50
+
51
+ const rows = await this._appOrm.connectAsync(async (db) => {
52
+ return db.order().select((item) => ({ id: item.id, status: item.status })).execute();
53
+ });
54
+ ```
55
+
56
+ - 콜백 인자 `db` 는 `MainDbContext`. 테이블·뷰 빌더와 쿼리 작성은 [orm.md](./orm.md).
57
+ - 트랜잭션이 곤란한 작업(initialize 등)만 `connectWithoutTransAsync` 사용.
58
+
59
+ ## 지킬 것
60
+
61
+ - DB 옵션(`DbClass`·`connOpt`·`dbContextOpt`)은 `AppOrmProvider` 한 곳에만 두고, 화면·프로바이더는 `connectAsync`/`connectWithoutTransAsync` 만 호출. 옵션을 호출부에 흩뿌리지 않음.
62
+ - 기본은 `connectAsync`. 트랜잭션 없이 돌려야 하는 명확한 이유가 있을 때만 `connectWithoutTransAsync`.
@@ -0,0 +1,96 @@
1
+ # 앱에서 서버 서비스·이벤트 호출 매뉴얼
2
+
3
+ 앱이 서버와 통신(서비스 RPC 호출·실시간 이벤트 구독)하려면 `AppServiceProvider` 가 필요. `@simplysm/service-client` 위에 앱이 만드는 root provider 로, 서버 연결·서비스 프록시·이벤트 프록시·ORM 커넥터의 공통 진입점.
4
+
5
+ - 새 앱이라 provider 자체가 없으면 → 아래 "AppServiceProvider 를 정의하려면".
6
+ - provider 는 이미 있고 서비스·이벤트만 더할 때 → "새 서비스 호출을 추가하려면" / "새 이벤트 프록시를 추가하려면".
7
+
8
+ ORM 사용은 [client-orm.md](./client-orm.md), 이벤트 정의·발생 메커니즘은 [event.md](./event.md) 참조.
9
+
10
+ ## AppServiceProvider 를 정의하려면 (새 앱 1회성)
11
+
12
+ 서버 연결·서비스·이벤트·ORM 진입점을 한 root provider 에 모음. 서비스·이벤트는 `private _xxx?` 캐시 필드 + getter 로 lazy 노출(`??=`).
13
+
14
+ ```ts
15
+ @Injectable({ providedIn: "root" })
16
+ export class AppServiceProvider {
17
+ private readonly _sdServiceClientFactory = inject(SdServiceClientFactoryProvider);
18
+
19
+ private _orm?: OrmClientConnector;
20
+ private _user?: ServiceProxy<UserServiceMethods>;
21
+ private _authInfoEvent?: ClientEventProxy<typeof AuthInfoEvent>;
22
+
23
+ get client() {
24
+ return this._sdServiceClientFactory.get("MAIN");
25
+ }
26
+
27
+ get orm(): OrmClientConnector {
28
+ return (this._orm ??= createOrmClientConnector(this.client));
29
+ }
30
+
31
+ get user(): ServiceProxy<UserServiceMethods> {
32
+ return (this._user ??= this.client.getService<UserServiceMethods>("User"));
33
+ }
34
+
35
+ get authInfoEvent(): ClientEventProxy<typeof AuthInfoEvent> {
36
+ return (this._authInfoEvent ??= this.client.getEvent(AuthInfoEvent));
37
+ }
38
+
39
+ async connectAsync() {
40
+ await this._sdServiceClientFactory.connectAsync("MAIN");
41
+ }
42
+ }
43
+ ```
44
+
45
+ **약속**:
46
+
47
+ - `@Injectable({ providedIn: "root" })`.
48
+ - `client` getter — `SdServiceClientFactoryProvider.get("MAIN")` 결과. 서비스·이벤트·ORM 의 공통 진입점.
49
+ - `orm` getter — `createOrmClientConnector(this.client)` 결과. DB 설정을 얹는 `AppOrmProvider` 가 이 위에 올라감 ([client-orm.md](./client-orm.md)).
50
+ - `connectAsync()` — 앱 부트스트랩 시점에 서버 연결 수행. `addListener` 등 통신은 이 호출 이후에만 가능.
51
+
52
+ ## 새 서비스 호출을 추가하려면
53
+
54
+ `client.getService<XxxServiceMethods>("XxxName")` 결과를 캐시 필드 + getter 로 노출.
55
+
56
+ ```ts
57
+ @Injectable({ providedIn: "root" })
58
+ export class AppServiceProvider {
59
+ // ... 기존 필드 ...
60
+ private _order?: ServiceProxy<OrderServiceMethods>;
61
+
62
+ get order(): ServiceProxy<OrderServiceMethods> {
63
+ return (this._order ??= this.client.getService<OrderServiceMethods>("Order"));
64
+ }
65
+ }
66
+ ```
67
+
68
+ - 타입 `XxxServiceMethods` 는 server 패키지가 export 한 `ServiceMethods<typeof XxxService>`.
69
+ - 첫 인자 `"XxxName"` 은 server 의 `defineService("XxxName", ...)` 이름과 일치해야 함.
70
+ - 호출: `await this._appService.order.ship(orderId)`.
71
+
72
+ ## 새 이벤트 프록시를 추가하려면
73
+
74
+ `client.getEvent(XxxEvent)` 결과를 캐시 필드 + getter 로 노출. `appService.client.getEvent(...)` 를 매번 적는 대신 `appService.xxxEvent` 로 짧게 씀.
75
+
76
+ ```ts
77
+ @Injectable({ providedIn: "root" })
78
+ export class AppServiceProvider {
79
+ // ... 기존 필드 ...
80
+ private _orderStatusChangedEvent?: ClientEventProxy<typeof OrderStatusChangedEvent>;
81
+
82
+ get orderStatusChangedEvent(): ClientEventProxy<typeof OrderStatusChangedEvent> {
83
+ return (this._orderStatusChangedEvent ??= this.client.getEvent(OrderStatusChangedEvent));
84
+ }
85
+ }
86
+ ```
87
+
88
+ - `XxxEvent` 는 공통 패키지가 `defineEvent(...)` 로 export 한 정의 객체 — 이름·타입이 객체에서 추론됨. 문자열 이름이나 `<typeof X>` 를 따로 적지 않음.
89
+ - 구독: `const key = await this._appService.orderStatusChangedEvent.addListener(info, cb)`.
90
+ - 이벤트 정의·발생·구독 메커니즘 전반은 [event.md](./event.md) 참조.
91
+
92
+ ## 지킬 것
93
+
94
+ - 캐시 필드는 `private _xxx?`, 노출은 getter, 초기화는 `??=` — 항목마다 동일 패턴 유지.
95
+ - 서비스 이름·이벤트 정의 객체는 단일 소스(server `defineService` 이름 / 공통 `defineEvent` 객체)를 그대로 따름. 호출부에서 문자열·제네릭을 중복으로 적지 않음.
96
+ - `connectAsync()` 이전에는 통신 호출 불가 — 부트스트랩 순서 준수.
@@ -0,0 +1,146 @@
1
+ # 앱에서 공유 마스터 데이터 사용 매뉴얼
2
+
3
+ 화면에서 자주 참조하는 마스터 데이터(고객사·품목·로케이션 등)를 공유 시그널로 쓰려면 `AppSharedDataProvider` 가 필요. `@simplysm/angular` 의 `SdSharedDataProvider` 를 상속해, 한 번 등록해 두면 어느 화면에서든 `useSharedSignal("<이름>")` 로 동일 데이터를 공유.
4
+
5
+ - 새 앱이라 provider 자체가 없으면 → "AppSharedDataProvider 를 정의하려면".
6
+ - provider 는 있고 데이터 항목만 더할 때 → "마스터 데이터 항목을 추가하려면".
7
+ - 전제: getter 가 DB 를 조회하므로 `AppOrmProvider` 가 먼저 있어야 함 ([client-orm.md](./client-orm.md)).
8
+
9
+ ## AppSharedDataProvider 를 정의하려면 (새 앱 1회성)
10
+
11
+ `SdSharedDataProvider<TAppSharedData>` 를 상속하고, `useSharedSignal` 헬퍼를 함께 export. `initialize()` 안에서 항목을 `register`.
12
+
13
+ ```ts
14
+ export function useSharedSignal<K extends keyof TAppSharedData>(
15
+ name: K,
16
+ ): SharedDataHandle<TAppSharedData[K]> {
17
+ const appSharedData = inject(AppSharedDataProvider);
18
+ return appSharedData.getHandle(name);
19
+ }
20
+
21
+ @Injectable({ providedIn: "root" })
22
+ export class AppSharedDataProvider extends SdSharedDataProvider<TAppSharedData> {
23
+ private readonly _appOrm = inject(AppOrmProvider);
24
+
25
+ override initialize() {
26
+ this.register("고객사", {
27
+ serviceKey: "MAIN",
28
+ getter: async (changeKeys) => {
29
+ return this._appOrm.connectAsync(async (db) => {
30
+ let qr = db.customer().select((item) => ({
31
+ id: item.id,
32
+ code: item.code,
33
+ name: item.name,
34
+ isDisabled: item.isDisabled,
35
+
36
+ __valueKey: item.id,
37
+ __searchText: expr.concat(item.code, "|_|", item.name),
38
+ __isHidden: item.isDisabled,
39
+ }));
40
+
41
+ if (changeKeys) {
42
+ qr = qr.where((item) => [expr.in(item.id, changeKeys as number[])]);
43
+ }
44
+ return qr.execute();
45
+ });
46
+ },
47
+ orderBy: (item) => item.code,
48
+ });
49
+ }
50
+ }
51
+
52
+ export type TAppSharedData = {
53
+ 고객사: ISharedCustomer;
54
+ };
55
+
56
+ export interface ISharedCustomer extends SharedDataBase<number> {
57
+ id: number;
58
+ code: string;
59
+ name: string;
60
+ isDisabled: boolean;
61
+ }
62
+ ```
63
+
64
+ **약속**:
65
+
66
+ - `@Injectable({ providedIn: "root" })` 사용, `SdSharedDataProvider<TAppSharedData>` 를 상속.
67
+ - 등록은 `override initialize()` 안에서 `this.register(name, opts)` 호출로 수행.
68
+ - `useSharedSignal<K>(name)` 헬퍼를 함께 export — 컴포넌트는 inject 없이 이름만으로 접근.
69
+
70
+ ## 마스터 데이터 항목을 추가하려면
71
+
72
+ 세 곳을 함께 손봄: ① `initialize()` 의 `register` ② `TAppSharedData` 타입 항목 ③ 항목 인터페이스.
73
+
74
+ ### 1. `initialize()` 에 `register` 추가
75
+
76
+ ```ts
77
+ this.register("품목", {
78
+ serviceKey: "MAIN",
79
+ getter: async (changeKeys) => {
80
+ return this._appOrm.connectAsync(async (db) => {
81
+ let qr = db.product().select((item) => ({
82
+ id: item.id,
83
+ code: item.code,
84
+ name: item.name,
85
+ isDisabled: item.isDisabled,
86
+
87
+ __valueKey: item.id,
88
+ __searchText: expr.concat(item.code, "|_|", item.name),
89
+ __isHidden: item.isDisabled,
90
+ }));
91
+
92
+ if (changeKeys) {
93
+ qr = qr.where((item) => [expr.in(item.id, changeKeys as number[])]);
94
+ }
95
+ return qr.execute();
96
+ });
97
+ },
98
+ orderBy: (item) => item.code,
99
+ });
100
+ ```
101
+
102
+ - getter 의 select 결과에 매직 필드를 포함:
103
+ - `__valueKey` — 항목의 키.
104
+ - `__searchText` — 검색용 텍스트.
105
+ - `__isHidden` — 숨김 여부 (예: `isDisabled` 값으로 지정).
106
+ - `getter(changeKeys)` 의 `changeKeys` 인자가 주어지면 해당 키들만 다시 조회 (incremental refresh). 위 `where` 분기가 그 처리.
107
+ - `orderBy` 는 정렬 키를 반환하는 함수.
108
+
109
+ ### 2. `TAppSharedData` 에 항목 추가
110
+
111
+ ```ts
112
+ export type TAppSharedData = {
113
+ 고객사: ISharedCustomer;
114
+ 품목: ISharedProduct;
115
+ };
116
+ ```
117
+
118
+ ### 3. 항목 인터페이스 정의 (`SharedDataBase` 상속)
119
+
120
+ ```ts
121
+ export interface ISharedProduct extends SharedDataBase<number> {
122
+ id: number;
123
+ code: string;
124
+ name: string;
125
+ isDisabled: boolean;
126
+ }
127
+ ```
128
+
129
+ - 제네릭 인자는 키 타입(여기선 `number`).
130
+
131
+ ## 화면에서 참조하려면
132
+
133
+ ```ts
134
+ sharedProducts = useSharedSignal("품목");
135
+
136
+ // sharedProducts.items() — 시그널, 항목 배열
137
+ // sharedProducts.get(id) — id 로 단건 조회
138
+ ```
139
+
140
+ - `register` 에 쓴 이름 문자열을 그대로 넘기면 `TAppSharedData` 에서 타입이 추론됨.
141
+
142
+ ## 지킬 것
143
+
144
+ - 항목 추가 시 세 곳(`register` · `TAppSharedData` · 인터페이스)을 모두 갱신. 하나라도 빠지면 타입 불일치 또는 미등록 데이터가 됨.
145
+ - select 결과에 매직 필드(`__valueKey` · `__searchText` · `__isHidden`)를 빠짐없이 포함.
146
+ - `changeKeys` 분기를 생략하지 않음 — incremental refresh 가 동작하지 않으면 변경 시 전체 재조회가 됨.
@@ -0,0 +1,96 @@
1
+ # 클라이언트 시스템 로그 적재 매뉴얼
2
+
3
+ 클라이언트(Angular)에서 프레임워크가 잡은 시스템 에러·경고를 DB 등 외부 저장소에 적재하고, 직접 시스템 로그를 남기려 할 때 참조.
4
+
5
+ `SdSystemLogProvider`(`@simplysm/angular`, `providedIn: "root"`)가 그 통로. 내부적으로는 `createLogger("angular:system-log")` 로 **항상 콘솔에 먼저 출력**한 뒤, 앱이 꽂은 `writeFn` 이 있으면 추가로 외부에 흘려보냄. 즉 [logging.md](./logging.md)의 콘솔 출력 표준 위에 "외부 적재 훅 + 프레임워크 자동 연동"을 얹은 것이며, `createLogger` 를 대체하지 않음.
6
+
7
+ 지원 심각도: `"error" | "warn" | "log"`.
8
+
9
+ ## 시스템 로그 테이블을 정의하려면
10
+
11
+ 외부 적재 대상이 DB 라면 로그 테이블을 먼저 정의. 시간 역순 조회가 잦으므로 `dateTime` 에 DESC 인덱스를 둠.
12
+
13
+ ```ts
14
+ import { Table } from "@simplysm/orm-common";
15
+ import { Employee } from "./employee";
16
+
17
+ export const SystemLog = Table("SystemLog")
18
+ .columns((c) => ({
19
+ id: c.bigint().autoIncrement(),
20
+ dateTime: c.datetime(),
21
+ severity: c.varchar(50), // "error" | "warn" | "log"
22
+ message: c.text(), // writeFn 의 data 를 JSON 직렬화해 저장
23
+ clientName: c.varchar(200), // 어느 클라이언트에서 발생했는지
24
+ employeeId: c.bigint().nullable(), // 인증 전 로그도 적재되므로 nullable
25
+ }))
26
+ .primaryKey("id")
27
+ .indexes((i) => [i.index("dateTime").orderBy("DESC")])
28
+ .relations((r) => ({
29
+ employee: r.foreignKey(["employeeId"], () => Employee),
30
+ }));
31
+ ```
32
+
33
+ - `severity` 는 `SdSystemLogProvider` 가 넘기는 세 값(`error`/`warn`/`log`)을 그대로 저장.
34
+ - `message` 는 `writeFn` 의 가변 인자 `data` 전체를 담을 수 있게 `text` + JSON 직렬화로 둠.
35
+ - `employeeId` 는 로그인 이전 시점의 에러도 기록되므로 `nullable`.
36
+
37
+ ## 부트스트랩에서 외부 적재를 배선하려면
38
+
39
+ `provideAppInitializer` 안에서 `SdSystemLogProvider.writeFn` 에 적재 함수를 할당. 트랜잭션이 필요 없는 단순 insert 이므로 `connectWithoutTransAsync` 를 사용.
40
+
41
+ ```ts
42
+ provideAppInitializer(() => {
43
+ const appService = inject(AppServiceProvider);
44
+ const appOrm = inject(AppOrmProvider);
45
+ const appAuth = inject(AppAuthProvider);
46
+
47
+ inject(SdSystemLogProvider).writeFn = async (severity, ...data) => {
48
+ await appOrm.connectWithoutTransAsync(async (db) => {
49
+ await db.systemLog().insert([
50
+ {
51
+ dateTime: new DateTime(),
52
+ severity,
53
+ message: JSON.stringify(data),
54
+ clientName: CLIENT_NAME,
55
+ employeeId: appAuth.authInfo()?.employeeId,
56
+ },
57
+ ]);
58
+ });
59
+ };
60
+
61
+ return appService.connectAsync();
62
+ }),
63
+ ```
64
+
65
+ - `CLIENT_NAME` 은 `provideSdAngular({ clientName: CLIENT_NAME })` 에 넘긴 값과 동일하게 두어 어느 앱에서 난 로그인지 구분.
66
+ - `data` 는 가변 인자 배열이므로 `JSON.stringify(data)` 로 통째로 저장.
67
+ - `writeFn` 미설정 시 외부 적재는 일어나지 않고 콘솔 출력만 수행됨. DB 적재가 필요한 앱에서만 배선.
68
+
69
+ ## 자동으로 적재되는 로그
70
+
71
+ 다음 프레임워크 지점이 별도 호출 없이 `writeAsync` 를 부르므로, `writeFn` 만 배선하면 자동으로 외부에 적재됨:
72
+
73
+ - `SdGlobalErrorHandlerPlugin` — 미처리 에러·미처리 Promise 거부를 `writeAsync("error", ...)`, `error` 가 없는 `ErrorEvent` 는 `writeAsync("warn", ...)`.
74
+ - `SdToastProvider.try()` / `.danger()` — 잡은 에러를 토스트로 띄울 때 `writeAsync("error", ...)`.
75
+
76
+ ## 직접 시스템 로그를 남기려면
77
+
78
+ 업무 코드에서 직접 적재하려면 프로바이더를 주입해 `writeAsync` 호출:
79
+
80
+ ```ts
81
+ private readonly _sdSystemLog = inject(SdSystemLogProvider);
82
+
83
+ await this._sdSystemLog.writeAsync("error", "결제 승인 실패", err.stack);
84
+ ```
85
+
86
+ - 콘솔 출력은 항상 일어나고, `writeFn` 이 배선돼 있으면 외부 적재까지 이어짐.
87
+
88
+ ## 적재된 로그를 조회하려면
89
+
90
+ `SystemLog` 테이블을 일반 ORM 조회로 읽으면 됨([orm.md](./orm.md) 참조). 시간 역순 인덱스를 활용해 `dateTime` 내림차순으로 정렬.
91
+
92
+ ## 지킬 것
93
+
94
+ - `writeFn` 안의 적재 실패는 throw 하지 않음. `SdSystemLogProvider` 가 `writeFn` 호출을 try/catch 로 감싸 실패를 `logger.error` 로만 남기고 삼킴 — 로그 적재 실패가 원래 동작(에러 처리·화면 흐름)을 막지 않게 하기 위한 설계. 이 자리는 silent skip 금지 원칙의 예외가 아니라, "로그 싱크 실패는 본 동작과 분리한다"는 의도된 동작.
95
+ - `writeFn` 은 부트스트랩(`provideAppInitializer`)에서 1회만 할당. 화면·서비스 코드에서 재할당하지 않음.
96
+ - 콘솔에 찍는 것 자체는 `SdSystemLogProvider` 가 내부에서 `createLogger` 로 처리하므로, 시스템 로그를 남기려고 `console.*` 를 직접 호출하지 않음([logging.md](./logging.md)).