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:
ThearchyHelios
2026-10-16 11:09:39 +08:00
parent ee7b8b2f97
commit a137182145
29 changed files with 1796 additions and 102 deletions
+17 -4
View File
@@ -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>,
}
// ===========================================================================
+2 -2
View File
@@ -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>
)
}
+2 -1
View File
@@ -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」):只作统计期录入框的建议,服务端会再核对 */
+97 -4
View File
@@ -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) }} />
+1
View File
@@ -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) */