mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 16:21:59 +00:00
Excel 期 5:列表按期累积、穿插的小计、表格对象起草、CSV 走配方(E)
为什么:每月一份的明细导出是最常见的场景,期 4 之前列表只能每期替换;夹在中间的小计会让规则起草失败;
表格对象这个现成的边界信号没用上;CSV 不能走配方。四项都复用现有的执行器、核对、累积、溯源,不另起一套。
- 列表按期累积:ListBlock.period 二选一——{column, check: within_context} 按日期列分期,试运行多一条结构类核对
D(每行的日期都在本期统计期内,空着也不行);{synthesize: 统计期} 每行加「统计期起」「统计期止」两列,列表在
统计期定下来之前就抽完了,这两格先放占位、写库时换成真值。accumulate_blockers 放行绑定了统计期的列表;合计行、
小计行另存的表各期主键分不开,照旧不能累积;同一订单两期都出现由并集的主键核对拒收。U3 按列表的分期方式核对。
说明里「按哪一列筛选」用分期列,加的两列有列说明;空行之后文字行的说法补了并集版本(列表能累积以后要用到)。
- 穿插的小计:ListRows.subtotals {label_column, pick, scope: since_previous | group_column, group_column, keep_as}。
小计行不进数据,每个数字格一条合计格(分段键是块 id + ·小计),核对模块按它所管那组明细的 rowid 区间用 SQL
求和比对(新 T 编号,标题「小计行按各组明细求和」),整行记进排除的行(reason=subtotal),可以另存成
reported_total 表(小计项、分组取值、各数字列);数字格全空报 subtotal_blank(结构类);合计行的公式引用了小计格
时按它管的明细换算。静态校验:标签列、分组列要在列里,按组小计要给分组列,小计和合计的开头词不能互为前缀。
规则起草把第一列同一个合计字样、下面还有明细的行提议成小计(以前记 stray_totals 失败),框选同一条规则。
- 表格对象起草:工作表有表格对象时按它出列表块:表头行、列范围、数据行、汇总行(映射成 total_row,标签取汇总行
第一列的文字)都由表格对象给,锚点仍按表头文字、不记坐标;表名优先用表格对象的名字(含数字的不用)。不调用 AI。
- CSV 走配方:新增 csv_grid,按内容认出 CSV(不是 zip、不是 OLE、开头没有 NUL),编码和分隔符复用 tabular 读 CSV
的那一段,适配成一张名为「CSV」的工作表(SheetScan.path=csv:,WorkbookScan.format=csv);只有写法毫无歧义的
整数、小数转成数,千分位、前导零、日期写法一律留作文字交给配方的规则。坐标沿用「工作表!A1」(偏离细案的
R{行}C{列},理由见 csv_grid 的模块说明)。CSV 不做稀疏判定。导入向导和简单上传的结构提示都放行 CSV。
- 每个列表一道题「每期怎么更新」(q_list_period):每期替换 / 按期累积并按某个日期列核对 / 按期累积并加统计期
起止两列,后两种同时把导入模式改成按期累积、缺统计期的工作表一并配上(问题集合按草稿生成、回答时不变,草稿里
的列表还不符合累积资格,q_mode 不会出现,所以并成一道题)。确认项新增 subtotals:<块>、list_period:<块>。
- 前端配方面板:列表块多「小计行」(标签列、开头词、管哪些明细、分组列、另存)和「按期累积时每一行属于哪一期」;
回执「排除的行」认 subtotal。
验证:新增 tests/test_csv_grid.py、test_recipe_p5.py(执行器、核对、校验、资格、并集物化、起草)、
test_recipe_p5_e2e.py(经接口:三份月度 CSV 累积、部分重叠拒收、同一期替换要确认;带穿插小计的 Excel 起草、
核对、提交)。去掉小计识别、统计期回填、D 核对分别回跑,新测试 10 条失败。旧测试里「夹在中间的小计一律失败」
「列表只能每期替换」的断言按新行为改写。
This commit is contained in:
@@ -1231,8 +1231,6 @@ export const UPLOAD_TEXT = {
|
||||
shapeTitle: '该表格不是一行一条记录的明细表',
|
||||
shapeLead: '检测到以下结构,默认不导入:',
|
||||
shapeRecipe: '这类表格可以按配方导入:指定表头、分段和合计行的处理方式,试运行核对、逐条确认后再启用。',
|
||||
/** CSV 没有配方这条路(配方只认 Excel 的工作表结构) */
|
||||
shapeRecipeCsv: '按配方导入目前只支持 Excel(.xlsx)文件:可另存为 .xlsx 后再按配方导入。',
|
||||
shapeHeader: (row: number) => `如果表头不在第 ${row} 行,请取消后修改表头行号,再重新上传。`,
|
||||
/** 结构问题之外,预告的混合列:选按原样导入以后还要再选一次 */
|
||||
shapeMixedLead: '另外,以下各列以数字为主,但混有非数字的值。选择按原样导入后,还需要选择这些值的处理方式:',
|
||||
@@ -1500,9 +1498,9 @@ export const RECIPE_TEXT = {
|
||||
descriptionLabel: '说明(供助手参考)',
|
||||
pickDrop: '拖入文件,或点击选择',
|
||||
/** 与选择框的 accept 一致 */
|
||||
pickFormats: '支持 Excel(.xlsx、.xlsm)',
|
||||
pickFormats: '支持 Excel(.xlsx、.xlsm)和 CSV / TSV',
|
||||
pickAria: '选择表格文件',
|
||||
pickWrongType: (name: string) => `「${name}」不是 Excel 文件:按配方导入只支持 .xlsx、.xlsm`,
|
||||
pickWrongType: (name: string) => `「${name}」不是 Excel 或 CSV 文件:按配方导入支持 .xlsx、.xlsm、.csv、.tsv`,
|
||||
cancelUpload: '取消上传',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
@@ -1637,6 +1635,18 @@ export const RECIPE_TEXT = {
|
||||
totalLabelColumn: '合计标签所在的列',
|
||||
totalPick: '合计行以此开头',
|
||||
totalKeep: '原表合计另存为表(留空则只核对)',
|
||||
// 期 5:穿插的小计行(ListRows.subtotals)、列表每期怎么更新(ListBlock.period)
|
||||
subtotals: '小计行',
|
||||
subtotalsOn: '有小计行',
|
||||
subtotalsHint: '夹在明细中间的小计不进数据,按它所管的那组明细逐列求和核对',
|
||||
subtotalLabelColumn: '小计标签所在的列',
|
||||
subtotalPick: '小计行以此开头',
|
||||
subtotalScope: '一行小计管哪些明细',
|
||||
subtotalGroupColumn: '按哪一列分组',
|
||||
subtotalKeep: '原表小计另存为表(留空则只核对)',
|
||||
listPeriod: '按期累积时每一行属于哪一期',
|
||||
listPeriodColumn: '按哪个日期列核对',
|
||||
listPeriodHint: '列表要按期累积,每一行都要说得出属于哪一期;所在工作表要配置统计期(表内没有时,每期试运行时录入)。导入模式另在「导入模式」中选',
|
||||
hiddenRows: '隐藏行',
|
||||
hiddenCols: '隐藏列',
|
||||
fallback: '工作表改名时',
|
||||
@@ -1994,6 +2004,7 @@ export const RECEIPT_BLOCK_TEXT = {
|
||||
ignored_outside: '按配方忽略了区域外数字的行',
|
||||
after_stop: '列表结束之后的文字行',
|
||||
total_not_kept: '只核对、不另存的合计行',
|
||||
subtotal: '穿插的小计行(核对后不进数据)',
|
||||
} as Record<string, string>,
|
||||
excludedOther: '其他原因',
|
||||
excludedTitle: '排除的行',
|
||||
@@ -2107,6 +2118,8 @@ export const RECIPE_CHOICE_LABEL = {
|
||||
colType: { TEXT: '文字', INTEGER: '整数', REAL: '小数', DATE: '日期' } as Record<string, string>,
|
||||
colStore: { '': '默认', canonical: '规范写法', raw: '原文' } as Record<string, string>,
|
||||
axisChecks: { contiguous: '逐日连续', covers_context: '恰好覆盖统计期' } as Record<string, string>,
|
||||
subtotalScope: { since_previous: '上一行小计之后的全部明细', group_column: '紧挨在上面、分组列取值相同的明细' } as Record<string, string>,
|
||||
listPeriod: { none: '不区分(只能每期替换)', column: '按日期列核对每行都在本期统计期内', synthesize: '每行加上本期统计期的起止两列' } as Record<string, string>,
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@@ -1773,7 +1773,7 @@ function UploadDecisionBody({ decision, choices, headerRow, sheetNoun, onRecipe
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 rounded-lg border bg-bg px-2.5 py-2" data-shape-recipe>
|
||||
<p className="min-w-0 flex-1 text-xs leading-relaxed text-dim">{onRecipe ? UPLOAD_TEXT.shapeRecipe : UPLOAD_TEXT.shapeRecipeCsv}</p>
|
||||
<p className="min-w-0 flex-1 text-xs leading-relaxed text-dim">{UPLOAD_TEXT.shapeRecipe}</p>
|
||||
{onRecipe && (
|
||||
<button type="button" className="btn btn-sm btn-primary shrink-0" onClick={onRecipe} data-shape-recipe-start>
|
||||
<Layers size={12} aria-hidden /> {RECIPE_TEXT.entry}
|
||||
@@ -2098,7 +2098,7 @@ function TableUploader({ initialName, taken, sqliteTaken, onClose, onImported, o
|
||||
)}
|
||||
>
|
||||
<UploadDecisionBody decision={decision} choices={choices} headerRow={headerRow} sheetNoun={sheetNoun}
|
||||
onRecipe={file && sheetNoun === '工作表' ? () => onRecipe(file, name, description) : undefined} />
|
||||
onRecipe={file ? () => onRecipe(file, name, description) : undefined} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,8 @@ const NAME_RE = /^[a-z][a-z0-9_]{0,40}$/
|
||||
/** 提交时遇到这些:试运行库已被消耗、作废或被改过,回到配方步骤重新试运行(期 3 加了并集、各期被改过的两种) */
|
||||
const RERUN_CODES = new Set(['trial_required', 'trial_tampered', 'base_changed', 'name_taken', 'union_tampered', 'part_tampered'])
|
||||
/** 选择框收的扩展名,与 RECIPE_TEXT.pickFormats 的说法一致 */
|
||||
const PICK_ACCEPT = '.xlsx,.xlsm'
|
||||
// CSV / TSV 也走配方(服务端 csv_grid 把它适配成一张「工作表」,名字固定为 CSV)
|
||||
const PICK_ACCEPT = '.xlsx,.xlsm,.csv,.tsv'
|
||||
const ENDED = new Set(['committed', 'discarded', 'expired'])
|
||||
|
||||
/** 文件名里的两个日期(「月报导出_2026-09-01_2026-09-30.xlsx」):只作统计期录入框的建议,服务端会再核对 */
|
||||
|
||||
@@ -72,8 +72,13 @@ export function fillRecipeDefaults(input: Recipe | null | undefined): Recipe | n
|
||||
block.extra_columns ??= 'reject'
|
||||
block.ignore_columns ??= []
|
||||
block.merged_data ??= 'reject'
|
||||
block.rows = { blank_rows: 'stop', total_row: null, ...(isObj(block.rows) ? block.rows : {}) }
|
||||
block.rows = { blank_rows: 'stop', total_row: null, subtotals: null, ...(isObj(block.rows) ? block.rows : {}) }
|
||||
if (isObj(block.rows.total_row)) block.rows.total_row.keep_as ??= null
|
||||
if (isObj(block.rows.subtotals)) {
|
||||
block.rows.subtotals = { scope: 'since_previous', group_column: null, keep_as: null, ...block.rows.subtotals }
|
||||
}
|
||||
block.period ??= null
|
||||
if (isObj(block.period)) block.period = { column: null, check: 'within_context', synthesize: null, ...block.period }
|
||||
block.values = {
|
||||
blank: 'null', text_number: 'reject', formula: 'accept_cached', ...(isObj(block.values) ? block.values : {}),
|
||||
}
|
||||
@@ -97,6 +102,10 @@ export interface RecipeColumn { name: string; type: string; role: string; header
|
||||
|
||||
/** 列表合计行另存的表固定有这一列(契约 LIST_TOTAL_DIM) */
|
||||
const LIST_TOTAL_DIM = '合计项'
|
||||
/** 期 5:列表小计另存的表固定有这一列(契约 LIST_SUBTOTAL_DIM) */
|
||||
const LIST_SUBTOTAL_DIM = '小计项'
|
||||
/** 期 5:列表每行加统计期时的两列(契约 LIST_PERIOD_START / LIST_PERIOD_END) */
|
||||
const LIST_PERIOD_COLUMNS = ['统计期起', '统计期止']
|
||||
|
||||
/**
|
||||
* 由配方推出每张表的列,照服务端 derive_tables 的顺序:宽表 = 轴、各指标;长表 = 轴、维度、常量(按列名排序)、
|
||||
@@ -140,11 +149,18 @@ export function tableColumns(recipe: Recipe | null): Map<string, RecipeColumn[]>
|
||||
name: String(c?.name ?? ''), type: c?.type === 'DATE' ? 'TEXT' : String(c?.type ?? 'TEXT'),
|
||||
role: c?.type === 'INTEGER' || c?.type === 'REAL' ? 'measure' : 'text', header: c?.header,
|
||||
}))
|
||||
put(block.table, cols)
|
||||
const synth = isObj(block.period) && block.period.synthesize
|
||||
? LIST_PERIOD_COLUMNS.map((name) => ({ name, type: 'TEXT', role: 'period' })) : []
|
||||
put(block.table, [...cols, ...synth])
|
||||
const numeric = cols.filter((c) => c.type === 'INTEGER' || c.type === 'REAL').map((c) => ({ ...c, role: 'value' }))
|
||||
const total = block.rows?.total_row
|
||||
if (isObj(total) && total.keep_as) {
|
||||
put(total.keep_as, [{ name: LIST_TOTAL_DIM, type: 'TEXT', role: 'dim' },
|
||||
...cols.filter((c) => c.type === 'INTEGER' || c.type === 'REAL').map((c) => ({ ...c, role: 'value' }))])
|
||||
put(total.keep_as, [{ name: LIST_TOTAL_DIM, type: 'TEXT', role: 'dim' }, ...numeric])
|
||||
}
|
||||
const sub = block.rows?.subtotals
|
||||
if (isObj(sub) && sub.keep_as) {
|
||||
const group = sub.scope === 'group_column' && sub.group_column ? [{ name: String(sub.group_column), type: 'TEXT', role: 'dim' }] : []
|
||||
put(sub.keep_as, [{ name: LIST_SUBTOTAL_DIM, type: 'TEXT', role: 'dim' }, ...group, ...numeric])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +180,7 @@ export function renameTable(r: Recipe, old: string, nu: string) {
|
||||
if (!isObj(block)) continue
|
||||
if (block.table === old) block.table = nu
|
||||
if (isObj(block.rows?.total_row) && block.rows.total_row.keep_as === old) block.rows.total_row.keep_as = nu
|
||||
if (isObj(block.rows?.subtotals) && block.rows.subtotals.keep_as === old) block.rows.subtotals.keep_as = nu
|
||||
for (const seg of arr(block.segments)) {
|
||||
if (!isObj(seg)) continue
|
||||
if (seg.table === old) seg.table = nu
|
||||
@@ -205,6 +222,11 @@ function renameRefs(r: Recipe, table: string, old: string, nu: string) {
|
||||
if (block.table === table && isObj(block.rows?.total_row) && block.rows.total_row.label_column === old) {
|
||||
block.rows.total_row.label_column = nu
|
||||
}
|
||||
if (block.table === table && isObj(block.rows?.subtotals)) {
|
||||
if (block.rows.subtotals.label_column === old) block.rows.subtotals.label_column = nu
|
||||
if (block.rows.subtotals.group_column === old) block.rows.subtotals.group_column = nu
|
||||
}
|
||||
if (block.table === table && isObj(block.period) && block.period.column === old) block.period.column = nu
|
||||
for (const seg of arr(block.segments)) {
|
||||
if (isObj(seg?.verify) && seg.verify.against_table === table && seg.verify.value === old) seg.verify.value = nu
|
||||
}
|
||||
@@ -736,6 +758,10 @@ function ListEditor({ s, b, block, edit }: { s: number; b: number; block: any; e
|
||||
const at = (r: Recipe) => r.sheets[s].blocks[b]
|
||||
const columns: any[] = arr(block.columns)
|
||||
const total = isObj(block.rows?.total_row) ? block.rows.total_row : null
|
||||
const subtotals = isObj(block.rows?.subtotals) ? block.rows.subtotals : null
|
||||
const period = isObj(block.period) ? block.period : null
|
||||
const periodKind = period?.synthesize ? 'synthesize' : period?.column ? 'column' : 'none'
|
||||
const dateColumns = columns.filter((c) => c?.type === 'DATE').map((c) => String(c?.name ?? ''))
|
||||
return (
|
||||
<Group title={RECIPE_TEXT.listBlock(String(block.id))} attr={{ 'data-list-block': String(block.id) }}>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
@@ -857,6 +883,73 @@ function ListEditor({ s, b, block, edit }: { s: number; b: number; block: any; e
|
||||
</div>
|
||||
)}
|
||||
</FieldWrap>
|
||||
<FieldWrap path={`${base}/rows/subtotals`} label={RECIPE_TEXT.subtotals} hint={RECIPE_TEXT.subtotalsHint}>
|
||||
<Check path={`${base}/rows/subtotals`} checked={!!subtotals}
|
||||
onChange={(on) => edit((r) => {
|
||||
const x = at(r)
|
||||
x.rows = isObj(x.rows) ? x.rows : {}
|
||||
x.rows.subtotals = on
|
||||
? { label_column: arr(x.columns)[0]?.name ?? '', pick: '小计', scope: 'since_previous', group_column: null, keep_as: null }
|
||||
: null
|
||||
})}>
|
||||
{RECIPE_TEXT.subtotalsOn}
|
||||
</Check>
|
||||
{subtotals && (
|
||||
<div className="mt-1 grid grid-cols-1 gap-2 md:grid-cols-3" data-list-subtotals={String(block.id)}>
|
||||
<FieldWrap path={`${base}/rows/subtotals/label_column`} label={RECIPE_TEXT.subtotalLabelColumn}>
|
||||
<Select path={`${base}/rows/subtotals/label_column`} value={subtotals.label_column ?? ''} label={RECIPE_TEXT.subtotalLabelColumn}
|
||||
options={columns.map((c) => String(c?.name ?? ''))}
|
||||
onChange={(v) => edit((r) => { at(r).rows.subtotals.label_column = v })} />
|
||||
</FieldWrap>
|
||||
<FieldWrap path={`${base}/rows/subtotals/pick`} label={RECIPE_TEXT.subtotalPick}>
|
||||
<TextInput path={`${base}/rows/subtotals/pick`} value={subtotals.pick ?? ''} label={RECIPE_TEXT.subtotalPick}
|
||||
onCommit={(v) => edit((r) => { at(r).rows.subtotals.pick = v })} />
|
||||
</FieldWrap>
|
||||
<FieldWrap path={`${base}/rows/subtotals/keep_as`} label={RECIPE_TEXT.subtotalKeep}>
|
||||
<TextInput path={`${base}/rows/subtotals/keep_as`} value={subtotals.keep_as ?? ''} allowEmpty label={RECIPE_TEXT.subtotalKeep}
|
||||
onCommit={(v) => edit((r) => {
|
||||
const old = at(r).rows.subtotals.keep_as
|
||||
if (old && v) renameTable(r, old, v)
|
||||
else at(r).rows.subtotals.keep_as = v || null
|
||||
})} />
|
||||
</FieldWrap>
|
||||
<FieldWrap path={`${base}/rows/subtotals/scope`} label={RECIPE_TEXT.subtotalScope} className="md:col-span-2">
|
||||
<Radios path={`${base}/rows/subtotals/scope`} value={subtotals.scope ?? 'since_previous'} label={RECIPE_TEXT.subtotalScope}
|
||||
choices={RECIPE_CHOICE_LABEL.subtotalScope}
|
||||
onChange={(v) => edit((r) => {
|
||||
const x = at(r).rows.subtotals
|
||||
x.scope = v
|
||||
x.group_column = v === 'group_column' ? (x.group_column ?? arr(at(r).columns)[0]?.name ?? null) : null
|
||||
})} />
|
||||
</FieldWrap>
|
||||
{subtotals.scope === 'group_column' && (
|
||||
<FieldWrap path={`${base}/rows/subtotals/group_column`} label={RECIPE_TEXT.subtotalGroupColumn}>
|
||||
<Select path={`${base}/rows/subtotals/group_column`} value={subtotals.group_column ?? ''} label={RECIPE_TEXT.subtotalGroupColumn}
|
||||
options={columns.map((c) => String(c?.name ?? ''))}
|
||||
onChange={(v) => edit((r) => { at(r).rows.subtotals.group_column = v })} />
|
||||
</FieldWrap>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</FieldWrap>
|
||||
<FieldWrap path={`${base}/period`} label={RECIPE_TEXT.listPeriod} hint={RECIPE_TEXT.listPeriodHint}>
|
||||
<Radios path={`${base}/period`} value={periodKind} label={RECIPE_TEXT.listPeriod}
|
||||
choices={RECIPE_CHOICE_LABEL.listPeriod}
|
||||
onChange={(v) => edit((r) => {
|
||||
const x = at(r)
|
||||
x.period = v === 'none' ? null
|
||||
: v === 'synthesize' ? { column: null, check: 'within_context', synthesize: '统计期' }
|
||||
: { column: dateColumns[0] ?? '', check: 'within_context', synthesize: null }
|
||||
})} />
|
||||
{periodKind === 'column' && (
|
||||
<div className="mt-1 max-w-xs">
|
||||
<FieldWrap path={`${base}/period/column`} label={RECIPE_TEXT.listPeriodColumn}>
|
||||
<Select path={`${base}/period/column`} value={period?.column ?? ''} label={RECIPE_TEXT.listPeriodColumn}
|
||||
options={dateColumns} onChange={(v) => edit((r) => { at(r).period.column = v })} />
|
||||
</FieldWrap>
|
||||
</div>
|
||||
)}
|
||||
</FieldWrap>
|
||||
<IgnoreRules path={`${base}/ignore_columns`} items={arr(block.ignore_columns)} edit={edit}
|
||||
text={(x) => RECIPE_TEXT.ignoreColumn(String(x?.header ?? ''))}
|
||||
remove={(r, k) => { at(r).ignore_columns.splice(k, 1) }} />
|
||||
|
||||
@@ -943,6 +943,7 @@ export interface PriorAcceptance {
|
||||
/** 排除的行的原因(契约 ExcludedRows.reason) */
|
||||
export type ExcludedReason =
|
||||
| 'hidden_excluded' | 'blank_skipped' | 'ignored_rows' | 'ignored_outside' | 'after_stop' | 'total_not_kept'
|
||||
| 'subtotal'
|
||||
| (string & {})
|
||||
|
||||
/** 回执里的「排除的行」(契约 ExcludedRows)。按表头忽略的列不在这里(记在回执的 ignored_columns) */
|
||||
|
||||
Reference in New Issue
Block a user