@things-factory/board-import 10.0.0-beta.71 → 10.0.0-beta.73
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.
- package/dist-server/index.d.ts +1 -1
- package/dist-server/index.js +1 -1
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/adapters/dxf-adapter.d.ts +1 -1
- package/dist-server/service/adapters/dxf-adapter.js +18 -4
- package/dist-server/service/adapters/dxf-adapter.js.map +1 -1
- package/dist-server/service/adapters/image-adapter.d.ts +32 -0
- package/dist-server/service/adapters/image-adapter.js +420 -0
- package/dist-server/service/adapters/image-adapter.js.map +1 -0
- package/dist-server/service/ai/types.d.ts +96 -4
- package/dist-server/service/ai/types.js +221 -12
- package/dist-server/service/ai/types.js.map +1 -1
- package/dist-server/service/converters/generic-to.d.ts +17 -8
- package/dist-server/service/converters/generic-to.js +44 -44
- package/dist-server/service/converters/generic-to.js.map +1 -1
- package/dist-server/service/import-session/import-actions.d.ts +79 -0
- package/dist-server/service/import-session/import-actions.js +98 -0
- package/dist-server/service/import-session/import-actions.js.map +1 -0
- package/dist-server/service/import-session/import-session-resolver.d.ts +22 -1
- package/dist-server/service/import-session/import-session-resolver.js +166 -38
- package/dist-server/service/import-session/import-session-resolver.js.map +1 -1
- package/dist-server/service/import-session/import-worker.js +55 -13
- package/dist-server/service/import-session/import-worker.js.map +1 -1
- package/dist-server/service/import-session/index.d.ts +5 -3
- package/dist-server/service/import-session/index.js +16 -9
- package/dist-server/service/import-session/index.js.map +1 -1
- package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
- package/dist-server/service/import-session/materialize-from-session.js +104 -0
- package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
- package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
- package/dist-server/service/import-session/suggest-board-name.js +195 -0
- package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
- package/dist-server/service/import-tools.d.ts +10 -0
- package/dist-server/service/import-tools.js +227 -0
- package/dist-server/service/import-tools.js.map +1 -0
- package/dist-server/service/index.d.ts +22 -19
- package/dist-server/service/index.js +46 -36
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/pipeline/index.d.ts +42 -9
- package/dist-server/service/pipeline/index.js +92 -23
- package/dist-server/service/pipeline/index.js.map +1 -1
- package/dist-server/service/pipeline/stage2-mapping.d.ts +20 -5
- package/dist-server/service/pipeline/stage2-mapping.js +84 -26
- package/dist-server/service/pipeline/stage2-mapping.js.map +1 -1
- package/dist-server/service/pipeline/stage3-board.d.ts +2 -2
- package/dist-server/service/pipeline/stage3-board.js +65 -10
- package/dist-server/service/pipeline/stage3-board.js.map +1 -1
- package/dist-server/service/pipeline/stage4-binding.d.ts +1 -1
- package/dist-server/service/pipeline/stage4-binding.js +2 -2
- package/dist-server/service/pipeline/stage4-binding.js.map +1 -1
- package/dist-server/service/registry/index.d.ts +1 -1
- package/dist-server/service/registry/index.js.map +1 -1
- package/dist-server/service/types/index.d.ts +35 -0
- package/dist-server/service/types/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -6
- package/server/index.ts +1 -1
- package/server/service/adapters/dxf-adapter.ts +17 -4
- package/server/service/adapters/image-adapter.test.ts +545 -0
- package/server/service/adapters/image-adapter.ts +464 -0
- package/server/service/ai/types.ts +354 -19
- package/server/service/converters/generic-to.test.ts +91 -0
- package/server/service/converters/generic-to.ts +66 -49
- package/server/service/import-session/import-actions.test.ts +185 -0
- package/server/service/import-session/import-actions.ts +164 -0
- package/server/service/import-session/import-session-resolver.ts +171 -39
- package/server/service/import-session/import-worker.ts +56 -12
- package/server/service/import-session/index.ts +19 -3
- package/server/service/import-session/materialize-from-session.test.ts +274 -0
- package/server/service/import-session/materialize-from-session.ts +158 -0
- package/server/service/import-session/suggest-board-name.test.ts +271 -0
- package/server/service/import-session/suggest-board-name.ts +279 -0
- package/server/service/import-tools.test.ts +137 -0
- package/server/service/import-tools.ts +255 -0
- package/server/service/index.ts +35 -18
- package/server/service/pipeline/index.ts +118 -23
- package/server/service/pipeline/stage2-mapping.test.ts +204 -0
- package/server/service/pipeline/stage2-mapping.ts +102 -27
- package/server/service/pipeline/stage3-board.test.ts +133 -0
- package/server/service/pipeline/stage3-board.ts +76 -11
- package/server/service/pipeline/stage4-binding.ts +2 -2
- package/server/service/registry/index.ts +1 -1
- package/server/service/types/index.ts +36 -0
|
@@ -9,14 +9,95 @@
|
|
|
9
9
|
*
|
|
10
10
|
* 비동기 worker 는 단일 프로세스 내 setImmediate 기반. 추후 큐(Redis/BullMQ) 로 이전 가능.
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
Resolver,
|
|
14
|
+
Query,
|
|
15
|
+
Mutation,
|
|
16
|
+
Arg,
|
|
17
|
+
Ctx,
|
|
18
|
+
Directive,
|
|
19
|
+
Field,
|
|
20
|
+
ID,
|
|
21
|
+
InputType,
|
|
22
|
+
ObjectType
|
|
23
|
+
} from 'type-graphql'
|
|
13
24
|
import { GraphQLJSON } from 'graphql-scalars'
|
|
25
|
+
import { Like } from 'typeorm'
|
|
14
26
|
import '@things-factory/auth-base'
|
|
15
27
|
|
|
16
28
|
import { getRepository } from '@things-factory/shell'
|
|
29
|
+
import { Board, Group } from '@things-factory/board-service'
|
|
30
|
+
import { Attachment } from '@things-factory/attachment-base'
|
|
31
|
+
|
|
32
|
+
import { ImportSession } from './import-session'
|
|
33
|
+
import { materializeFromSession } from './materialize-from-session'
|
|
34
|
+
import {
|
|
35
|
+
suggestBoardName,
|
|
36
|
+
suggestBoardMeta,
|
|
37
|
+
type SuggestBoardMetaResult,
|
|
38
|
+
type SuggestBoardRepoLike
|
|
39
|
+
} from './suggest-board-name'
|
|
40
|
+
import {
|
|
41
|
+
startImportJobAsync,
|
|
42
|
+
fetchImportSession,
|
|
43
|
+
listImportSessionsByChatSession
|
|
44
|
+
} from './import-actions'
|
|
17
45
|
|
|
18
|
-
|
|
19
|
-
|
|
46
|
+
@InputType()
|
|
47
|
+
class MaterializeImportSessionInput {
|
|
48
|
+
@Field(() => ID, { description: 'ImportSession id (must be in completed state).' })
|
|
49
|
+
sessionId!: string
|
|
50
|
+
|
|
51
|
+
@Field({ description: 'New Board name.' })
|
|
52
|
+
name!: string
|
|
53
|
+
|
|
54
|
+
@Field({ nullable: true, description: 'Optional Board description.' })
|
|
55
|
+
description?: string
|
|
56
|
+
|
|
57
|
+
@Field(() => ID, { nullable: true, description: 'Optional Group id to attach the Board to.' })
|
|
58
|
+
groupId?: string
|
|
59
|
+
|
|
60
|
+
@Field({ nullable: true, description: "Board type — 'main' | 'sub' | 'popup'. Default 'main'." })
|
|
61
|
+
type?: 'main' | 'sub' | 'popup'
|
|
62
|
+
|
|
63
|
+
@Field({ nullable: true, description: 'Base64 thumbnail. If omitted, board-service default empty thumbnail is used.' })
|
|
64
|
+
thumbnail?: string
|
|
65
|
+
|
|
66
|
+
@Field({
|
|
67
|
+
nullable: true,
|
|
68
|
+
description:
|
|
69
|
+
"ImportSession 의 결과 시안 중 하나의 id. 'as-is' / 'scene' / 'auto-fit'. " +
|
|
70
|
+
'미지정 시 default variant (보통 \'scene\') 사용.'
|
|
71
|
+
})
|
|
72
|
+
variantId?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@ObjectType()
|
|
76
|
+
class BoardMetaSuggestion {
|
|
77
|
+
@Field({ description: 'Suggested short board name (collision-free with current domain).' })
|
|
78
|
+
name!: string
|
|
79
|
+
|
|
80
|
+
@Field({
|
|
81
|
+
description:
|
|
82
|
+
'Suggested detailed description — combines user prompt, AI importStrategy narrative, and category distribution stats.'
|
|
83
|
+
})
|
|
84
|
+
description!: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@InputType()
|
|
88
|
+
class SuggestBoardNameInput {
|
|
89
|
+
@Field(() => ID, {
|
|
90
|
+
nullable: true,
|
|
91
|
+
description: 'ImportSession id — 있으면 거기서 userPrompt / VLM reasoning / attachment.name 추출.'
|
|
92
|
+
})
|
|
93
|
+
sessionId?: string
|
|
94
|
+
|
|
95
|
+
@Field({
|
|
96
|
+
nullable: true,
|
|
97
|
+
description: '명시 hint. session 정보보다 우선. 사용자가 직접 적은 메모를 기반으로 추천받고 싶을 때.'
|
|
98
|
+
})
|
|
99
|
+
hint?: string
|
|
100
|
+
}
|
|
20
101
|
|
|
21
102
|
@InputType()
|
|
22
103
|
class ImportBoardAsyncInput {
|
|
@@ -43,6 +124,14 @@ class ImportBoardAsyncInput {
|
|
|
43
124
|
|
|
44
125
|
@Field(() => GraphQLJSON, { nullable: true, description: 'Adapter parse options (excludeLayers, maxEntities, ...).' })
|
|
45
126
|
parseOptions?: any
|
|
127
|
+
|
|
128
|
+
@Field({
|
|
129
|
+
nullable: true,
|
|
130
|
+
description:
|
|
131
|
+
'User-provided context / hints about the drawing — passed to VLM as additional guidance. ' +
|
|
132
|
+
'e.g. "fab lithography zone, central rectangles are stockers, thin lines are OHT rails."'
|
|
133
|
+
})
|
|
134
|
+
userPrompt?: string
|
|
46
135
|
}
|
|
47
136
|
|
|
48
137
|
@Resolver()
|
|
@@ -54,7 +143,7 @@ export class ImportSessionResolver {
|
|
|
54
143
|
@Ctx() context: ResolverContext
|
|
55
144
|
): Promise<ImportSession | null> {
|
|
56
145
|
const { domain } = context.state
|
|
57
|
-
return
|
|
146
|
+
return await fetchImportSession(id, { domain, user: context.state.user }, getRepository(ImportSession) as any)
|
|
58
147
|
}
|
|
59
148
|
|
|
60
149
|
@Query(() => [ImportSession], { description: 'List recent import sessions for a chat session.' })
|
|
@@ -63,12 +152,8 @@ export class ImportSessionResolver {
|
|
|
63
152
|
@Arg('chatSessionId') chatSessionId: string,
|
|
64
153
|
@Ctx() context: ResolverContext
|
|
65
154
|
): Promise<ImportSession[]> {
|
|
66
|
-
const { domain } = context.state
|
|
67
|
-
return await getRepository(ImportSession)
|
|
68
|
-
where: { chatSessionId, domain: { id: domain.id } },
|
|
69
|
-
order: { createdAt: 'DESC' },
|
|
70
|
-
take: 50
|
|
71
|
-
})
|
|
155
|
+
const { domain, user } = context.state
|
|
156
|
+
return await listImportSessionsByChatSession(chatSessionId, { domain, user }, getRepository(ImportSession) as any)
|
|
72
157
|
}
|
|
73
158
|
|
|
74
159
|
@Mutation(() => ImportSession, {
|
|
@@ -80,34 +165,81 @@ export class ImportSessionResolver {
|
|
|
80
165
|
@Ctx() context: ResolverContext
|
|
81
166
|
): Promise<ImportSession> {
|
|
82
167
|
const { domain, user } = context.state
|
|
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
|
-
|
|
168
|
+
return await startImportJobAsync(input, { domain, user }, getRepository(ImportSession) as any)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@Query(() => String, {
|
|
172
|
+
description:
|
|
173
|
+
'@deprecated suggestBoardMeta 사용 권장. 단순 string name 만 반환하는 구버전 query.'
|
|
174
|
+
})
|
|
175
|
+
@Directive('@privilege(category: "board-import", privilege: "query")')
|
|
176
|
+
async suggestBoardName(
|
|
177
|
+
@Arg('input') input: SuggestBoardNameInput,
|
|
178
|
+
@Ctx() context: ResolverContext
|
|
179
|
+
): Promise<string> {
|
|
180
|
+
const { domain } = context.state
|
|
181
|
+
const deps = this._buildSuggestDeps()
|
|
182
|
+
return await suggestBoardName(input, { domain }, deps)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
@Query(() => BoardMetaSuggestion, {
|
|
186
|
+
description:
|
|
187
|
+
'도메인 안에서 충돌하지 않는 Board name + 상세 description 제안. ' +
|
|
188
|
+
'name 은 짧고 심플 (30자 이내, 충돌 회피 (n) suffix), description 은 자세히 ' +
|
|
189
|
+
'(사용자 prompt + AI importStrategy + 카테고리 분포 통계 합성).'
|
|
190
|
+
})
|
|
191
|
+
@Directive('@privilege(category: "board-import", privilege: "query")')
|
|
192
|
+
async suggestBoardMeta(
|
|
193
|
+
@Arg('input') input: SuggestBoardNameInput,
|
|
194
|
+
@Ctx() context: ResolverContext
|
|
195
|
+
): Promise<SuggestBoardMetaResult> {
|
|
196
|
+
const { domain } = context.state
|
|
197
|
+
const deps = this._buildSuggestDeps()
|
|
198
|
+
return await suggestBoardMeta(input, { domain }, deps)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 두 query 가 공유하는 deps wiring — typeorm find idiomatic 패턴 (distinctAlias 회귀 회피). */
|
|
202
|
+
private _buildSuggestDeps() {
|
|
203
|
+
const sessionRepo = getRepository(ImportSession) as any
|
|
204
|
+
const attachmentRepo = getRepository(Attachment) as any
|
|
205
|
+
const boardRepo: SuggestBoardRepoLike = {
|
|
206
|
+
findExistingNames: async ({ domainId, basePrefix }) => {
|
|
207
|
+
// 도메인 격리 + 'basePrefix' 로 시작하는 name 후보 한 번에 조회.
|
|
208
|
+
// 단순 find({ where, select }) — take 없음 → DISTINCT 서브쿼리 미발생.
|
|
209
|
+
const escaped = basePrefix.replace(/[%_\\]/g, ch => '\\' + ch)
|
|
210
|
+
const items = await getRepository(Board).find({
|
|
211
|
+
select: ['name'],
|
|
212
|
+
where: { domain: { id: domainId }, name: Like(`${escaped}%`) }
|
|
213
|
+
})
|
|
214
|
+
return items.map(b => b.name as string)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { sessionRepo, attachmentRepo, boardRepo }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
@Mutation(() => Board, {
|
|
221
|
+
description:
|
|
222
|
+
'완료된 ImportSession 의 결과 boardModel 을 새 Board entity 로 영속화한다. ' +
|
|
223
|
+
'검수 단계 (사용자/AI 가 import 결과를 확인 후 명시적으로 호출) 에서 사용. ' +
|
|
224
|
+
'Board.state="draft" 로 생성되므로 release mutation 으로 별도 발행 필요.'
|
|
225
|
+
})
|
|
226
|
+
@Directive('@privilege(category: "board-import", privilege: "mutation")')
|
|
227
|
+
async materializeImportSession(
|
|
228
|
+
@Arg('input') input: MaterializeImportSessionInput,
|
|
229
|
+
@Ctx() context: ResolverContext
|
|
230
|
+
): Promise<Board> {
|
|
231
|
+
const { domain, user } = context.state
|
|
232
|
+
const sessionRepo = getRepository(ImportSession)
|
|
233
|
+
const boardRepo = getRepository(Board)
|
|
234
|
+
const groupRepo = getRepository(Group)
|
|
235
|
+
|
|
236
|
+
const session = await sessionRepo.findOneBy({ id: input.sessionId, domain: { id: domain.id } })
|
|
237
|
+
|
|
238
|
+
return (await materializeFromSession(
|
|
239
|
+
session as any,
|
|
240
|
+
input,
|
|
241
|
+
{ domain, user },
|
|
242
|
+
{ boardRepo: boardRepo as any, groupRepo: groupRepo as any }
|
|
243
|
+
)) as Board
|
|
112
244
|
}
|
|
113
245
|
}
|
|
@@ -15,8 +15,8 @@ import { getRepository } from '@things-factory/shell'
|
|
|
15
15
|
import { Attachment } from '@things-factory/attachment-base'
|
|
16
16
|
import { STORAGE } from '@things-factory/attachment-base'
|
|
17
17
|
|
|
18
|
-
import {
|
|
19
|
-
import { ImportSession } from './import-session
|
|
18
|
+
import { importBoardVariants } from '../pipeline/index'
|
|
19
|
+
import { ImportSession } from './import-session'
|
|
20
20
|
|
|
21
21
|
export async function runImportJob(sessionId: string): Promise<void> {
|
|
22
22
|
const repo = getRepository(ImportSession)
|
|
@@ -34,8 +34,9 @@ export async function runImportJob(sessionId: string): Promise<void> {
|
|
|
34
34
|
throw new Error(`Attachment ${session.attachmentId} not found`)
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
// attachment-base STORAGE.readFile 으로 본문 로드 (Buffer)
|
|
38
|
-
|
|
37
|
+
// attachment-base STORAGE.readFile 으로 본문 로드 (Buffer).
|
|
38
|
+
// file storage 는 sync, db/s3/azure 는 async — await 으로 양쪽 다 흡수.
|
|
39
|
+
const fileBuffer = (await STORAGE.readFile(attachment.path!, null)) as Buffer
|
|
39
40
|
|
|
40
41
|
await update(repo, sessionId, { status: 'parsing', progress: 20, message: 'Parsing drawing' })
|
|
41
42
|
|
|
@@ -45,7 +46,15 @@ export async function runImportJob(sessionId: string): Promise<void> {
|
|
|
45
46
|
// importBoard 는 내부에서 stage 1~4 모두 수행. 단계별 progress 는 우선 거시적 업데이트.
|
|
46
47
|
await update(repo, sessionId, { status: 'mapping', progress: 50, message: 'Mapping entities' })
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
// userPrompt 는 parseOptions 로 합류 — image-adapter 가 VLM 프롬프트에 사용.
|
|
50
|
+
// 다른 adapter (DXF) 에는 무해 (몰라도 무시).
|
|
51
|
+
const mergedParseOptions = {
|
|
52
|
+
...(opts.parseOptions || {}),
|
|
53
|
+
...(opts.userPrompt ? { userPrompt: opts.userPrompt } : {})
|
|
54
|
+
}
|
|
55
|
+
// AI 가 같은 도면을 다른 관점으로 해석한 multi-variant. parse 한 번, 시안별 mapping + assemble.
|
|
56
|
+
// DXF 등 single-variant adapter 는 1 boardModel 만.
|
|
57
|
+
const result = await importBoardVariants({
|
|
49
58
|
file: fileBuffer,
|
|
50
59
|
filename,
|
|
51
60
|
scopes: opts.scopes,
|
|
@@ -53,22 +62,57 @@ export async function runImportJob(sessionId: string): Promise<void> {
|
|
|
53
62
|
flipY: opts.flipY,
|
|
54
63
|
scale: opts.scale,
|
|
55
64
|
applyBinding: opts.applyBinding,
|
|
56
|
-
parseOptions:
|
|
65
|
+
parseOptions: mergedParseOptions
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
await update(repo, sessionId, { status: 'assembling', progress: 80, message: 'Assembling boards' })
|
|
69
|
+
|
|
70
|
+
// 시안별 카테고리 분포 — 각 variant 의 entities 가 다르므로.
|
|
71
|
+
const variantsWithStats = result.variants.map((v, i) => {
|
|
72
|
+
const dist: Record<string, number> = {}
|
|
73
|
+
for (const c of v.boardModel.components ?? []) {
|
|
74
|
+
const k = c.category ?? 'unknown'
|
|
75
|
+
dist[k] = (dist[k] ?? 0) + 1
|
|
76
|
+
}
|
|
77
|
+
return { variant: v, mapping: result.mappings[i], categoryDistribution: dist }
|
|
57
78
|
})
|
|
58
79
|
|
|
59
|
-
|
|
80
|
+
// default variantId — 첫 variant.
|
|
81
|
+
const defaultVariantId = result.variants[0]?.id
|
|
60
82
|
|
|
83
|
+
// resultJson 구조:
|
|
84
|
+
// - variants: 풀 시안 데이터 (선택용 — boardModel + subject + importStrategy + stats + categoryDist)
|
|
85
|
+
// - boardModel: default variant 의 boardModel (back-compat)
|
|
86
|
+
// - defaultVariantId: 첫 variant 의 id
|
|
87
|
+
// - metadata: viewType / aiClientId / userPrompt 등 시안 무관 공통
|
|
88
|
+
const defaultVariant = variantsWithStats[0]
|
|
61
89
|
const resultJson = JSON.stringify({
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
90
|
+
variants: variantsWithStats.map(({ variant, mapping, categoryDistribution }) => ({
|
|
91
|
+
id: variant.id,
|
|
92
|
+
label: variant.label,
|
|
93
|
+
description: variant.description,
|
|
94
|
+
subjectName: variant.subjectName,
|
|
95
|
+
subjectDescription: variant.subjectDescription,
|
|
96
|
+
importStrategy: variant.importStrategy,
|
|
97
|
+
boardModel: variant.boardModel,
|
|
98
|
+
stats: mapping.stats,
|
|
99
|
+
warnings: mapping.warnings,
|
|
100
|
+
categoryDistribution
|
|
101
|
+
})),
|
|
102
|
+
defaultVariantId,
|
|
103
|
+
boardModel: defaultVariant?.variant.boardModel,
|
|
104
|
+
stats: defaultVariant?.mapping.stats,
|
|
105
|
+
warnings: defaultVariant?.mapping.warnings,
|
|
106
|
+
metadata: result.parse.metadata,
|
|
107
|
+
categoryDistribution: defaultVariant?.categoryDistribution
|
|
65
108
|
})
|
|
66
109
|
|
|
110
|
+
const totalAcross = result.mappings.reduce((s, m) => s + (m.stats.total ?? 0), 0)
|
|
67
111
|
await update(repo, sessionId, {
|
|
68
112
|
status: 'completed',
|
|
69
113
|
progress: 100,
|
|
70
|
-
message: `Completed: ${result.
|
|
71
|
-
totalEntities:
|
|
114
|
+
message: `Completed: ${result.variants.length} variants, ${totalAcross} entities total`,
|
|
115
|
+
totalEntities: defaultVariant?.mapping.stats.total ?? 0,
|
|
72
116
|
resultJson,
|
|
73
117
|
completedAt: new Date()
|
|
74
118
|
})
|
|
@@ -1,8 +1,24 @@
|
|
|
1
|
-
import { ImportSession } from './import-session
|
|
2
|
-
import { ImportSessionResolver } from './import-session-resolver
|
|
1
|
+
import { ImportSession } from './import-session'
|
|
2
|
+
import { ImportSessionResolver } from './import-session-resolver'
|
|
3
3
|
|
|
4
4
|
export { ImportSession, ImportSessionResolver }
|
|
5
|
-
export { runImportJob } from './import-worker
|
|
5
|
+
export { runImportJob } from './import-worker'
|
|
6
|
+
export {
|
|
7
|
+
startImportJobAsync,
|
|
8
|
+
fetchImportSession,
|
|
9
|
+
listImportSessionsByChatSession,
|
|
10
|
+
describeAdapter,
|
|
11
|
+
type StartImportJobInput,
|
|
12
|
+
type ImportActionsContext,
|
|
13
|
+
type SessionRepoLike,
|
|
14
|
+
type AdapterInfo
|
|
15
|
+
} from './import-actions'
|
|
16
|
+
export {
|
|
17
|
+
materializeFromSession,
|
|
18
|
+
type MaterializeRequest,
|
|
19
|
+
type MaterializeContext,
|
|
20
|
+
type MaterializeDeps
|
|
21
|
+
} from './materialize-from-session'
|
|
6
22
|
|
|
7
23
|
export const entities = [ImportSession]
|
|
8
24
|
export const resolverClasses = [ImportSessionResolver]
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* materializeFromSession — 단위 테스트.
|
|
3
|
+
*
|
|
4
|
+
* Resolver IO (typeorm getRepository, ResolverContext) 와 분리된 순수 비즈니스 로직 검증.
|
|
5
|
+
* mock repository 로 검증 시나리오 / Board persist 정책 / 예외 케이스 회귀 방지.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
materializeFromSession,
|
|
9
|
+
type BoardRepoLike,
|
|
10
|
+
type GroupRepoLike,
|
|
11
|
+
type MaterializeContext,
|
|
12
|
+
type SessionLike
|
|
13
|
+
} from './materialize-from-session'
|
|
14
|
+
|
|
15
|
+
// ── Test fixtures ────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const DOMAIN = { id: 'domain-1', name: 'test-domain' }
|
|
18
|
+
const USER = { id: 'user-1', name: 'tester' }
|
|
19
|
+
const CONTEXT: MaterializeContext = { domain: DOMAIN, user: USER }
|
|
20
|
+
|
|
21
|
+
const SESSION_BASE: SessionLike = {
|
|
22
|
+
id: 'session-1',
|
|
23
|
+
status: 'completed',
|
|
24
|
+
result: {
|
|
25
|
+
boardModel: {
|
|
26
|
+
width: 1920,
|
|
27
|
+
height: 1080,
|
|
28
|
+
components: [{ refid: 1, type: 'rect' }]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeBoardRepo(overrides: Partial<BoardRepoLike> = {}): BoardRepoLike & {
|
|
34
|
+
saveCalls: any[]
|
|
35
|
+
findOneByCalls: any[]
|
|
36
|
+
} {
|
|
37
|
+
const findOneByCalls: any[] = []
|
|
38
|
+
const saveCalls: any[] = []
|
|
39
|
+
const repo: any = {
|
|
40
|
+
findOneByCalls,
|
|
41
|
+
saveCalls,
|
|
42
|
+
findOneBy: async (criteria: any) => {
|
|
43
|
+
findOneByCalls.push(criteria)
|
|
44
|
+
return null
|
|
45
|
+
},
|
|
46
|
+
save: async (entity: any) => {
|
|
47
|
+
saveCalls.push(entity)
|
|
48
|
+
return { ...entity, id: 'new-board-1' }
|
|
49
|
+
},
|
|
50
|
+
...overrides
|
|
51
|
+
}
|
|
52
|
+
return repo
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeGroupRepo(overrides: Partial<GroupRepoLike> = {}): GroupRepoLike {
|
|
56
|
+
return {
|
|
57
|
+
findOneBy: async () => null,
|
|
58
|
+
...overrides
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const baseInput = {
|
|
63
|
+
sessionId: 'session-1',
|
|
64
|
+
name: 'New Imported Board'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── 검증 / 예외 ──────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
describe('materializeFromSession — 검증', () => {
|
|
70
|
+
test('session 이 null 이면 not found 에러', async () => {
|
|
71
|
+
await expect(
|
|
72
|
+
materializeFromSession(null, baseInput, CONTEXT, {
|
|
73
|
+
boardRepo: makeBoardRepo(),
|
|
74
|
+
groupRepo: makeGroupRepo()
|
|
75
|
+
})
|
|
76
|
+
).rejects.toThrow(/not found/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("status 가 'completed' 가 아니면 cannot materialize 에러", async () => {
|
|
80
|
+
const session: SessionLike = { ...SESSION_BASE, status: 'parsing' }
|
|
81
|
+
await expect(
|
|
82
|
+
materializeFromSession(session, baseInput, CONTEXT, {
|
|
83
|
+
boardRepo: makeBoardRepo(),
|
|
84
|
+
groupRepo: makeGroupRepo()
|
|
85
|
+
})
|
|
86
|
+
).rejects.toThrow(/cannot materialize/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('result.boardModel 이 없으면 no boardModel 에러', async () => {
|
|
90
|
+
const session: SessionLike = { ...SESSION_BASE, result: { boardModel: undefined } }
|
|
91
|
+
await expect(
|
|
92
|
+
materializeFromSession(session, baseInput, CONTEXT, {
|
|
93
|
+
boardRepo: makeBoardRepo(),
|
|
94
|
+
groupRepo: makeGroupRepo()
|
|
95
|
+
})
|
|
96
|
+
).rejects.toThrow(/no boardModel/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('이름이 같은 도메인에 이미 있으면 already taken 에러', async () => {
|
|
100
|
+
const boardRepo = makeBoardRepo({
|
|
101
|
+
findOneBy: async () => ({ id: 'existing-1', name: baseInput.name })
|
|
102
|
+
})
|
|
103
|
+
await expect(
|
|
104
|
+
materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
|
|
105
|
+
boardRepo: boardRepo as any,
|
|
106
|
+
groupRepo: makeGroupRepo()
|
|
107
|
+
})
|
|
108
|
+
).rejects.toThrow(/already taken/)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// ── 정상 흐름 ────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe('materializeFromSession — Board 생성 정책', () => {
|
|
115
|
+
test("저장 시 state='draft' + sourceImportSessionId=session.id", async () => {
|
|
116
|
+
const boardRepo = makeBoardRepo()
|
|
117
|
+
await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
|
|
118
|
+
boardRepo: boardRepo as any,
|
|
119
|
+
groupRepo: makeGroupRepo()
|
|
120
|
+
})
|
|
121
|
+
expect(boardRepo.saveCalls).toHaveLength(1)
|
|
122
|
+
const saved = boardRepo.saveCalls[0]
|
|
123
|
+
expect(saved.state).toBe('draft')
|
|
124
|
+
expect(saved.sourceImportSessionId).toBe('session-1')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('boardModel 이 JSON 문자열로 직렬화되어 model 필드에 저장됨', async () => {
|
|
128
|
+
const boardRepo = makeBoardRepo()
|
|
129
|
+
await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
|
|
130
|
+
boardRepo: boardRepo as any,
|
|
131
|
+
groupRepo: makeGroupRepo()
|
|
132
|
+
})
|
|
133
|
+
const saved = boardRepo.saveCalls[0]
|
|
134
|
+
expect(typeof saved.model).toBe('string')
|
|
135
|
+
expect(JSON.parse(saved.model)).toEqual(SESSION_BASE.result!.boardModel)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test("type 미지정 시 default 'main'", async () => {
|
|
139
|
+
const boardRepo = makeBoardRepo()
|
|
140
|
+
await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
|
|
141
|
+
boardRepo: boardRepo as any,
|
|
142
|
+
groupRepo: makeGroupRepo()
|
|
143
|
+
})
|
|
144
|
+
expect(boardRepo.saveCalls[0].type).toBe('main')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test("type='popup' 입력 시 그대로 저장", async () => {
|
|
148
|
+
const boardRepo = makeBoardRepo()
|
|
149
|
+
await materializeFromSession(
|
|
150
|
+
SESSION_BASE,
|
|
151
|
+
{ ...baseInput, type: 'popup' },
|
|
152
|
+
CONTEXT,
|
|
153
|
+
{ boardRepo: boardRepo as any, groupRepo: makeGroupRepo() }
|
|
154
|
+
)
|
|
155
|
+
expect(boardRepo.saveCalls[0].type).toBe('popup')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('thumbnail 미지정 시 기본 empty thumbnail 사용', async () => {
|
|
159
|
+
const boardRepo = makeBoardRepo()
|
|
160
|
+
await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
|
|
161
|
+
boardRepo: boardRepo as any,
|
|
162
|
+
groupRepo: makeGroupRepo()
|
|
163
|
+
})
|
|
164
|
+
expect(boardRepo.saveCalls[0].thumbnail).toMatch(/^data:image\/gif;base64,/)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('thumbnail 지정 시 그대로 저장', async () => {
|
|
168
|
+
const boardRepo = makeBoardRepo()
|
|
169
|
+
const customThumbnail = 'data:image/png;base64,custom'
|
|
170
|
+
await materializeFromSession(
|
|
171
|
+
SESSION_BASE,
|
|
172
|
+
{ ...baseInput, thumbnail: customThumbnail },
|
|
173
|
+
CONTEXT,
|
|
174
|
+
{ boardRepo: boardRepo as any, groupRepo: makeGroupRepo() }
|
|
175
|
+
)
|
|
176
|
+
expect(boardRepo.saveCalls[0].thumbnail).toBe(customThumbnail)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('groupId 지정 시 groupRepo 조회 결과를 group 으로 attach', async () => {
|
|
180
|
+
const boardRepo = makeBoardRepo()
|
|
181
|
+
const group = { id: 'group-1', name: 'test-group' }
|
|
182
|
+
const groupRepo = makeGroupRepo({ findOneBy: async () => group })
|
|
183
|
+
await materializeFromSession(
|
|
184
|
+
SESSION_BASE,
|
|
185
|
+
{ ...baseInput, groupId: 'group-1' },
|
|
186
|
+
CONTEXT,
|
|
187
|
+
{ boardRepo: boardRepo as any, groupRepo }
|
|
188
|
+
)
|
|
189
|
+
expect(boardRepo.saveCalls[0].group).toEqual(group)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('groupId 미지정 시 group=undefined 로 저장', async () => {
|
|
193
|
+
const boardRepo = makeBoardRepo()
|
|
194
|
+
await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
|
|
195
|
+
boardRepo: boardRepo as any,
|
|
196
|
+
groupRepo: makeGroupRepo()
|
|
197
|
+
})
|
|
198
|
+
expect(boardRepo.saveCalls[0].group).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('createQueryBuilder 미지원 환경에서 sortOrder=0 fallback', async () => {
|
|
202
|
+
const boardRepo = makeBoardRepo()
|
|
203
|
+
expect(boardRepo.createQueryBuilder).toBeUndefined()
|
|
204
|
+
await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
|
|
205
|
+
boardRepo: boardRepo as any,
|
|
206
|
+
groupRepo: makeGroupRepo()
|
|
207
|
+
})
|
|
208
|
+
expect(boardRepo.saveCalls[0].sortOrder).toBe(0)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('createQueryBuilder 지원 환경에서 sortOrder = MIN(sortOrder) - 1', async () => {
|
|
212
|
+
const boardRepo = makeBoardRepo({
|
|
213
|
+
createQueryBuilder: () =>
|
|
214
|
+
({
|
|
215
|
+
select: function (this: any) {
|
|
216
|
+
return this
|
|
217
|
+
},
|
|
218
|
+
where: function (this: any) {
|
|
219
|
+
return this
|
|
220
|
+
},
|
|
221
|
+
getRawOne: async () => ({ minSortOrder: 5 })
|
|
222
|
+
}) as any
|
|
223
|
+
})
|
|
224
|
+
await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
|
|
225
|
+
boardRepo: boardRepo as any,
|
|
226
|
+
groupRepo: makeGroupRepo()
|
|
227
|
+
})
|
|
228
|
+
expect(boardRepo.saveCalls[0].sortOrder).toBe(4)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('빈 보드 (minSortOrder=null) 환경에서 sortOrder=0', async () => {
|
|
232
|
+
const boardRepo = makeBoardRepo({
|
|
233
|
+
createQueryBuilder: () =>
|
|
234
|
+
({
|
|
235
|
+
select: function (this: any) {
|
|
236
|
+
return this
|
|
237
|
+
},
|
|
238
|
+
where: function (this: any) {
|
|
239
|
+
return this
|
|
240
|
+
},
|
|
241
|
+
getRawOne: async () => ({ minSortOrder: null })
|
|
242
|
+
}) as any
|
|
243
|
+
})
|
|
244
|
+
await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
|
|
245
|
+
boardRepo: boardRepo as any,
|
|
246
|
+
groupRepo: makeGroupRepo()
|
|
247
|
+
})
|
|
248
|
+
expect(boardRepo.saveCalls[0].sortOrder).toBe(0)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('domain 과 creator/updater 가 context 에서 그대로 전파', async () => {
|
|
252
|
+
const boardRepo = makeBoardRepo()
|
|
253
|
+
await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
|
|
254
|
+
boardRepo: boardRepo as any,
|
|
255
|
+
groupRepo: makeGroupRepo()
|
|
256
|
+
})
|
|
257
|
+
const saved = boardRepo.saveCalls[0]
|
|
258
|
+
expect(saved.domain).toBe(DOMAIN)
|
|
259
|
+
expect(saved.creator).toBe(USER)
|
|
260
|
+
expect(saved.updater).toBe(USER)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('이름 충돌 검사가 도메인 id 기반', async () => {
|
|
264
|
+
const boardRepo = makeBoardRepo()
|
|
265
|
+
await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
|
|
266
|
+
boardRepo: boardRepo as any,
|
|
267
|
+
groupRepo: makeGroupRepo()
|
|
268
|
+
})
|
|
269
|
+
expect(boardRepo.findOneByCalls[0]).toEqual({
|
|
270
|
+
name: baseInput.name,
|
|
271
|
+
domain: { id: DOMAIN.id }
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
})
|