@things-factory/headless-twin 10.1.29 → 10.1.32
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/engine/kpi-fold.js +9 -0
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.d.ts +1 -3
- package/dist-server/service/twin-attention/twin-attention-query.js +15 -6
- package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
- package/dist-server/service/twin-forecast/forecast-metrics.js +26 -3
- package/dist-server/service/twin-forecast/forecast-metrics.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +15 -0
- package/dist-server/service/twin-journal/twin-journal-query.js +18 -3
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/package.json +8 -8
- package/server/engine/kpi-fold.ts +10 -1
- package/server/service/twin-attention/twin-attention-query.ts +13 -6
- package/server/service/twin-forecast/forecast-metrics.ts +28 -3
- package/server/service/twin-journal/twin-journal-query.ts +19 -2
- package/test/attention-digest.test.ts +26 -0
- package/test/event-type-split.test.ts +23 -0
- package/test/forecast-metrics.test.ts +43 -0
- package/test/kpi-fold.test.ts +28 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -28,17 +28,24 @@ export class TwinAttentionQuery {
|
|
|
28
28
|
* 경고는 **합집합이 그대로 뜻이 있는 유일한 렌즈**다. 예측은 합칠 수 없다(P50 두 개를 더할 수 없다) —
|
|
29
29
|
* 그래서 예측은 여전히 인스턴스별로 답하고 화면이 나란히 놓는다.
|
|
30
30
|
*/
|
|
31
|
-
@Arg('spaceId', { nullable: true }) spaceId?: string
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
@Arg('spaceId', { nullable: true }) spaceId?: string
|
|
32
|
+
/*
|
|
33
|
+
* ── 자르는 인자는 여기 없다 (2026-09-19, 수석 아키텍트 판정) ──────────────
|
|
34
|
+
*
|
|
35
|
+
* `limit` 이 있었다. 자르면서 **몇 건을 잘랐는지는 답에 싣지 못했다** — 답이 맨 목록이라 실을
|
|
36
|
+
* 자리가 없었기 때문이다. 자를 수 있는 문이 잘린 수를 못 말하면, 스무 건만 받은 화면은 그것이
|
|
37
|
+
* 전부인지 이백 중 스무 건인지 구별할 수 없다.
|
|
38
|
+
*
|
|
39
|
+
* 자르려는 소비자는 `twinAttentionDigest` 를 쓴다 — 그 문은 `attentionTotal` 을 함께 답하고,
|
|
40
|
+
* 자리별 색을 **전량을 근거로** 계산한다. 이 문은 자르지 않는다.
|
|
41
|
+
*/
|
|
34
42
|
): Promise<any[]> {
|
|
35
|
-
if (spaceId) return (await TwinEngine.attentionsOfSpace(context.state.domain.id, spaceId
|
|
43
|
+
if (spaceId) return (await TwinEngine.attentionsOfSpace(context.state.domain.id, spaceId)).attentions
|
|
36
44
|
if (!instanceId || !TwinEngine.owns(context.state.domain.id, instanceId)) return [] // 테넌트 격리
|
|
37
45
|
// 라이브 커널 스냅샷(fresh) 우선 — snapshot()=resync() 는 캐시라 커맨드 델타 지연(recover 와 동일 원천 사용).
|
|
38
46
|
const kernel = TwinEngine.kernel(context.state.domain.id, instanceId)
|
|
39
47
|
const snap = kernel?.getSnapshot?.() ?? TwinEngine.snapshot(context.state.domain.id, instanceId)
|
|
40
|
-
|
|
41
|
-
return limit && limit > 0 ? digestAttentions(list, limit).attentions : list
|
|
48
|
+
return snap?.attentions ?? []
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
/**
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* 그래서 지점을 직접 더하지 않고 **구간 값**을 읽는다.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { isFlowOrder, isOrderTerminal } from '@operato/ops-contract'
|
|
19
|
+
|
|
18
20
|
/** 스냅샷 → 수 하나. 값이 없으면 0 이 아니라 **그 지표를 그릴 수 없다**는 뜻이지만, 그래프 계약이
|
|
19
21
|
* 수를 요구하므로 0 을 낸다 — 그래서 에너지 지표는 「없음」이 0 과 헷갈리지 않는 것만 골랐다. */
|
|
20
22
|
export type MetricFn = (s: any) => number
|
|
@@ -23,10 +25,33 @@ export const FORECAST_METRICS: Record<string, MetricFn> = {
|
|
|
23
25
|
items: s => (s?.items || []).length,
|
|
24
26
|
occupancy: s => (s?.locations || []).reduce((a: number, n: any) => a + (n.occupancy || 0), 0),
|
|
25
27
|
tasks: s => (s?.tasks || []).length,
|
|
26
|
-
|
|
28
|
+
/*
|
|
29
|
+
* ── 세는 것은 **흐름 오더**다 (2026-09-19, ADR-0076 보탬 ⑥) ────────────────
|
|
30
|
+
*
|
|
31
|
+
* 받는 오더(입고 · 반품 입고)는 도착이 세우고 도착이 이행한다. 그대로 세면 「나간 것」이 받을 때마다
|
|
32
|
+
* 한 건씩 늘고 백로그는 영영 안 줄어든다 — 예측 그래프가 있지도 않은 일을 그린다. 무엇이 흐름
|
|
33
|
+
* 오더인지는 계약의 `isFlowOrder` 한 곳이 정한다(여기서 `kind` 를 다시 적지 않는다).
|
|
34
|
+
*/
|
|
35
|
+
orders: s => (s?.orders || []).filter(isFlowOrder).length,
|
|
27
36
|
// 이행 지표 — 라이브 예측의 핵심 질문("언제 다 나가나·백로그 언제 풀리나").
|
|
28
|
-
shipped: s => (s?.orders || []).filter((o: any) => o.status === 'shipped').length,
|
|
29
|
-
|
|
37
|
+
shipped: s => (s?.orders || []).filter(isFlowOrder).filter((o: any) => o.status === 'shipped').length,
|
|
38
|
+
/*
|
|
39
|
+
* ── 백로그는 **「종결인가」**로 묻는다 (2026-09-19) ─────────────────────────
|
|
40
|
+
*
|
|
41
|
+
* 낱말 둘(`shipped`·`cancelled`)로 물으면 **원본이 다른 낱말을 쓰는 미러에서 끝난 오더가 영영
|
|
42
|
+
* 백로그에 남는다.** `status` 는 열린 축이고(표준도 `RequestState` 를 열거하지 않는다) 실 시스템은
|
|
43
|
+
* `completed`·`FINISHED` 를 쓴다 — 그 트윈의 백로그 곡선은 내려가지 않는다. 종결 판정은 커널이
|
|
44
|
+
* 한 곳에서 한다(`isOrderTerminal` — 낱말이 아니라 **양**을 1차 근거로 본다).
|
|
45
|
+
*
|
|
46
|
+
* `shipped` 한 낱말은 남는다. 그것은 이 커널이 **자기가 쓰는 말**이고(WMS 의 출하 완료), 계약의
|
|
47
|
+
* 종결 목록(`completed` · `cancelled`)에는 없다. 양을 말하지 않는 스냅샷에서 그 오더가 백로그로
|
|
48
|
+
* 되돌아오지 않게 둘을 함께 본다 — 빼는 쪽이라 이 셈이 전보다 커지는 일은 없다. 커널의 낱말이
|
|
49
|
+
* 계약의 종결 목록에 들어오는 날 이 줄이 지워진다(아키텍트에게 올림).
|
|
50
|
+
*/
|
|
51
|
+
backlog: s =>
|
|
52
|
+
(s?.orders || [])
|
|
53
|
+
.filter(isFlowOrder)
|
|
54
|
+
.filter((o: any) => !isOrderTerminal(o) && o.status !== 'shipped').length,
|
|
30
55
|
|
|
31
56
|
/*
|
|
32
57
|
* ── 에너지 ────────────────────────────────────────────────────────────────
|
|
@@ -42,6 +42,22 @@ function toDateValue(v: any): any {
|
|
|
42
42
|
return Array.isArray(v) ? v.map(one) : one(v)
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 자유 검색어가 펼쳐지는 축 — **목록과 건수가 같은 것을 본다**(2026-09-19).
|
|
47
|
+
*
|
|
48
|
+
* 전부 인덱스 가능한 승격 컬럼이다(payload JSON 안이 아니라). 프레임워크 질의 빌더는 이 목록에 없는
|
|
49
|
+
* 컬럼의 LIKE 를 경고 후 무시한다 — 인덱스 없는 전체 스캔을 막기 위해서다.
|
|
50
|
+
*
|
|
51
|
+
* ── 왜 상수인가 ────────────────────────────────────────────────────────────
|
|
52
|
+
* 목록과 건수가 **다른 빌더**로 만들어진다(빠른 갈래의 건수 probe). 두 자리에 배열을 따로 적으면
|
|
53
|
+
* 한쪽만 늘어나는 날 목록은 걸러지고 건수는 안 걸러진다 — 화면에 「한 건」과 「전체 수」가 나란히 선다.
|
|
54
|
+
*
|
|
55
|
+
* 지금은 그 일이 **일어날 수 없다**: 빠른 갈래는 검색어가 있으면 아예 안 탄다
|
|
56
|
+
* (`event-type-split-shape.ts` — 시험 「자유 검색어」가 그 축을 붙든다). 그래도 같은 상수를 쓴다 —
|
|
57
|
+
* 그 규칙이 언젠가 느슨해질 때 이 자리가 조용히 갈리지 않게.
|
|
58
|
+
*/
|
|
59
|
+
export const JOURNAL_SEARCHABLES = ['epc', 'orderId', 'locationId', 'moverId', 'bizStep', 'eventType', 'correlationId'] as const
|
|
60
|
+
|
|
45
61
|
@Resolver()
|
|
46
62
|
export class TwinJournalQuery {
|
|
47
63
|
@Query(returns => [TwinEvent], {
|
|
@@ -191,6 +207,7 @@ export class TwinJournalQuery {
|
|
|
191
207
|
*
|
|
192
208
|
* 정렬 기본값은 revision DESC(최신순) — 저널의 자연 순서이자 ix_twin_event_0 가 그대로 타는 축.
|
|
193
209
|
*/
|
|
210
|
+
|
|
194
211
|
@Query(returns => TwinEventList, {
|
|
195
212
|
description:
|
|
196
213
|
'List twin journal events with the standard list contract (filters, pagination, sortings) plus the total match count. ' +
|
|
@@ -286,7 +303,7 @@ export class TwinJournalQuery {
|
|
|
286
303
|
repository: getRepository(TwinEvent),
|
|
287
304
|
params: { ...effective, pagination: { page: 1, limit: COUNT_CAP + 1 } },
|
|
288
305
|
domain,
|
|
289
|
-
searchables: [],
|
|
306
|
+
searchables: [...JOURNAL_SEARCHABLES],
|
|
290
307
|
sortables: ['eventTime', 'revision', 'eventType', 'bizStep'],
|
|
291
308
|
defaultLimit: COUNT_CAP + 1,
|
|
292
309
|
maxLimit: COUNT_CAP + 1
|
|
@@ -314,7 +331,7 @@ export class TwinJournalQuery {
|
|
|
314
331
|
params: effective,
|
|
315
332
|
domain,
|
|
316
333
|
/* 전부 인덱스 가능한 승격 컬럼 — 자유 검색어는 서버가 이 축들로 펼친다. */
|
|
317
|
-
searchables: [
|
|
334
|
+
searchables: [...JOURNAL_SEARCHABLES],
|
|
318
335
|
/* 정렬은 인덱스가 받쳐 주는 축으로만 — 큰 저널에서 임의 컬럼 정렬은 전체 정렬 스캔이다. */
|
|
319
336
|
sortables: ['eventTime', 'revision', 'eventType', 'bizStep'],
|
|
320
337
|
defaultLimit: 100,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { test } from 'node:test'
|
|
15
15
|
import assert from 'node:assert/strict'
|
|
16
|
+
import { readFile } from 'node:fs/promises'
|
|
16
17
|
|
|
17
18
|
import { digestAttentions, compareAttentions, mergeLensAttentions } from '../server/engine/attention-digest.ts'
|
|
18
19
|
|
|
@@ -133,3 +134,28 @@ test('★ 렌즈가 하나도 없으면 빈 요약 — 공간에 배치된 트
|
|
|
133
134
|
assert.equal(d.attentionTotal, 0)
|
|
134
135
|
assert.deepEqual(d.severityByLocation, {})
|
|
135
136
|
})
|
|
137
|
+
|
|
138
|
+
/*
|
|
139
|
+
* ── 자를 수 있는 문만 자른다 (2026-09-19, 수석 아키텍트 판정) ────────────────
|
|
140
|
+
*
|
|
141
|
+
* 목록 문(`twinAttentions`)에 `limit` 이 있었다. 자르면서 **몇 건을 잘랐는지는 답에 실을 자리가
|
|
142
|
+
* 없었다** — 답이 맨 목록이라서다. 그러면 스무 건을 받은 화면은 그것이 전부인지 이백 중 스물인지
|
|
143
|
+
* 구별할 수 없고, 그 구별이 없으면 「괜찮아졌다」로 읽힌다(위 ②와 같은 자리).
|
|
144
|
+
*
|
|
145
|
+
* 그래서 자르는 일은 **자른 수를 말할 수 있는 문**에만 둔다. 이 검사는 그 규칙을 소스에서 센다 —
|
|
146
|
+
* 인자를 되돌려 놓으면 빨강이 된다.
|
|
147
|
+
*/
|
|
148
|
+
test('★ 목록 문은 자르지 않는다 — 자르는 인자는 전체 개수를 함께 내는 문에만 있다', async () => {
|
|
149
|
+
const source = await readFile(new URL('../server/service/twin-attention/twin-attention-query.ts', import.meta.url), 'utf-8')
|
|
150
|
+
|
|
151
|
+
const listAt = source.indexOf('async twinAttentions(')
|
|
152
|
+
const digestAt = source.indexOf('async twinAttentionDigest(')
|
|
153
|
+
assert.ok(listAt >= 0 && digestAt > listAt, '두 문을 못 찾았다 — 이 검사는 아무것도 지키지 않는다')
|
|
154
|
+
|
|
155
|
+
const list = source.slice(listAt, digestAt)
|
|
156
|
+
const digest = source.slice(digestAt)
|
|
157
|
+
|
|
158
|
+
assert.ok(!/@Arg\(\s*'limit'/.test(list), '목록 문에 자르는 인자가 있다 — 자른 수를 답에 실을 자리가 없다')
|
|
159
|
+
assert.ok(/@Arg\(\s*'limit'/.test(digest), '자르는 문이 없어졌다 — 화면이 상위 N 을 받을 길이 없다')
|
|
160
|
+
assert.ok(/digestAttentions\(/.test(digest), '자르는 문은 전체 개수를 함께 내는 함수를 지나야 한다')
|
|
161
|
+
})
|
|
@@ -153,3 +153,26 @@ test('빈 단계 값은 받지 않는다 — 모든 행을 뜻하는지 없는
|
|
|
153
153
|
)
|
|
154
154
|
assert.equal(shape, null)
|
|
155
155
|
})
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
/*
|
|
159
|
+
* ── 목록과 건수가 같은 축을 본다 (2026-09-19) ───────────────────────────────
|
|
160
|
+
*
|
|
161
|
+
* 빠른 갈래는 건수를 **다른 빌더**로 센다. 그 빌더가 검색 축을 빈 배열로 선언하고 있었다 — 목록은
|
|
162
|
+
* 걸러지고 건수는 안 걸러지는 모양이다(UX 레인이 검색 길을 세다 짚었다).
|
|
163
|
+
*
|
|
164
|
+
* **지금 그 일은 일어날 수 없다**: 검색어가 있으면 빠른 갈래를 아예 안 탄다(위 「자유 검색어」가 그
|
|
165
|
+
* 축을 붙든다). 그래서 이것은 산 결함이 아니라 **잠긴 함정**이고, 두 자리가 같은 상수를 보게 해서
|
|
166
|
+
* 닫는다 — 저 규칙이 언젠가 느슨해지면 그때 조용히 갈릴 자리다.
|
|
167
|
+
*/
|
|
168
|
+
test('★ 건수 probe 와 목록이 같은 검색 축을 쓴다 — 한쪽만 걸러지면 화면이 「한 건 / 전체 수」를 말한다', () => {
|
|
169
|
+
const uses = [...journal.matchAll(/searchables:\s*([^,\n]+)/g)].map(m => m[1].trim())
|
|
170
|
+
|
|
171
|
+
assert.ok(uses.length >= 2, '검색 축을 선언하는 자리가 둘이어야 한다(목록 · 건수 probe)')
|
|
172
|
+
assert.deepEqual(
|
|
173
|
+
[...new Set(uses)],
|
|
174
|
+
['[...JOURNAL_SEARCHABLES]'],
|
|
175
|
+
'두 자리가 같은 상수를 봐야 한다 — 배열을 따로 적으면 한쪽만 늘어난다'
|
|
176
|
+
)
|
|
177
|
+
assert.doesNotMatch(journal, /searchables:\s*\[\]/, '빈 목록은 「검색어를 조용히 버린다」는 뜻이다')
|
|
178
|
+
})
|
|
@@ -25,6 +25,49 @@ test('물류 지표 — 기존 여섯은 그대로 센다', () => {
|
|
|
25
25
|
assert.equal(FORECAST_METRICS.backlog(s), 1)
|
|
26
26
|
})
|
|
27
27
|
|
|
28
|
+
/*
|
|
29
|
+
* ── 받는 오더는 흐름 지표에서 빠진다 (2026-09-19, ADR-0076 보탬 ⑥) ──────────
|
|
30
|
+
*
|
|
31
|
+
* 입고 · 반품 입고는 도착이 세우고 도착이 이행한다. 세면 「나간 것」이 받을 때마다 늘고 백로그는
|
|
32
|
+
* 영영 안 줄어든다 — 그래프가 있지도 않은 일을 그린다.
|
|
33
|
+
*/
|
|
34
|
+
test('★ 입고 · 반품 입고는 세지 않는다 — 그 오더는 나가지 않는다', () => {
|
|
35
|
+
const s = {
|
|
36
|
+
orders: [
|
|
37
|
+
{ kind: 'outbound', status: 'shipped' },
|
|
38
|
+
{ kind: 'outbound', status: 'picking' },
|
|
39
|
+
{ kind: 'inbound', status: 'received', requested: 10, fulfilled: 10 },
|
|
40
|
+
{ kind: 'return', status: 'received', requested: 3, fulfilled: 3 }
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
assert.equal(FORECAST_METRICS.orders(s), 2, '흐름 오더 둘만 센다')
|
|
44
|
+
assert.equal(FORECAST_METRICS.shipped(s), 1)
|
|
45
|
+
assert.equal(FORECAST_METRICS.backlog(s), 1, '받은 오더가 백로그에 남으면 곡선이 안 내려간다')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* ── 백로그는 「종결인가」로 묻는다 ──────────────────────────────────────────
|
|
50
|
+
*
|
|
51
|
+
* 낱말 둘로 물을 때 원본이 `completed` 를 쓰는 미러에서 끝난 오더가 영영 백로그에 남았다. `status` 는
|
|
52
|
+
* 열린 축이고 실 시스템은 자기 낱말을 쓴다 — 이 셈은 낱말이 아니라 양을 1차 근거로 본다.
|
|
53
|
+
*/
|
|
54
|
+
test('★ 원본의 낱말로 끝난 오더도 백로그에서 빠진다', () => {
|
|
55
|
+
const s = {
|
|
56
|
+
orders: [
|
|
57
|
+
{ kind: 'outbound', status: 'completed', requested: 5, fulfilled: 5 },
|
|
58
|
+
{ kind: 'outbound', status: 'FINISHED', requested: 2, fulfilled: 2 },
|
|
59
|
+
{ kind: 'outbound', status: 'picking', requested: 4, fulfilled: 1 }
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
assert.equal(FORECAST_METRICS.backlog(s), 1, '아직 안 끝난 하나만 백로그다')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('낱말을 말하지 않은 오더는 그대로 센다 — 지금까지의 거동이다', () => {
|
|
66
|
+
const s = { orders: [{ status: 'picking' }, { status: 'shipped' }] }
|
|
67
|
+
assert.equal(FORECAST_METRICS.orders(s), 2)
|
|
68
|
+
assert.equal(FORECAST_METRICS.shipped(s), 1)
|
|
69
|
+
})
|
|
70
|
+
|
|
28
71
|
test('에너지 지표 — 커널이 낸 값을 그대로 읽는다(다시 계산하지 않는다)', () => {
|
|
29
72
|
const s = {
|
|
30
73
|
energy: {
|
package/test/kpi-fold.test.ts
CHANGED
|
@@ -156,6 +156,34 @@ test('오더 완료: 양이 답한다 — 낱말이 진행 중이라 말해도',
|
|
|
156
156
|
assert.equal(r.throughput.orders, 1, '10 중 10 은 끝났고 10 중 9 는 아니다')
|
|
157
157
|
})
|
|
158
158
|
|
|
159
|
+
/*
|
|
160
|
+
* ── 받는 오더는 처리량이 아니다 (2026-09-19, ADR-0076 보탬 ⑥) ────────────────
|
|
161
|
+
*
|
|
162
|
+
* 입고 · 반품 입고는 세우는 순간 `fulfilled >= requested` 다 — 받았다는 사실이 곧 그 오더의 완료이기
|
|
163
|
+
* 때문이다. 그대로 세면 도착마다 처리량이 한 건씩 부풀고, 그 수는 화면에서 「많이 해냈다」로 읽힌다.
|
|
164
|
+
*/
|
|
165
|
+
test('★ 입고 · 반품 입고는 처리량에서 빠진다 — 받은 것은 해낸 것이 아니다', () => {
|
|
166
|
+
const kinded = (orderId: string, kind: string, sec: number): KpiEvent => ({
|
|
167
|
+
eventType: 'order.status',
|
|
168
|
+
eventTime: at(sec),
|
|
169
|
+
payload: { data: { orderId, kind, status: 'received', requested: 10, fulfilled: 10 } }
|
|
170
|
+
})
|
|
171
|
+
const out = (orderId: string, sec: number): KpiEvent => ({
|
|
172
|
+
eventType: 'order.status',
|
|
173
|
+
eventTime: at(sec),
|
|
174
|
+
payload: { data: { orderId, kind: 'outbound', status: 'completed', requested: 5, fulfilled: 5 } }
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const r = foldKpi([kinded('i1', 'inbound', 10), kinded('r1', 'return', 20), out('o1', 30)], WINDOW)
|
|
178
|
+
|
|
179
|
+
assert.equal(r.throughput.orders, 1, '나간 오더 하나만 센다')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('낱말을 말하지 않은 오더는 그대로 센다 — 지금까지의 거동이다', () => {
|
|
183
|
+
const r = foldKpi([order('o1', 'completed', 10)], WINDOW)
|
|
184
|
+
assert.equal(r.throughput.orders, 1)
|
|
185
|
+
})
|
|
186
|
+
|
|
159
187
|
test('오더 완료: 같은 오더를 한 번만 센다 — 폴링 원본이 다시 보내도', () => {
|
|
160
188
|
/*
|
|
161
189
|
* 폴링하는 원본은 같은 오더의 종결을 여러 주기에 걸쳐 다시 보낼 수 있다(결함이 아니라 폴링의 성질).
|