# 自动编号 → 手动固定编号修复（删段重排根治）

## 适用场景
他人（如屠佳青）用修订模式整段删除了一个**自动编号列表项**（段落 pPr 含 `<w:numPr>`，且段落标记 del=True），导致 OnlyOffice **markup 修订视图**把后续列表项渲染成"旧号新号"双编号：

```
（1）报名服务          ← 正常
（2）笔试服务[删除线]   ← 被删，仍占编号位
（3）（2）面试服务      ← 双号！自动引擎按"接受后会变(2)"提前显示
（4）（3）项目管理      ← 双号！
```

Maggie/Doro 平时看 markup 视图，要求"修订视图下编号稳定显示 (1)(2)(3)(4)"。
根治办法：把这一组列表项从自动编号转成**手动文本编号**——文本是字面量，渲染器原样输出，不再经过自动编号引擎重排。

## 关键认知（动手前必须确认）
1. **被删项的删除是他人修订 → 绝不动其正文**（只在段首加编号 run，不碰 del 内容）。
2. **全文先确认没有对这些子项编号的交叉引用**（如"按上述第3项""见（4）"）。本例"具体服务内容详见 附件一：服务报价单"是文字列举，非编号引用 → 安全。若存在引用，转手动编号后引用文字需同步核对。
3. 用 `docker exec <nc容器> find ... ` 从 Nextcloud **拉当前交付版**作修复源，`md5sum` 比对确认本地副本没过时。

## 可复用代码（2026-06-16 验证通过）
```python
import zipfile, shutil, os
from lxml import etree
NS='http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def q(t): return f'{{{NS}}}{t}'
def ln(el): return etree.QName(el).localname

src='交付版.docx'; out='FIXED.docx'
work='work.docx'; shutil.copy(src, work)
root=etree.fromstring(zipfile.ZipFile(work).read('word/document.xml'))

# 1. 按正文开头定位目标段（按你的合同改这些前缀）
segs={}
for p in root.iter(q('p')):
    t=''.join((x.text or '') for x in p.iter() if ln(x) in ('t','delText'))
    if   t.startswith('报名服务：'):       segs['报名']=p
    elif t.startswith('笔试服务：提供'):   segs['笔试']=p   # 被删的那项
    elif t.startswith('面试服务：'):       segs['面试']=p
    elif t.startswith('项目管理：整个项目'): segs['项目管理']=p

def make_rpr():  # 字体/字号照搬本段原文（本例宋体sz=24）。务必与目标段一致
    rpr=etree.SubElement(etree.Element(q('tmp')), q('rPr'))
    rf=etree.SubElement(rpr, q('rFonts'))
    for a in ('ascii','hAnsi','cs'): rf.set(q(a),'宋体;SimSun')
    etree.SubElement(rpr, q('sz')).set(q('val'),'24')
    etree.SubElement(rpr, q('szCs')).set(q('val'),'24')
    return rpr

def make_run(text):
    r=etree.Element(q('r')); r.append(make_rpr())
    t=etree.SubElement(r, q('t')); t.text=text
    t.set('{http://www.w3.org/XML/1998/namespace}space','preserve')
    return r

def remove_numpr(p):
    ppr=p.find(q('pPr'))
    if ppr is not None:
        np=ppr.find(q('numPr'))
        if np is not None: ppr.remove(np)

def insert_first(p, node):  # 插到 pPr 之后、第一个内容元素之前
    idx=len(p)
    for i,c in enumerate(p):
        if ln(c) in ('r','ins','del','hyperlink'): idx=i; break
    p.insert(idx, node)

# 2. 普通项：明文编号 run
for key,label in [('报名','（1）'),('面试','（3）'),('项目管理','（4）')]:
    remove_numpr(segs[key]); insert_first(segs[key], make_run(label))

# 3. 被删项：编号 run 必须包进【他人的】<w:del>（复制其 author/date）
p=segs['笔试']; remove_numpr(p)
ex=p.find(q('del'))                       # 已有的他人 del（屠佳青）
nd=etree.Element(q('del'))
nd.set(q('author'), ex.get(q('author')))  # 照抄他人 author
nd.set(q('date'),   ex.get(q('date')))
nd.set(q('id'),'99001')                   # 不冲突的大 id
r=etree.SubElement(nd, q('r')); r.append(make_rpr())
dt=etree.SubElement(r, q('delText')); dt.text='（2）'
dt.set('{http://www.w3.org/XML/1998/namespace}space','preserve')
insert_first(p, nd)

# 4. 写回（只换 document.xml，其余条目原样复制）
new_doc=etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(work) as zin, zipfile.ZipFile(out,'w',zipfile.ZIP_DEFLATED) as zout:
    for it in zin.namelist():
        zout.writestr(it, new_doc if it=='word/document.xml' else zin.read(it))
```

## 交付前必验（vision 不可用时降级四查，缺一不可）
1. **逐段 markup diff vs 交付源** → 只有目标 N 段不同，其余零改动（本例 346 段只动 4 段）。
2. **pdftotext 渲染层数编号链** → `onlyoffice-render.sh out.docx && pdftotext -f1 -l1 out.pdf -` 确认 1.2 下严格 (1)(2)(3)(4) 无双号。
3. **编号 run rPr == 本段正文 run rPr**（字体宋体、sz=24 一致）。
4. **接受修订后视图编号链连续** + `python-docx Document(out)` 可打开（XML 合法）。验证被删项的编号确实在 `<del author=他人>` 里、他人原 del 内容（id 不变）一字未动。

## 上传
`docker cp` 覆盖 `任务交付/` 同名文件 → `chown www-data` → `occ files:scan --path` → 清 OnlyOffice 缓存（`rm -rf .../App_Data/cache/files/*`）让 Maggie 打开看到新版。`md5sum` 比对容器内==本地确认上传成功。
