@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.
Files changed (32) hide show
  1. package/client/components/entry-review.ts +5 -0
  2. package/client/utils/entry-proposal.ts +11 -1
  3. package/dist-client/components/entry-review.js +6 -0
  4. package/dist-client/components/entry-review.js.map +1 -1
  5. package/dist-client/tsconfig.tsbuildinfo +1 -1
  6. package/dist-client/utils/entry-proposal.d.ts +1 -0
  7. package/dist-client/utils/entry-proposal.js +11 -1
  8. package/dist-client/utils/entry-proposal.js.map +1 -1
  9. package/dist-server/service/data-entry/entry-apply.js +26 -2
  10. package/dist-server/service/data-entry/entry-apply.js.map +1 -1
  11. package/dist-server/service/data-entry/entry-target.d.ts +30 -3
  12. package/dist-server/service/data-entry/entry-target.js +92 -2
  13. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  14. package/dist-server/service/data-entry/entry-tools.d.ts +12 -0
  15. package/dist-server/service/data-entry/entry-tools.js +8 -2
  16. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  17. package/dist-server/service/data-entry/entry-update.js +1 -1
  18. package/dist-server/service/data-entry/entry-update.js.map +1 -1
  19. package/dist-server/tsconfig.tsbuildinfo +1 -1
  20. package/package.json +2 -2
  21. package/server/service/data-entry/data-entry-update.test.ts +46 -0
  22. package/server/service/data-entry/data-entry.test.ts +32 -1
  23. package/server/service/data-entry/entry-apply.ts +25 -4
  24. package/server/service/data-entry/entry-target.ts +99 -4
  25. package/server/service/data-entry/entry-tools.ts +10 -2
  26. package/server/service/data-entry/entry-update.ts +2 -1
  27. package/test/entry-proposal.test.ts +11 -0
  28. package/translations/en.json +3 -1
  29. package/translations/ja.json +3 -1
  30. package/translations/ko.json +3 -1
  31. package/translations/ms.json +3 -1
  32. 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.100",
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": "e3c2444ded42290fdc77b189f5befa419f575f9b"
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
- for (const row of rows) {
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') throw new Error(`[ai-assistant] entry target '${target.key}' has no variables.`)
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
+
@@ -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
  }
@@ -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
  }
@@ -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
  }
@@ -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
  }
@@ -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
  }