@things-factory/ai-assistant 10.1.100 → 10.1.102
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/client/components/entry-review.ts +5 -0
- package/client/utils/entry-proposal.ts +11 -1
- package/dist-client/components/entry-review.js +6 -0
- package/dist-client/components/entry-review.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/entry-proposal.d.ts +1 -0
- package/dist-client/utils/entry-proposal.js +11 -1
- package/dist-client/utils/entry-proposal.js.map +1 -1
- package/dist-server/service/data-entry/entry-apply.js +26 -2
- package/dist-server/service/data-entry/entry-apply.js.map +1 -1
- package/dist-server/service/data-entry/entry-target.d.ts +30 -3
- package/dist-server/service/data-entry/entry-target.js +92 -2
- package/dist-server/service/data-entry/entry-target.js.map +1 -1
- package/dist-server/service/data-entry/entry-tools.d.ts +12 -0
- package/dist-server/service/data-entry/entry-tools.js +8 -2
- package/dist-server/service/data-entry/entry-tools.js.map +1 -1
- package/dist-server/service/data-entry/entry-update.js +1 -1
- package/dist-server/service/data-entry/entry-update.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/data-entry/data-entry-update.test.ts +46 -0
- package/server/service/data-entry/data-entry.test.ts +32 -1
- package/server/service/data-entry/entry-apply.ts +25 -4
- package/server/service/data-entry/entry-target.ts +99 -4
- package/server/service/data-entry/entry-tools.ts +10 -2
- package/server/service/data-entry/entry-update.ts +2 -1
- package/test/entry-proposal.test.ts +11 -0
- package/translations/en.json +3 -1
- package/translations/ja.json +3 -1
- package/translations/ko.json +3 -1
- package/translations/ms.json +3 -1
- package/translations/zh.json +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.102",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -51,5 +51,5 @@
|
|
|
51
51
|
"copyfiles": "^2.4.1",
|
|
52
52
|
"rimraf": "^5.0.0"
|
|
53
53
|
},
|
|
54
|
-
"gitHead": "
|
|
54
|
+
"gitHead": "8bba629dd321caa7c047d52f451d61d2678cc6e2"
|
|
55
55
|
}
|
|
@@ -265,3 +265,49 @@ describe('a field that points at another record', () => {
|
|
|
265
265
|
})
|
|
266
266
|
})
|
|
267
267
|
|
|
268
|
+
describe('a record with lines', () => {
|
|
269
|
+
const advice: EntryTarget = {
|
|
270
|
+
key: 'warehouse.inbound-advice',
|
|
271
|
+
labelKey: 'x',
|
|
272
|
+
description: 'one inbound advice line.',
|
|
273
|
+
mutation: 'mutation ($advice: InboundAdviceInput!) { receiveInboundAdvice(advice: $advice) { name } }',
|
|
274
|
+
fields: [
|
|
275
|
+
{ name: 'despatchAdvice', type: 'string', required: true },
|
|
276
|
+
{ name: 'epcClass', type: 'string', required: true },
|
|
277
|
+
{ name: 'quantity', type: 'number', required: true }
|
|
278
|
+
],
|
|
279
|
+
group: {
|
|
280
|
+
by: 'despatchAdvice',
|
|
281
|
+
variables: rows => ({ advice: { name: rows[0].values.despatchAdvice, lines: rows.map(row => ({ epcClass: row.values.epcClass, quantity: row.values.quantity })) } }),
|
|
282
|
+
appliedOf: data => ({ id: data?.receiveInboundAdvice?.name })
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const line = (despatchAdvice: string, epcClass: string, quantity: any) => ({ values: { despatchAdvice, epcClass, quantity }, sources: [{ where: 'x' }] })
|
|
286
|
+
|
|
287
|
+
it('★ the lines of one record go in by one call; a record with a line kept out stays out whole', async () => {
|
|
288
|
+
declareEntryTarget(advice)
|
|
289
|
+
const proposed: any = await proposeEntries(advice, { rows: [line('DA-1', 'A', 20), line('DA-1', 'B', 5), line('DA-2', 'C', 'many'), line('DA-2', 'D', 1)] }, { state: {} }, async () => new Map(), run)
|
|
290
|
+
expect(proposed.counts).toMatchObject({ rows: 4, groups: 2, groupsReady: 1 })
|
|
291
|
+
expect(proposed.groupBy).toBe('despatchAdvice')
|
|
292
|
+
const sent: any[] = []
|
|
293
|
+
const outcome = await applyEntryProposal('g1', {
|
|
294
|
+
messages: [{ id: 'm1', proposals: [{ ...proposed, key: 'g1' }] }],
|
|
295
|
+
answered: false,
|
|
296
|
+
passesDoor: async () => true,
|
|
297
|
+
execute: async (_document, variables) => (sent.push(variables), { data: { receiveInboundAdvice: { name: variables.advice.name } } }),
|
|
298
|
+
state: {}
|
|
299
|
+
})
|
|
300
|
+
expect(sent).toEqual([{ advice: { name: 'DA-1', lines: [{ epcClass: 'A', quantity: 20 }, { epcClass: 'B', quantity: 5 }] } }])
|
|
301
|
+
expect(outcome.rows).toEqual([
|
|
302
|
+
{ index: 0, ok: true, id: 'DA-1' },
|
|
303
|
+
{ index: 1, ok: true, id: 'DA-1' },
|
|
304
|
+
{ index: 2, ok: false, error: 'quantity: not-a-number' },
|
|
305
|
+
{ index: 3, ok: false, error: 'group-kept-out (row 3)' }
|
|
306
|
+
])
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('a group by a field that is not declared is refused at boot', () => {
|
|
310
|
+
expect(() => declareEntryTarget({ ...advice, group: { ...advice.group!, by: 'order' } })).toThrow(/group by 'order'/)
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Data entry from material (design/ai/data-entry.md) - the rules a row passes, what the tool hands back,
|
|
3
3
|
* and what the server asks again before it writes.
|
|
4
4
|
*/
|
|
5
|
-
import { clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, printedTotalOf, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
|
|
5
|
+
import { offsetAt, zonedDateTimes, clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, printedTotalOf, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
|
|
6
6
|
import { entryCategoryOf, proposeEntries } from './entry-tools'
|
|
7
7
|
import { applyEntryProposal, entryProposalAnswered, findEntryProposal, type EntryApplyDeps } from './entry-apply'
|
|
8
8
|
import { modeToolCategories } from '../assistant-mode/resolve'
|
|
@@ -341,3 +341,34 @@ describe('the model names the total its own way', () => {
|
|
|
341
341
|
})
|
|
342
342
|
})
|
|
343
343
|
|
|
344
|
+
describe('a moment', () => {
|
|
345
|
+
const shift: EntryTarget = { ...expense, fields: [{ name: 'startTime', type: 'datetime', required: true }] }
|
|
346
|
+
it('reads the ways a moment is written - without an offset it stays without one', () => {
|
|
347
|
+
const { rows } = normalizeEntryRows(shift, [
|
|
348
|
+
{ values: { startTime: '2026-10-01 08:30' }, sources: [{ where: 'x' }] },
|
|
349
|
+
{ values: { startTime: '2026.10.1 8:05:09' }, sources: [{ where: 'x' }] },
|
|
350
|
+
{ values: { startTime: '2026-10-01T08:30:00+0900' }, sources: [{ where: 'x' }] },
|
|
351
|
+
{ values: { startTime: '2026-10-01 25:00' }, sources: [{ where: 'x' }] }
|
|
352
|
+
])
|
|
353
|
+
expect(rows.map(row => row.values.startTime)).toEqual(['2026-10-01T08:30:00', '2026-10-01T08:05:09', '2026-10-01T08:30:00+09:00', '2026-10-01 25:00'])
|
|
354
|
+
expect(rows[3].issues).toEqual([{ field: 'startTime', code: 'not-a-datetime', blocking: true }])
|
|
355
|
+
})
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
describe("a moment is the domain's wall time", () => {
|
|
359
|
+
it('★ a time without an offset gets the domain time zone\'s offset at that day - DST taken at the day, not today', () => {
|
|
360
|
+
expect(offsetAt('Asia/Seoul', '2026-10-01T08:30:00')).toBe('+09:00')
|
|
361
|
+
expect(offsetAt('America/New_York', '2026-07-01T08:30:00')).toBe('-04:00')
|
|
362
|
+
expect(offsetAt('America/New_York', '2026-12-01T08:30:00')).toBe('-05:00')
|
|
363
|
+
const rows = [{ values: { at: '2026-10-01T08:30:00', other: '2026-10-01T08:30:00+00:00' } }]
|
|
364
|
+
zonedDateTimes([{ name: 'at', type: 'datetime' }, { name: 'other', type: 'datetime' }], rows, 'Asia/Seoul')
|
|
365
|
+
expect(rows[0].values).toEqual({ at: '2026-10-01T08:30:00+09:00', other: '2026-10-01T08:30:00+00:00' })
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('the tool gives the domain\'s offset to what the model wrote', async () => {
|
|
369
|
+
const shift: EntryTarget = { ...expense, fields: [{ name: 'startTime', type: 'datetime', required: true }] }
|
|
370
|
+
const result: any = await proposeEntries(shift, { rows: [{ values: { startTime: '2026-10-01 08:30' }, sources: [{ where: 'x' }] }] }, { state: { domain: { timezone: 'Asia/Seoul' } } }, async () => new Map(), async () => ({}))
|
|
371
|
+
expect(result.rows[0].values.startTime).toBe('2026-10-01T08:30:00+09:00')
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { PROPOSAL_OUTCOME_PREFIX, PROPOSAL_VERDICTS } from '../chat-message/proposal-outcome.js'
|
|
14
14
|
import { readRecords, stillAsProposed, type ChangeRow } from './entry-update.js'
|
|
15
|
-
import { changeToolName, resolveReferences, entryDoorOf, entryTargetOf, entryToolName, rootFieldOf, isBlocked, recordEntryEvent, normalizeEntryRows, withCheckedIssues, type EntryRow, type EntryTarget } from './entry-target.js'
|
|
15
|
+
import { changeToolName, zonedDateTimes, entryGroups, resolveReferences, entryDoorOf, entryTargetOf, entryToolName, rootFieldOf, isBlocked, recordEntryEvent, normalizeEntryRows, withCheckedIssues, type EntryRow, type EntryTarget } from './entry-target.js'
|
|
16
16
|
|
|
17
17
|
export interface StoredProposalMessage {
|
|
18
18
|
id: string
|
|
@@ -86,7 +86,7 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
|
|
|
86
86
|
/* Read again from the stored values - the same rules, and today's records for the target's checks. */
|
|
87
87
|
let { rows } = normalizeEntryRows(target, (proposal.rows ?? []).map((row: EntryRow) => ({ values: row.values, sources: row.sources })))
|
|
88
88
|
/* A reference that was not settled on the card is asked again - never entered as a name. */
|
|
89
|
-
rows = await resolveReferences(target.fields, rows, deps.execute)
|
|
89
|
+
rows = await resolveReferences(target.fields, zonedDateTimes(target.fields, rows, deps.state?.domain?.timezone), deps.execute)
|
|
90
90
|
if (target.check) {
|
|
91
91
|
try {
|
|
92
92
|
rows = withCheckedIssues(rows, await target.check(rows, { state: deps.state }))
|
|
@@ -97,14 +97,35 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
const results: EntryRowResult[] = []
|
|
100
|
-
|
|
100
|
+
if (target.group) {
|
|
101
|
+
/* One call per group; a group with a row kept out stays out whole - an order missing a line is not entered. */
|
|
102
|
+
for (const [, members] of entryGroups(rows, target.group.by)) {
|
|
103
|
+
const blocked = members.find(isBlocked)
|
|
104
|
+
if (blocked) {
|
|
105
|
+
const issue = blocked.issues.find(one => one.blocking)!
|
|
106
|
+
for (const row of members)
|
|
107
|
+
results.push({ index: row.index, ok: false, error: row === blocked ? (issue.field ? `${issue.field}: ${issue.code}` : issue.code) : `group-kept-out (row ${blocked.index + 1})` })
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const answer = await deps.execute(target.mutation, target.group.variables(members))
|
|
112
|
+
const error = answer.errors?.length ? answer.errors.map(one => one.message).join('; ') || 'refused' : undefined
|
|
113
|
+
const applied = error ? undefined : target.group.appliedOf?.(answer.data, members)
|
|
114
|
+
for (const row of members) results.push(error ? { index: row.index, ok: false, error } : { index: row.index, ok: true, ...(applied?.id ? { id: applied.id } : {}), ...(applied?.label ? { label: applied.label } : {}) })
|
|
115
|
+
} catch (error: any) {
|
|
116
|
+
for (const row of members) results.push({ index: row.index, ok: false, error: error?.message || 'refused' })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
results.sort((a, b) => a.index - b.index)
|
|
120
|
+
}
|
|
121
|
+
for (const row of target.group ? [] : rows) {
|
|
101
122
|
if (isBlocked(row)) {
|
|
102
123
|
const issue = row.issues.find(one => one.blocking)!
|
|
103
124
|
results.push({ index: row.index, ok: false, error: issue.field ? `${issue.field}: ${issue.code}` : issue.code })
|
|
104
125
|
continue
|
|
105
126
|
}
|
|
106
127
|
try {
|
|
107
|
-
const answer = await deps.execute(target.mutation, target.variables(row))
|
|
128
|
+
const answer = await deps.execute(target.mutation, target.variables!(row))
|
|
108
129
|
if (answer.errors?.length) {
|
|
109
130
|
results.push({ index: row.index, ok: false, error: answer.errors.map(one => one.message).join('; ') || 'refused' })
|
|
110
131
|
continue
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { parse } from 'graphql'
|
|
16
16
|
|
|
17
|
-
export type EntryFieldType = 'string' | 'text' | 'number' | 'integer' | 'date' | 'boolean' | 'enum' | 'reference'
|
|
17
|
+
export type EntryFieldType = 'string' | 'text' | 'number' | 'integer' | 'date' | 'datetime' | 'boolean' | 'enum' | 'reference'
|
|
18
18
|
|
|
19
19
|
export interface EntryField {
|
|
20
20
|
name: string
|
|
@@ -68,6 +68,8 @@ export interface EntryIssue {
|
|
|
68
68
|
code: string
|
|
69
69
|
messageKey?: string
|
|
70
70
|
params?: Record<string, unknown>
|
|
71
|
+
/** A param that is a code the screen words - { status: 'text.state-' } shows `down` as 고장. */
|
|
72
|
+
paramKeys?: Record<string, string>
|
|
71
73
|
/** A blocking issue keeps the row out when the person presses the button. */
|
|
72
74
|
blocking: boolean
|
|
73
75
|
}
|
|
@@ -127,10 +129,20 @@ export interface EntryTarget {
|
|
|
127
129
|
* to a person typing it in.
|
|
128
130
|
*/
|
|
129
131
|
mutation: string
|
|
130
|
-
/** The mutation's variables for one row. */
|
|
131
|
-
variables(row: EntryRow): Record<string, unknown>
|
|
132
|
+
/** The mutation's variables for one row. Absent when the target enters rows by group (below). */
|
|
133
|
+
variables?(row: EntryRow): Record<string, unknown>
|
|
132
134
|
/** What the conversation says of an entered row - its id and a short name. */
|
|
133
135
|
appliedOf?(data: any, row: EntryRow): EntryApplied | undefined
|
|
136
|
+
/**
|
|
137
|
+
* A record with lines - one order, several item lines. Rows that share the value of `by` (the order number) are one
|
|
138
|
+
* record, entered by one call of the mutation with `variables(rows)`. If any row of a group is kept out, the whole
|
|
139
|
+
* group is: an order missing a line must not go in unseen.
|
|
140
|
+
*/
|
|
141
|
+
group?: {
|
|
142
|
+
by: string
|
|
143
|
+
variables(rows: EntryRow[]): Record<string, unknown>
|
|
144
|
+
appliedOf?(data: any, rows: EntryRow[]): EntryApplied | undefined
|
|
145
|
+
}
|
|
134
146
|
/** At most this many rows in one proposal. Default 200. */
|
|
135
147
|
maxRows?: number
|
|
136
148
|
/**
|
|
@@ -214,7 +226,10 @@ export function declareEntryTarget(target: EntryTarget): void {
|
|
|
214
226
|
} catch (error: any) {
|
|
215
227
|
throw new Error(`[ai-assistant] entry target '${target.key}': its mutation ${error?.message ?? 'does not parse'}.`)
|
|
216
228
|
}
|
|
217
|
-
if (typeof target.variables !== 'function'
|
|
229
|
+
if (typeof target.variables !== 'function' && typeof target.group?.variables !== 'function')
|
|
230
|
+
throw new Error(`[ai-assistant] entry target '${target.key}' has no variables (per row, or per group).`)
|
|
231
|
+
if (target.group && !target.fields.some(field => field.name === target.group!.by))
|
|
232
|
+
throw new Error(`[ai-assistant] entry target '${target.key}': group by '${target.group.by}' is not a declared field.`)
|
|
218
233
|
if (target.update) {
|
|
219
234
|
for (const [what, document, operation] of [
|
|
220
235
|
['update.read', target.update.read?.query, 'query'],
|
|
@@ -308,6 +323,22 @@ function toDate(value: unknown): string | undefined {
|
|
|
308
323
|
return `${match[1]}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
|
309
324
|
}
|
|
310
325
|
|
|
326
|
+
/**
|
|
327
|
+
* A moment as `YYYY-MM-DDTHH:mm:ss`, with the offset when it was written - "2026-10-01 08:30", "2026.10.1 8:30",
|
|
328
|
+
* an ISO string. Without an offset it stays without one: the application reads it in its own time zone, as it
|
|
329
|
+
* reads a time typed into its own screen.
|
|
330
|
+
*/
|
|
331
|
+
function toDateTime(value: unknown): string | undefined {
|
|
332
|
+
if (typeof value !== 'string') return undefined
|
|
333
|
+
const match = value.trim().match(/^(\d{4})[-./](\d{1,2})[-./](\d{1,2})[ T](\d{1,2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?\s*(Z|[+-]\d{2}:?\d{2})?$/)
|
|
334
|
+
if (!match) return undefined
|
|
335
|
+
const day = toDate(`${match[1]}-${match[2]}-${match[3]}`)
|
|
336
|
+
const [hour, minute, second] = [Number(match[4]), Number(match[5]), Number(match[6] ?? 0)]
|
|
337
|
+
if (!day || hour > 23 || minute > 59 || second > 59) return undefined
|
|
338
|
+
const offset = match[7] ? (match[7] === 'Z' ? 'Z' : match[7].replace(/^([+-]\d{2})(\d{2})$/, '$1:$2')) : ''
|
|
339
|
+
return `${day}T${String(hour).padStart(2, '0')}:${match[5]}:${String(second).padStart(2, '0')}${offset}`
|
|
340
|
+
}
|
|
341
|
+
|
|
311
342
|
function toBoolean(value: unknown): boolean | undefined {
|
|
312
343
|
if (typeof value === 'boolean') return value
|
|
313
344
|
if (typeof value !== 'string') return undefined
|
|
@@ -332,6 +363,10 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
|
|
|
332
363
|
const read = toDate(value)
|
|
333
364
|
return read === undefined ? { code: 'not-a-date' } : { read }
|
|
334
365
|
}
|
|
366
|
+
case 'datetime': {
|
|
367
|
+
const read = toDateTime(value)
|
|
368
|
+
return read === undefined ? { code: 'not-a-datetime' } : { read }
|
|
369
|
+
}
|
|
335
370
|
case 'boolean': {
|
|
336
371
|
const read = toBoolean(value)
|
|
337
372
|
return read === undefined ? { code: 'not-a-boolean' } : { read }
|
|
@@ -452,6 +487,12 @@ export function writtenForms(type: EntryFieldType, value: unknown): string[] {
|
|
|
452
487
|
}
|
|
453
488
|
return [...forms].map(form => (negative ? `-${form}` : form))
|
|
454
489
|
}
|
|
490
|
+
if (type === 'datetime') {
|
|
491
|
+
/* A moment is found by its day and its hh:mm - the file writes seconds and zones its own way. */
|
|
492
|
+
const match = typeof value === 'string' ? value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/) : null
|
|
493
|
+
if (!match) return []
|
|
494
|
+
return writtenForms('date', match[1])
|
|
495
|
+
}
|
|
455
496
|
if (type === 'date') {
|
|
456
497
|
const match = typeof value === 'string' ? value.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null
|
|
457
498
|
if (!match) return []
|
|
@@ -631,3 +672,57 @@ export async function resolveReferences<T extends { values: Record<string, unkno
|
|
|
631
672
|
return rows
|
|
632
673
|
}
|
|
633
674
|
|
|
675
|
+
/** The rows by group, in the order groups first appear - `[key, rows]`. A row without the key is a group of its own. */
|
|
676
|
+
export function entryGroups<T extends { index: number; values: Record<string, unknown> }>(rows: T[], by: string): [string, T[]][] {
|
|
677
|
+
const groups = new Map<string, T[]>()
|
|
678
|
+
for (const row of rows) {
|
|
679
|
+
const value = row.values[by]
|
|
680
|
+
const key = value === undefined || value === null || value === '' ? `\u0000row-${row.index}` : String(typeof value === 'object' ? (value as any).id ?? (value as any).text : value)
|
|
681
|
+
groups.set(key, [...(groups.get(key) ?? []), row])
|
|
682
|
+
}
|
|
683
|
+
return [...groups.entries()]
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** The UTC offset a time zone has at a wall time - "+09:00". DST is taken at that day, not today. */
|
|
687
|
+
export function offsetAt(timeZone: string | undefined, wall: string): string | undefined {
|
|
688
|
+
const match = wall.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/)
|
|
689
|
+
if (!match) return undefined
|
|
690
|
+
const [y, mo, d, h, mi, se] = match.slice(1).map(Number)
|
|
691
|
+
let format: Intl.DateTimeFormat
|
|
692
|
+
try {
|
|
693
|
+
format = new Intl.DateTimeFormat('en-US', { timeZone: timeZone || undefined, timeZoneName: 'longOffset', year: 'numeric' })
|
|
694
|
+
} catch {
|
|
695
|
+
return undefined
|
|
696
|
+
}
|
|
697
|
+
const minutesOf = (at: number) => {
|
|
698
|
+
const name = format.formatToParts(new Date(at)).find(part => part.type === 'timeZoneName')?.value ?? 'GMT'
|
|
699
|
+
const m = name.match(/GMT([+-])(\d{2}):?(\d{2})?/)
|
|
700
|
+
return m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0)) : 0
|
|
701
|
+
}
|
|
702
|
+
/* The offset at the wall time read as UTC, then once more at the instant that gives - right across a DST change. */
|
|
703
|
+
const asUtc = Date.UTC(y, mo - 1, d, h, mi, se)
|
|
704
|
+
const minutes = minutesOf(asUtc - minutesOf(asUtc) * 60_000)
|
|
705
|
+
const sign = minutes < 0 ? '-' : '+'
|
|
706
|
+
const abs = Math.abs(minutes)
|
|
707
|
+
return `${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* A moment written without an offset is the domain's wall time - "08:30" in a factory is 08:30 there. Each such
|
|
712
|
+
* value gets the domain time zone's offset at that day, so an API taking DateTimeISO accepts it and reads the moment
|
|
713
|
+
* meant (plant refused every downtime log without it, 2026-10-01).
|
|
714
|
+
*/
|
|
715
|
+
export function zonedDateTimes<T extends { values: Record<string, unknown> }>(fields: EntryField[], rows: T[], timeZone: string | undefined): T[] {
|
|
716
|
+
const moments = fields.filter(field => field.type === 'datetime')
|
|
717
|
+
if (!moments.length) return rows
|
|
718
|
+
for (const row of rows) {
|
|
719
|
+
for (const field of moments) {
|
|
720
|
+
const value = row.values[field.name]
|
|
721
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(value)) continue
|
|
722
|
+
const offset = offsetAt(timeZone, value)
|
|
723
|
+
if (offset) row.values[field.name] = `${value}${offset}`
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return rows
|
|
727
|
+
}
|
|
728
|
+
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
entryToolName,
|
|
23
23
|
isBlocked,
|
|
24
24
|
normalizeEntryRows,
|
|
25
|
+
zonedDateTimes,
|
|
26
|
+
entryGroups,
|
|
25
27
|
referenceDoorsOf,
|
|
26
28
|
resolveReferences,
|
|
27
29
|
recordEntryEvent,
|
|
@@ -42,6 +44,7 @@ function fieldSchema(field: EntryField): any {
|
|
|
42
44
|
field.description,
|
|
43
45
|
field.required ? 'required' : 'optional',
|
|
44
46
|
field.type === 'date' ? 'YYYY-MM-DD' : undefined,
|
|
47
|
+
field.type === 'datetime' ? 'YYYY-MM-DD HH:mm, with the offset only when the material gives one' : undefined,
|
|
45
48
|
field.type === 'enum' ? `one of: ${field.values!.join(', ')}` : undefined
|
|
46
49
|
]
|
|
47
50
|
.filter(Boolean)
|
|
@@ -78,7 +81,7 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
|
|
|
78
81
|
const cited = [...normalized.flatMap(row => row.sources.map(source => source.attachmentId)), declared?.attachmentId].filter((id): id is string => typeof id === 'string' && !!id)
|
|
79
82
|
const texts = cited.length ? await loadTexts(cited, context).catch(() => new Map<string, string>()) : new Map<string, string>()
|
|
80
83
|
/* A name the model wrote for another record is settled to its id by the application's query, as the person. */
|
|
81
|
-
const resolved = await resolveReferences(target.fields, normalized, run ?? runAsCaller(context))
|
|
84
|
+
const resolved = await resolveReferences(target.fields, zonedDateTimes(target.fields, normalized, context?.state?.domain?.timezone), run ?? runAsCaller(context))
|
|
82
85
|
const read = verifyRowsAgainstSources(resolved, target.fields, texts)
|
|
83
86
|
|
|
84
87
|
let rows: EntryRow[] = read
|
|
@@ -92,6 +95,9 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
|
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
const blocked = rows.filter(isBlocked).length
|
|
98
|
+
/* With lines grouped into records, the card counts the records and says which are kept out whole. */
|
|
99
|
+
const groups = target.group ? entryGroups(rows, target.group.by) : undefined
|
|
100
|
+
const groupCounts = groups ? { groups: groups.length, groupsReady: groups.filter(([, members]) => !members.some(isBlocked)).length } : {}
|
|
95
101
|
const totals = entryTotals(rows, target.fields, declared, texts)
|
|
96
102
|
const verified = rows.filter(row => row.evidence).length
|
|
97
103
|
const unmatched = rows.filter(row => row.issues.some(issue => issue.code === 'not-in-source')).length
|
|
@@ -105,7 +111,8 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
|
|
|
105
111
|
icon: 'playlist_add',
|
|
106
112
|
columns: columnsOf(target),
|
|
107
113
|
rows,
|
|
108
|
-
counts: { rows: rows.length, ready: rows.length - blocked, blocked, cut, verified, unmatched },
|
|
114
|
+
counts: { rows: rows.length, ready: rows.length - blocked, blocked, cut, verified, unmatched, ...groupCounts },
|
|
115
|
+
...(target.group ? { groupBy: target.group.by } : {}),
|
|
109
116
|
...(totals.length ? { totals } : {}),
|
|
110
117
|
...(typeof args?.note === 'string' && args.note.trim() ? { reason: args.note.trim().slice(0, 300) } : {}),
|
|
111
118
|
...(checkFailed ? { checkFailed } : {}),
|
|
@@ -191,6 +198,7 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
|
|
|
191
198
|
doors: [entryDoorOf(target.mutation), ...referenceDoorsOf(target.fields)],
|
|
192
199
|
description:
|
|
193
200
|
`Propose records to enter: ${target.description} ` +
|
|
201
|
+
(target.group ? `Each row is one line; the rows of one record share the same ${target.group.by}. ` : '') +
|
|
194
202
|
'Pass every record found in the material as one row. The rows are shown to the person as a card; nothing is written until they press it.',
|
|
195
203
|
schema: {
|
|
196
204
|
type: 'object',
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
findToolName,
|
|
15
15
|
readFieldValue,
|
|
16
16
|
resolveReferences,
|
|
17
|
+
zonedDateTimes,
|
|
17
18
|
sameFieldValue,
|
|
18
19
|
type EntryField,
|
|
19
20
|
type EntryIssue,
|
|
@@ -147,7 +148,7 @@ export async function proposeChanges(target: EntryTarget, args: any, context: an
|
|
|
147
148
|
} catch (error: any) {
|
|
148
149
|
return { rejected: true, error: 'read-failed', message: `could not read the records: ${error?.message ?? error}` }
|
|
149
150
|
}
|
|
150
|
-
const rows = await resolveReferences(changeableFields(target), changeRows(target, picked, records), run)
|
|
151
|
+
const rows = await resolveReferences(changeableFields(target), zonedDateTimes(changeableFields(target), changeRows(target, picked, records), context?.state?.domain?.timezone), run)
|
|
151
152
|
const blocked = rows.filter(blockedChange).length
|
|
152
153
|
return {
|
|
153
154
|
proposed: true,
|
|
@@ -77,4 +77,15 @@ describe('the entry card', () => {
|
|
|
77
77
|
'changed 1 records.\nrow 2: amount: changed-since'
|
|
78
78
|
)
|
|
79
79
|
})
|
|
80
|
+
|
|
81
|
+
it('★ the button counts only the lines of records that go in whole', () => {
|
|
82
|
+
const p = { ...card('g'), groupBy: 'order', rows: [
|
|
83
|
+
{ index: 0, values: { order: 'A' }, sources: [], issues: [] },
|
|
84
|
+
{ index: 1, values: { order: 'A' }, sources: [], issues: [] },
|
|
85
|
+
{ index: 2, values: { order: 'B' }, sources: [], issues: [] },
|
|
86
|
+
{ index: 3, values: { order: 'B' }, sources: [], issues: [{ code: 'required', field: 'qty', blocking: true }] }
|
|
87
|
+
] }
|
|
88
|
+
expect(entryReadyCount(p)).toBe(2)
|
|
89
|
+
})
|
|
80
90
|
})
|
|
91
|
+
|
package/translations/en.json
CHANGED
|
@@ -273,5 +273,7 @@
|
|
|
273
273
|
"ai-assistant.entry.issue.reference-not-found": "\"{text}\" - not found",
|
|
274
274
|
"ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" - matches several: {candidates}",
|
|
275
275
|
"ai-assistant.entry.issue.reference-unreadable": "\"{text}\" - could not be looked up",
|
|
276
|
-
"ai-assistant.entry.issue.not-a-reference": "not a record it can point to"
|
|
276
|
+
"ai-assistant.entry.issue.not-a-reference": "not a record it can point to",
|
|
277
|
+
"ai-assistant.entry.groups": "{count} records",
|
|
278
|
+
"ai-assistant.entry.groups-kept-out": "{count} records kept out whole"
|
|
277
279
|
}
|
package/translations/ja.json
CHANGED
|
@@ -273,5 +273,7 @@
|
|
|
273
273
|
"ai-assistant.entry.issue.reference-not-found": "「{text}」— 見つかりません",
|
|
274
274
|
"ai-assistant.entry.issue.reference-ambiguous": "「{text}」— 複数に一致: {candidates}",
|
|
275
275
|
"ai-assistant.entry.issue.reference-unreadable": "「{text}」— 検索できませんでした",
|
|
276
|
-
"ai-assistant.entry.issue.not-a-reference": "参照先の記録が分かりません"
|
|
276
|
+
"ai-assistant.entry.issue.not-a-reference": "参照先の記録が分かりません",
|
|
277
|
+
"ai-assistant.entry.groups": "{count}件",
|
|
278
|
+
"ai-assistant.entry.groups-kept-out": "{count}件は行が止まり全体を入力しません"
|
|
277
279
|
}
|
package/translations/ko.json
CHANGED
|
@@ -273,5 +273,7 @@
|
|
|
273
273
|
"ai-assistant.entry.issue.reference-not-found": "\"{text}\" — 찾지 못했습니다",
|
|
274
274
|
"ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" — 여럿과 맞습니다: {candidates}",
|
|
275
275
|
"ai-assistant.entry.issue.reference-unreadable": "\"{text}\" — 찾아보지 못했습니다",
|
|
276
|
-
"ai-assistant.entry.issue.not-a-reference": "가리키는 기록을 알 수 없습니다"
|
|
276
|
+
"ai-assistant.entry.issue.not-a-reference": "가리키는 기록을 알 수 없습니다",
|
|
277
|
+
"ai-assistant.entry.groups": "{count}건",
|
|
278
|
+
"ai-assistant.entry.groups-kept-out": "{count}건은 줄이 막혀 통째로 입력하지 않습니다"
|
|
277
279
|
}
|
package/translations/ms.json
CHANGED
|
@@ -273,5 +273,7 @@
|
|
|
273
273
|
"ai-assistant.entry.issue.reference-not-found": "\"{text}\" - tidak ditemui",
|
|
274
274
|
"ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" - sepadan dengan beberapa: {candidates}",
|
|
275
275
|
"ai-assistant.entry.issue.reference-unreadable": "\"{text}\" - tidak dapat dicari",
|
|
276
|
-
"ai-assistant.entry.issue.not-a-reference": "bukan rekod yang boleh dirujuk"
|
|
276
|
+
"ai-assistant.entry.issue.not-a-reference": "bukan rekod yang boleh dirujuk",
|
|
277
|
+
"ai-assistant.entry.groups": "{count} rekod",
|
|
278
|
+
"ai-assistant.entry.groups-kept-out": "{count} rekod ditahan sepenuhnya"
|
|
277
279
|
}
|
package/translations/zh.json
CHANGED
|
@@ -273,5 +273,7 @@
|
|
|
273
273
|
"ai-assistant.entry.issue.reference-not-found": "\"{text}\" — 未找到",
|
|
274
274
|
"ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" — 匹配多个:{candidates}",
|
|
275
275
|
"ai-assistant.entry.issue.reference-unreadable": "\"{text}\" — 无法查找",
|
|
276
|
-
"ai-assistant.entry.issue.not-a-reference": "无法确定所指记录"
|
|
276
|
+
"ai-assistant.entry.issue.not-a-reference": "无法确定所指记录",
|
|
277
|
+
"ai-assistant.entry.groups": "{count}条",
|
|
278
|
+
"ai-assistant.entry.groups-kept-out": "{count}条因有行被拦而整体不录入"
|
|
277
279
|
}
|