diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py
index 7a62840f..3deec211 100644
--- a/backend/app/data/catalog.py
+++ b/backend/app/data/catalog.py
@@ -878,7 +878,8 @@ class PatchChange:
"""提案里的一项,核对过的。
- path:规范化后的路径(新增关系写成 relations.<编号>);
- - before / before_status:当前目录里这一项的值和状态(被驳回的和没有的都算没有,为 None);
+ - before / before_status:当前目录里这一项的值和状态,没有这一项为 None。被驳回的照样给出原值、状态写 rejected
+ (同导入预览「目标里这一项已经驳回」):只是给人看的,算改后、算 state 都按没有这一项,保存时换成人工确认的新值;
- after:保存后的值(码值是补充后的完整对照);value:要交回保存的原样取值(码值只有补充的那几个,
409 之后在最新的目录上重新补);
- state:change 值有变化;confirm 值相同、但还不是已确认(保存即确认);same 已经是这个值且已确认。
@@ -1014,6 +1015,8 @@ def plan_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
没有这个码时加进去,不会把已有的含义抹掉(_merge_codes)。
- 关联关系不抹掉测量结果:覆盖率沿用目录里的,基数没写时沿用或推算(_relation_after)。两端、基数、覆盖率都和
目录里一样时只是确认,来源不变。
+ - 被驳回的项算没有:码值不在驳回的码值上补,和驳回的值一样也是 change(保存时换成人工确认的新值)。改前照样写出
+ 驳回的原值、before_status 为 rejected,不然界面上只看到「未填写」,看不出原来有一个被驳回的值。
- 看起来像计算公式的文字不收(_FORMULA):公式进口径卡。
- 同一个路径出现两次只认第一次。
"""
@@ -1094,8 +1097,10 @@ def plan_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
state = "same" if live.get("status") == "confirmed" else "confirm"
else:
state = "change"
- out.append(PatchChange(path=norm, before=copy.deepcopy(before),
- before_status=live.get("status") if live is not None else None,
+ # 改前给人看:被驳回的也写出原值(上面算改后、算 state 照旧按没有这一项)
+ shown = live if live is not None else prev
+ out.append(PatchChange(path=norm, before=copy.deepcopy(_value_of(key, shown)) if shown is not None else None,
+ before_status=shown.get("status") if shown is not None else None,
after=after, value=value, reason=reason, state=state, note=note))
return PatchPlan(out, problems)
diff --git a/backend/tests/test_catalog_patch.py b/backend/tests/test_catalog_patch.py
index b8b5ee39..500a9042 100644
--- a/backend/tests/test_catalog_patch.py
+++ b/backend/tests/test_catalog_patch.py
@@ -204,6 +204,36 @@ async def test_saving_keeps_the_reason_as_the_note(scenic_db):
assert catalog.validate_notes(out) == []
+async def test_rejected_items_show_their_value_before(scenic_db):
+ """提案落在已驳回的项上:改前带上被驳回的原值、状态写已驳回(同导入预览「目标里这一项已经驳回」)。只改预览给出的
+ 信息,保存的规矩不变:照旧换成人工确认的新值;码值不在驳回的码值上补;和驳回的值一样的也不算「值不变」,照样存成
+ 人工确认。"""
+ tables = (await _schema(scenic_db))["tables"]
+ rejected = scenic_notes.notes("rejected")["visits"]
+ rel_value = {"columns": ["park_id"], "to_table": "parks", "to_columns": ["id"], "cardinality": "many_to_one"}
+ changes = [
+ {"path": "columns.visitor_count.measure", "value": "stock"},
+ {"path": "columns.status.codes", "value": {"9": "作废"}},
+ {"path": "valid_filter", "value": "status = 1"}, # 和驳回的值一样
+ {"path": f"relations.{_PARKS}", "value": rel_value}, # 两端、基数都和驳回的那条一样
+ ]
+ plan = catalog.plan_patch(rejected, changes, table="visits", tables=tables)
+ assert plan.problems == []
+ measure, codes, valid, rel = plan.changes
+ assert (measure.before, measure.before_status, measure.state) == ("flow", "rejected", "change")
+ assert (codes.before, codes.before_status, codes.state) == ({"1": "有效", "0": "作废"}, "rejected", "change")
+ assert codes.after == {"9": "作废"}, "码值不在驳回的码值上补"
+ assert (valid.before, valid.before_status, valid.state) == ("status = 1", "rejected", "change")
+ assert (rel.before, rel.before_status, rel.state) == ({**rel_value, "coverage": None}, "rejected", "change")
+ assert rel.as_dict()["before_status"] == "rejected" and rel.as_dict()["before"]["to_table"] == "parks"
+ out = catalog.apply_patch(rejected, changes, table="visits", tables=tables)
+ saved = [out["columns"]["visitor_count"]["measure"], out["columns"]["status"]["codes"], out["valid_filter"],
+ next(r for r in out["relations"] if r["id"] == _PARKS)]
+ assert all((item["source"], item["status"]) == ("human", "confirmed") for item in saved)
+ codes = out["columns"]["status"]["codes"]
+ assert codes["value"] == {"9": "作废"} and "complete" not in codes
+
+
# --------------------------------------------------------------------------
# 协议
# --------------------------------------------------------------------------
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts
index 0071a7c9..cd3bc739 100644
--- a/frontend/src/lib/terms.ts
+++ b/frontend/src/lib/terms.ts
@@ -2610,6 +2610,8 @@ export const CATALOG_PATCH_TEXT = {
after: '改后',
reason: '理由',
empty: '未填写',
+ /** 改前是一个被驳回的值(保存后换成人工确认的新值):同导入预览「目标里这一项已经驳回」 */
+ beforeRejected: (value: string) => `已驳回:${value}`,
stateConfirm: '值不变,保存即确认',
stateSame: '已是这个值',
save: '保存到数据目录',
diff --git a/frontend/src/pages/catalog/TableAssistant.tsx b/frontend/src/pages/catalog/TableAssistant.tsx
index 2d0e0648..ea398543 100644
--- a/frontend/src/pages/catalog/TableAssistant.tsx
+++ b/frontend/src/pages/catalog/TableAssistant.tsx
@@ -9,7 +9,7 @@ import { Notice, Spinner, confirmDialog, isComposing, toast } from '../../compon
import { useLeaveGuard } from '../../lib/leave'
import { CATALOG_ASSISTANT_TEXT as T, CATALOG_STATUS_LABEL } from '../../lib/terms'
import { Markdown } from '../../run/Markdown'
-import { patchValueText } from '../../run/catalogPatch'
+import { beforeStatusShown, patchBeforeText, patchValueText } from '../../run/catalogPatch'
import {
ASSISTANT_MAX_CHARS, ASSISTANT_MAX_SAVE, QUERY_ROWS_SHOWN, applyEvent, applyPreview, assistantChangesOf, blankColumnsOf, blankLeadOf,
changeStateOf, changeWhere, dropPending, historyOf, mergeProposal, pendingForAssistant, pendingSubmit, pendingVersion, savedKey,
@@ -495,9 +495,10 @@ function ChangeRow({ change: c, state, onToggle }: {
- {T.before}
- -
- {valueText(c.path, c.before)}
- {c.before != null && c.before_status && ({CATALOG_STATUS_LABEL[c.before_status]})}
+
-
+ {patchBeforeText(c)}
+ {beforeStatusShown(c) && ({CATALOG_STATUS_LABEL[c.before_status!]})}
- {T.after}
-
@@ -585,7 +586,7 @@ function PendingTray({ pending, open, onToggle, save, editing, onDrop, onClear,
{where}
- {T.before} {valueText(p.path, p.before)}
+ {T.before} {patchBeforeText(p)}
→ {T.after} {valueText(p.path, p.after)}
{p.reason &&
{T.reason}:{p.reason}
}
diff --git a/frontend/src/run/CatalogPatchCard.tsx b/frontend/src/run/CatalogPatchCard.tsx
index d4c7d4d0..fb33580c 100644
--- a/frontend/src/run/CatalogPatchCard.tsx
+++ b/frontend/src/run/CatalogPatchCard.tsx
@@ -8,7 +8,8 @@ import { CatalogImpactList } from '../components/CatalogImpact'
import { CATALOG_PATCH_TEXT as T, CATALOG_STATUS_LABEL, CATALOG_UI_TEXT as UT } from '../lib/terms'
import type { CatalogPatchChange } from '../types'
import {
- catalogTableHref, codeMeaning, patchSubmit, patchTarget, patchValueText, patchWhere, type CatalogPatch,
+ beforeStatusShown, catalogTableHref, codeMeaning, patchBeforeText, patchSubmit, patchTarget, patchValueText, patchWhere,
+ type CatalogPatch,
} from './catalogPatch'
// ===========================================================================
@@ -183,13 +184,15 @@ function ChangeRow({ change: c, dense }: { change: CatalogPatchChange; dense: bo
- {T.before}
- -
- {patchValueText(c.path, c.before)}
- {c.before != null && c.before_status && ({CATALOG_STATUS_LABEL[c.before_status]})}
+
-
+ {patchBeforeText(c)}
+ {beforeStatusShown(c) && ({CATALOG_STATUS_LABEL[c.before_status!]})}
- {T.after}
-
- {isCodes ? : patchValueText(c.path, c.after)}
+ {/* 被驳回的码值不算:保存时整项换成新的,改后的码值都是新加的 */}
+ {isCodes ? : patchValueText(c.path, c.after)}
{c.reason && (
<>
diff --git a/frontend/src/run/catalogPatch.ts b/frontend/src/run/catalogPatch.ts
index 29821bc3..fbe56517 100644
--- a/frontend/src/run/catalogPatch.ts
+++ b/frontend/src/run/catalogPatch.ts
@@ -141,6 +141,20 @@ export function patchValueText(path: string, v: unknown): string {
return typeof v === 'string' ? v : JSON.stringify(v)
}
+/**
+ * 改前那一格(卡片、表助手的提案和待保存的修改都用它):被驳回的项写「已驳回:原值」——服务端把驳回的原值也交回来
+ * (before_status 为 rejected),保存时照旧换成人工确认的新值;只写「未填写」就看不出原来有一个被驳回的值,和导入预览
+ * 「目标里这一项已经驳回」对不上。别的照 patchValueText 写,没有值写「未填写」
+ */
+export function patchBeforeText(change: Pick): string {
+ const text = patchValueText(change.path, change.before)
+ return change.before_status === 'rejected' && change.before != null ? CATALOG_PATCH_TEXT.beforeRejected(text) : text
+}
+
+/** 改前要不要跟一个状态(「(推断)」):有值、不是被驳回的(被驳回的状态已经写在 patchBeforeText 里) */
+export const beforeStatusShown = (change: Pick) =>
+ change.before != null && !!change.before_status && change.before_status !== 'rejected'
+
/** 交回保存、预览的那一份:路径、原样取值、理由 */
export function patchSubmit(changes: CatalogPatchChange[]) {
return changes.map((c) => ({ path: c.path, value: c.value, ...(c.reason ? { reason: c.reason } : {}) }))
diff --git a/scripts/check-catalog-loop.mjs b/scripts/check-catalog-loop.mjs
index e16c7534..3ce3f373 100644
--- a/scripts/check-catalog-loop.mjs
+++ b/scripts/check-catalog-loop.mjs
@@ -307,7 +307,7 @@ function applyPatch(notes, changes) {
}
return assemble(map)
}
-/** 预览:对着当前目录算改前、改后(只读) */
+/** 预览:对着当前目录算改前、改后(只读)。被驳回的项改前照样写出原值、状态写已驳回,改后和状态按没有这一项算 */
function previewOf(t, changes) {
const map = slots(t.notes ?? {})
return {
@@ -316,8 +316,8 @@ function previewOf(t, changes) {
const prev = map.get(c.path)
const live = prev && prev.status !== 'rejected' ? prev : null
const after = c.path.endsWith('.codes') && live ? { ...live.value, ...c.value } : c.value
- return { path: c.path, before: live?.value ?? null, before_status: live?.status ?? null, after, value: c.value, reason: c.reason ?? '',
- state: JSON.stringify(live?.value) === JSON.stringify(after) ? (live.status === 'confirmed' ? 'same' : 'confirm') : 'change' }
+ return { path: c.path, before: prev?.value ?? null, before_status: prev?.status ?? null, after, value: c.value, reason: c.reason ?? '',
+ state: live && JSON.stringify(live.value) === JSON.stringify(after) ? (live.status === 'confirmed' ? 'same' : 'confirm') : 'change' }
}),
}
}
@@ -625,6 +625,15 @@ await section('纯函数 · 对话怎么截、待保存的修改怎么累积、
dedup: { value: 'y', source: 'llm', status: 'rejected' } }, 4, res.outcomes, res.versions)
out.sync = { grain: synced.outcomes.get(rv.itemKey(items[0])), dedup: synced.outcomes.get(rv.itemKey(items[1]))?.status, v: synced.versions.get('t1') }
out.pct = [rv.percentOf(120, 286), rv.percentOf(0, 0)]
+ // 提案的改前:被驳回的原值也写出来(同导入预览「目标里这一项已经驳回」)
+ const cp = await import('/src/run/catalogPatch.ts')
+ out.before = [
+ cp.patchBeforeText({ path: 'columns.amount.unit', before: '元', before_status: 'rejected' }),
+ cp.patchBeforeText({ path: 'columns.amount.unit', before: '元', before_status: 'proposed' }),
+ cp.patchBeforeText({ path: 'columns.amount.unit', before: null, before_status: null }),
+ cp.patchBeforeText({ path: 'kind', before: 'fact', before_status: 'rejected' }),
+ cp.patchBeforeText({ path: 'columns.status.codes', before: { 1: '有效' }, before_status: 'rejected' }),
+ ]
// 审阅队列一项的位置:按 path 说到列(服务端的 field_label 只是字段的叫法)
out.where = [
rv.reviewWhere({ path: 'columns.status.codes', field_label: '码值', column: 'status', value: { 1: '有效' } }),
@@ -687,6 +696,8 @@ await section('纯函数 · 对话怎么截、待保存的修改怎么累积、
check('表详情改过之后:回到推断的记成撤销审阅,驳回的记成已驳回,版本换成最新的', r.sync.grain?.action === 'reset' && r.sync.dedup === 'rejected' && r.sync.v === 4,
JSON.stringify(r.sync))
check('进度的百分比四舍五入,分母为 0 时不写', r.pct[0] === 42 && r.pct[1] === null, JSON.stringify(r.pct))
+ check('提案的改前:被驳回的写「已驳回:原值」(值按界面上的说法),别的照原样,没有值写「未填写」',
+ JSON.stringify(r.before) === JSON.stringify(['已驳回:元', '元', '未填写', '已驳回:明细表', '已驳回:1=有效']), JSON.stringify(r.before))
check('队列一项的位置写到列:列 status 的码值、表的有效记录条件、指向 parks 的关联关系;列名里有句点、认不出的路径也说得通',
JSON.stringify(r.where) === JSON.stringify(['列 status 的码值', '表的有效记录条件', '指向 parks 的关联关系', '列 a.b 的中文名', '列 x 的怪字段', '怪字段']),
JSON.stringify(r.where))
@@ -1439,6 +1450,41 @@ await section('表详情 · 人工确认的项的说明', async () => {
await close()
})
+await section('表助手 · 改前是已驳回的值', async () => {
+ // 单位「元」被驳回过:助手提「人」,改前写「已驳回:元」(不是「未填写」),和导入预览「目标里这一项已经驳回」一致
+ const state = freshState()
+ const visits = state.tables[S1].find((t) => t.name === 'visits')
+ visits.notes.columns.visitor_count.unit = item('元', 'llm', 'rejected')
+ const PROPOSAL_R = {
+ type: 'proposal', source: NAME1, source_id: S1, table: 'visits', table_label: '入园记录', version: 2, changes: [
+ { path: 'columns.visitor_count.unit', label: '列 visitor_count 的单位', before: '元', before_status: 'rejected', after: '人', value: '人',
+ reason: '使用者说:入园人数按人计', state: 'change' },
+ { path: 'kind', label: '表的表类型', before: null, before_status: null, after: 'fact', value: 'fact', reason: '使用者说:一行一次入园', state: 'change' },
+ ],
+ }
+ const replies = { assistant: [sse([{ type: 'text', delta: '好的,单位按人计。' }, PROPOSAL_R, done()])] }
+ const { page, sent, errors, close } = await open(`/data/catalog/${S1}/visits`, { handlers: loopHandlers(state, replies) })
+ await until(async () => (await page.locator('[data-catalog-detail="visits"][data-version]').count()) > 0)
+ await page.locator('[data-catalog-assistant-open="header"]').click()
+ await until(async () => panel(page).isVisible())
+ await say(page, '入园人数按人计')
+ const unit = panel(page).locator('[data-assistant-change="columns.visitor_count.unit"]')
+ await shot(page, 'catalog-assistant-rejected')
+ check('提案卡片的改前:「已驳回:元」,不写「未填写」', (await textOf(unit.locator('[data-change-before]'))) === '已驳回:元', await textOf(unit))
+ check('……原来没有的照旧写「未填写」', (await textOf(panel(page).locator('[data-assistant-change="kind"] [data-change-before]'))) === '未填写')
+ const tray = panel(page).locator('[data-assistant-pending-item="columns.visitor_count.unit"]')
+ check('待保存的修改里同样写「改前 已驳回:元」', (await textOf(tray)).includes('改前 已驳回:元'), await textOf(tray))
+ await panel(page).locator('[data-assistant-save]').click()
+ check('保存:交回原样取值和依据', await until(async () => count(sent, /\/visits\/patch$/) === 1)
+ && JSON.stringify(lastSent(sent, /\/visits\/patch$/)?.body?.changes?.[0]) === JSON.stringify({ path: 'columns.visitor_count.unit', value: '人', reason: '使用者说:入园人数按人计' }))
+ check('……存上以后那一格是人工确认的「人」,悬停写保存时的依据', await until(async () => (await version(page)) === 3)
+ && (await textOf(page.locator('[data-column="visitor_count"] [data-cell="unit"]'))).includes('人')
+ && ((await page.locator('[data-column="visitor_count"] [data-cell="unit"] [data-cell-note]').getAttribute('title').catch(() => '')) ?? '').includes('依据:使用者说:入园人数按人计'))
+ await cleanCopy(page, '改前是已驳回的值')
+ check('没有运行时报错', errors.length === 0, errors[0] ?? '')
+ await close()
+})
+
await section('防错事实的提议 · 表详情里的说明', async () => {
const state = freshState()
const { page, errors, close } = await open(`/data/catalog/${S1}/visits`, { handlers: loopHandlers(state) })
diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs
index aae71d98..5cd73ef3 100644
--- a/scripts/check-stream.mjs
+++ b/scripts/check-stream.mjs
@@ -822,12 +822,14 @@ await section('建议更新数据目录:卡片的保存、忽略和 409(cata
const body = req.postDataJSON()
sent.push({ url: req.url(), body, actor: req.headers()['x-actor'] })
if (req.url().endsWith('/patch/preview')) {
- // 重新载入:别人刚把码值补了一个 8,改前改后按最新的算
+ // 重新载入:别人刚把码值补了一个 8、把度量类型驳回了,改前改后按最新的算(驳回的项改前照样写出原值)
return route.fulfill({ json: { table_name: 'visits', version: 4, problems: [], changes: [
{ path: 'columns.status.codes', before: { 1: '有效', 0: '作废', 8: '退票' }, before_status: 'confirmed',
after: { 1: '有效', 0: '作废', 8: '退票', 9: '作废' }, value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' },
{ path: 'valid_filter', before: 'status = 1', before_status: 'proposed', after: 'status = 1 AND status <> 9',
value: 'status = 1 AND status <> 9', reason: '统计时要排除作废记录', state: 'change' },
+ { path: 'columns.visitor_count.measure', before: 'flow', before_status: 'rejected', after: 'flow', value: 'flow',
+ reason: '', state: 'change' },
] } })
}
if (conflictOnce) {
@@ -871,6 +873,8 @@ await section('建议更新数据目录:卡片的保存、忽略和 409(cata
check('重新载入交回的是助手原来的提案', sent[1]?.url.endsWith('/patch/preview')
&& JSON.stringify(sent[1]?.body?.changes?.[0]?.value) === '{"9":"作废"}', JSON.stringify(sent[1]))
check('重新载入后改前按最新的目录写', (await codes.locator('[data-patch-before]').innerText()).includes('8=退票'))
+ check('……刚被驳回的项:改前写「已驳回:可累加」,不写「未填写」', (await card.locator('[data-patch-change="columns.visitor_count.measure"] [data-patch-before]')
+ .innerText()) === '已驳回:可累加', await card.locator('[data-patch-change="columns.visitor_count.measure"]').innerText())
await card.locator('[data-patch-save]').click()
await card.locator('[data-patch-done]').waitFor({ timeout: 3000 })
check('再保存带新版本', sent[2]?.body?.if_version === 4, JSON.stringify(sent[2]?.body))