#!/usr/bin/env python3
from __future__ import annotations

import argparse
import hashlib
from pathlib import Path
from typing import Iterable, Sequence

from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Inches, Pt, RGBColor


ORANGE = "F36C21"
DARK = "243447"
MID = "52606D"
LIGHT_ORANGE = "FFF2E8"
LIGHT_BLUE = "EDF4F8"
LIGHT_GRAY = "F4F6F8"
BORDER = "D5DBE0"
WHITE = "FFFFFF"
CONTENT_DXA = 8306
BODY_FONT = "Arial Unicode MS"


def set_cell_shading(cell, fill: str) -> None:
    tc_pr = cell._tc.get_or_add_tcPr()
    shd = tc_pr.find(qn("w:shd"))
    if shd is None:
        shd = OxmlElement("w:shd")
        tc_pr.append(shd)
    shd.set(qn("w:fill"), fill)
    shd.set(qn("w:val"), "clear")


def set_cell_width(cell, width_dxa: int) -> None:
    tc_pr = cell._tc.get_or_add_tcPr()
    tc_w = tc_pr.find(qn("w:tcW"))
    if tc_w is None:
        tc_w = OxmlElement("w:tcW")
        tc_pr.append(tc_w)
    tc_w.set(qn("w:w"), str(width_dxa))
    tc_w.set(qn("w:type"), "dxa")


def set_cell_margins(cell, top=90, start=110, bottom=90, end=110) -> None:
    tc_pr = cell._tc.get_or_add_tcPr()
    tc_mar = tc_pr.first_child_found_in("w:tcMar")
    if tc_mar is None:
        tc_mar = OxmlElement("w:tcMar")
        tc_pr.append(tc_mar)
    for side, value in (("top", top), ("start", start), ("bottom", bottom), ("end", end)):
        node = tc_mar.find(qn(f"w:{side}"))
        if node is None:
            node = OxmlElement(f"w:{side}")
            tc_mar.append(node)
        node.set(qn("w:w"), str(value))
        node.set(qn("w:type"), "dxa")


def set_table_borders(table, color=BORDER, size="5") -> None:
    tbl_pr = table._tbl.tblPr
    borders = tbl_pr.find(qn("w:tblBorders"))
    if borders is None:
        borders = OxmlElement("w:tblBorders")
        tbl_pr.append(borders)
    for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
        node = borders.find(qn(f"w:{edge}"))
        if node is None:
            node = OxmlElement(f"w:{edge}")
            borders.append(node)
        node.set(qn("w:val"), "single")
        node.set(qn("w:sz"), size)
        node.set(qn("w:space"), "0")
        node.set(qn("w:color"), color)


def set_table_geometry(table, widths: Sequence[int]) -> None:
    table.autofit = False
    table.alignment = WD_TABLE_ALIGNMENT.CENTER
    tbl_pr = table._tbl.tblPr
    tbl_w = tbl_pr.find(qn("w:tblW"))
    if tbl_w is None:
        tbl_w = OxmlElement("w:tblW")
        tbl_pr.append(tbl_w)
    tbl_w.set(qn("w:w"), str(sum(widths)))
    tbl_w.set(qn("w:type"), "dxa")
    tbl_layout = tbl_pr.find(qn("w:tblLayout"))
    if tbl_layout is None:
        tbl_layout = OxmlElement("w:tblLayout")
        tbl_pr.append(tbl_layout)
    tbl_layout.set(qn("w:type"), "fixed")
    grid = table._tbl.tblGrid
    for child in list(grid):
        grid.remove(child)
    for width in widths:
        col = OxmlElement("w:gridCol")
        col.set(qn("w:w"), str(width))
        grid.append(col)
    for row in table.rows:
        tr_pr = row._tr.get_or_add_trPr()
        cant_split = OxmlElement("w:cantSplit")
        tr_pr.append(cant_split)
        for cell, width in zip(row.cells, widths):
            set_cell_width(cell, width)
            set_cell_margins(cell)


def repeat_header(row) -> None:
    tr_pr = row._tr.get_or_add_trPr()
    tbl_header = OxmlElement("w:tblHeader")
    tbl_header.set(qn("w:val"), "true")
    tr_pr.append(tbl_header)


def set_font(run, name=BODY_FONT, size=10.5, bold=False, color=DARK) -> None:
    run.font.name = name
    run.font.size = Pt(size)
    run.font.bold = bold
    run.font.color.rgb = RGBColor.from_string(color)
    r_pr = run._element.get_or_add_rPr()
    r_fonts = r_pr.rFonts
    if r_fonts is None:
        r_fonts = OxmlElement("w:rFonts")
        r_pr.insert(0, r_fonts)
    for attr in ("ascii", "hAnsi", "eastAsia", "cs"):
        r_fonts.set(qn(f"w:{attr}"), name)


def style_paragraph(paragraph, before=0, after=5, line=1.35, keep=False) -> None:
    fmt = paragraph.paragraph_format
    fmt.space_before = Pt(before)
    fmt.space_after = Pt(after)
    fmt.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
    fmt.line_spacing = line
    fmt.keep_together = keep


def add_text(paragraph, text: str, bold=False, color=DARK, size=10.5):
    run = paragraph.add_run(text)
    set_font(run, size=size, bold=bold, color=color)
    return run


def add_body(doc, text: str, bold_prefix: str | None = None):
    p = doc.add_paragraph()
    style_paragraph(p)
    if bold_prefix and text.startswith(bold_prefix):
        add_text(p, bold_prefix, bold=True)
        add_text(p, text[len(bold_prefix):])
    else:
        add_text(p, text)
    return p


def add_heading(doc, text: str, level: int):
    p = doc.add_paragraph()
    p.style = f"Heading {level}"
    style_paragraph(p, before=11 if level == 1 else 8, after=5, line=1.1, keep=True)
    p.paragraph_format.keep_with_next = True
    run = p.add_run(text)
    set_font(run, size={1: 16, 2: 13, 3: 11.5}[level], bold=True, color=ORANGE if level == 1 else DARK)
    return p


def create_numbering_instance(doc, abstract_num_id: int) -> int:
    numbering = doc.part.numbering_part.element
    existing = [int(node.get(qn("w:numId"))) for node in numbering.findall(qn("w:num"))]
    num_id = (max(existing) + 1) if existing else 1
    num = OxmlElement("w:num")
    num.set(qn("w:numId"), str(num_id))
    abstract = OxmlElement("w:abstractNumId")
    abstract.set(qn("w:val"), str(abstract_num_id))
    num.append(abstract)
    numbering.append(num)
    return num_id


def add_bullets(doc, items: Iterable[str], numbered=False):
    num_id = create_numbering_instance(doc, 3 if numbered else 2)
    for item in items:
        p = doc.add_paragraph(style="List Paragraph")
        style_paragraph(p, after=3, line=1.25)
        p_pr = p._p.get_or_add_pPr()
        num_pr = OxmlElement("w:numPr")
        ilvl = OxmlElement("w:ilvl")
        ilvl.set(qn("w:val"), "0")
        num_id_node = OxmlElement("w:numId")
        num_id_node.set(qn("w:val"), str(num_id))
        num_pr.append(ilvl)
        num_pr.append(num_id_node)
        p_pr.insert(0, num_pr)
        add_text(p, item)


def add_callout(doc, label: str, text: str, fill=LIGHT_ORANGE):
    p = doc.add_paragraph()
    style_paragraph(p, before=5, after=8, line=1.3, keep=True)
    p.paragraph_format.left_indent = Inches(0.12)
    p.paragraph_format.right_indent = Inches(0.12)
    p_pr = p._p.get_or_add_pPr()
    shd = OxmlElement("w:shd")
    shd.set(qn("w:fill"), fill)
    p_pr.append(shd)
    borders = OxmlElement("w:pBdr")
    left = OxmlElement("w:left")
    left.set(qn("w:val"), "single")
    left.set(qn("w:sz"), "18")
    left.set(qn("w:space"), "8")
    left.set(qn("w:color"), ORANGE)
    borders.append(left)
    p_pr.append(borders)
    add_text(p, label + "  ", bold=True, color=ORANGE)
    add_text(p, text, color=DARK)


def add_table(doc, headers: Sequence[str], rows: Sequence[Sequence[str]], widths: Sequence[int], font_size=9.2):
    table = doc.add_table(rows=1, cols=len(headers))
    set_table_geometry(table, widths)
    set_table_borders(table)
    repeat_header(table.rows[0])
    for i, header in enumerate(headers):
        cell = table.rows[0].cells[i]
        set_cell_shading(cell, ORANGE)
        cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
        p = cell.paragraphs[0]
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        style_paragraph(p, after=0, line=1.1)
        add_text(p, header, bold=True, color=WHITE, size=font_size)
    for r_index, values in enumerate(rows):
        cells = table.add_row().cells
        for i, value in enumerate(values):
            cell = cells[i]
            set_cell_width(cell, widths[i])
            set_cell_margins(cell)
            cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
            if r_index % 2 == 1:
                set_cell_shading(cell, LIGHT_GRAY)
            p = cell.paragraphs[0]
            style_paragraph(p, after=0, line=1.15)
            add_text(p, str(value), size=font_size)
    set_table_geometry(table, widths)
    spacer = doc.add_paragraph()
    style_paragraph(spacer, after=3, line=1.0)
    return table


def add_flow(doc):
    headers = ["Android 11 应用", "JNI / AAR 封装", "厂商 Android SDK", "读卡器 / PSAM", "一卡通中心"]
    widths = [1661, 1661, 1662, 1661, 1661]
    table = doc.add_table(rows=1, cols=5)
    set_table_geometry(table, widths)
    set_table_borders(table, color="F7B185", size="8")
    for i, text in enumerate(headers):
        cell = table.rows[0].cells[i]
        set_cell_shading(cell, LIGHT_ORANGE if i % 2 == 0 else LIGHT_BLUE)
        p = cell.paragraphs[0]
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        style_paragraph(p, after=0, line=1.1)
        add_text(p, text, bold=True, size=9.1, color=ORANGE if i % 2 == 0 else DARK)
    set_table_geometry(table, widths)
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    style_paragraph(p, before=2, after=7)
    add_text(p, "业务与账务控制：Android 应用通过 HTTPS 调用智慧食堂 10 个标准接口。", bold=True, color=MID, size=9.5)


def configure_styles(doc: Document) -> None:
    styles = doc.styles
    normal = styles["Normal"]
    normal.font.name = BODY_FONT
    normal.font.size = Pt(10.5)
    normal._element.rPr.rFonts.set(qn("w:eastAsia"), BODY_FONT)
    normal.paragraph_format.space_after = Pt(5)
    normal.paragraph_format.line_spacing = 1.35
    for level, size in ((1, 16), (2, 13), (3, 11.5)):
        style = styles[f"Heading {level}"]
        style.font.name = BODY_FONT
        style.font.size = Pt(size)
        style.font.bold = True
        style._element.rPr.rFonts.set(qn("w:eastAsia"), BODY_FONT)
        style.font.color.rgb = RGBColor.from_string(ORANGE if level == 1 else DARK)
        style.paragraph_format.keep_with_next = True
    list_style = styles["List Paragraph"]
    list_style.font.name = BODY_FONT
    list_style._element.rPr.rFonts.set(qn("w:eastAsia"), BODY_FONT)
    list_style.font.size = Pt(10.5)


def add_cover(doc: Document) -> None:
    for _ in range(4):
        p = doc.add_paragraph()
        style_paragraph(p, after=0)
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    style_paragraph(p, after=10, line=1.0)
    add_text(p, "Android 11 一卡通原生 SDK", bold=True, color=ORANGE, size=25)
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    style_paragraph(p, after=20, line=1.0)
    add_text(p, "对接需求说明", bold=True, color=DARK, size=22)
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    style_paragraph(p, after=28)
    add_text(p, "适用于智慧食堂 Android 11 设备直连一卡通读卡器、PSAM 与中心系统", color=MID, size=11)
    add_table(doc, ["项目", "内容"], [
        ["文档版本", "V1.0（评审稿）"],
        ["编制日期", "2026年8月21日"],
        ["适用对象", "一卡通厂商、Android工程师、智慧食堂服务端工程师、测试与交付人员"],
        ["结论边界", "厂商提供可运行的 Android 11 SDK、驱动及授权后方可进入开发；无实机证据不得宣称上线。"],
    ], [1800, 6506], font_size=9.5)
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    style_paragraph(p, before=20, after=0)
    add_text(p, "康比特数字体育科技", bold=True, color=DARK, size=12)
    p.add_run().add_break(WD_BREAK.PAGE)


def build_docx(template: Path, output: Path) -> None:
    doc = Document(str(template))
    configure_styles(doc)
    doc.core_properties.title = "Android 11 一卡通原生 SDK 对接需求说明"
    doc.core_properties.subject = "智慧食堂 Android 11 原生一卡通集成"
    doc.core_properties.author = "康比特数字体育科技"
    doc.core_properties.last_modified_by = "康比特数字体育科技"
    section = doc.sections[0]
    section.start_type = WD_SECTION.NEW_PAGE
    for paragraph in section.header.paragraphs:
        for run in paragraph.runs:
            original_size = run.font.size.pt if run.font.size else 12
            set_font(run, name=BODY_FONT, size=original_size, bold=bool(run.bold), color=DARK)
    add_cover(doc)

    add_heading(doc, "1. 项目结论与决策条件", 1)
    add_callout(doc, "推荐结论", "Android 11 设备要直接完成读卡、扣款和人员查询，必须由一卡通厂商提供适配该设备 CPU 架构、读卡器和 PSAM 的 Android SDK。现有 Windows x86 的 Change.dll、ChangeRest.exe 及其依赖不能直接放入 Android，也不能通过改后缀或普通 NDK 编译转换为可用 .so。")
    add_body(doc, "本需求文档用于冻结“方案二：Android 11 原生接入一卡通 SDK”的交付边界。厂商 SDK 是技术前置条件；SDK、驱动、授权或样机任一缺失时，项目只能停留在方案评审，不能承诺真实扣款上线。")
    add_table(doc, ["判定条件", "处理结论"], [
        ["厂商提供 Android 11 SDK、驱动、Demo、授权和样机", "进入原生适配开发与测试。"],
        ["只提供 Windows DLL/EXE，不提供 Android SDK", "本方案不可实施；改走 Windows 中转机方案。"],
        ["仅提供部分 C/C++ 源码", "仍需确认全部依赖、读卡器驱动、PSAM协议和授权算法均可在 Android 使用。"],
        ["读卡器只支持 Windows 驱动", "Android 直连不可实施；更换支持 Android 的读卡器或采用 Windows 中转。"],
    ], [3000, 5306])

    add_heading(doc, "2. 建设目标、范围与非目标", 1)
    add_heading(doc, "2.1 建设目标", 2)
    add_bullets(doc, [
        "Android 11 智慧食堂设备能够识别一卡通卡片、读取账户和钱包状态，并在授权交易中完成扣款。",
        "智慧食堂消费订单与一卡通扣卡、中心上传结果建立一一对应、可追溯、可恢复的账务关系。",
        "智慧食堂能够查询一卡通人员，并维护人员、账户、卡片与智慧食堂人员的映射关系。",
        "异常情况下禁止自动重复扣款；已扣未上传的交易只能重传原记录。",
        "形成可复制到同型号 Android 11 设备的安装包、配置、测试用例和现场验收流程。",
    ])
    add_heading(doc, "2.2 本期范围", 2)
    add_bullets(doc, [
        "Android 11 应用、JNI/AAR封装、厂商原生库、读卡器、PSAM和一卡通中心之间的最小可用链路。",
        "主钱包和补助钱包的读取与扣款；钱包定义最终以厂商确认文档为准。",
        "人员单查、批量同步、部门和黑名单能力；具体范围以厂商 SDK 实际支持为准。",
        "智慧食堂既定10个标准接口、任务状态机、本地交易日志、补传和对账。",
    ])
    add_heading(doc, "2.3 非目标", 2)
    add_bullets(doc, [
        "不把 Windows PE/DLL 直接加载到 Android，不采用 Wine、虚拟机、模拟器或反编译重建作为生产方案。",
        "不在厂商未明确支持时自行承诺离线扣款、退款、充值、提现、挂失解挂或发卡能力。",
        "不使用生产人员、生产卡或大额资金进行开发测试。",
        "不以模拟测试、接口返回200或应用能启动代替真实读卡、扣卡和中心入账验收。",
    ])

    add_heading(doc, "3. 总体架构", 1)
    add_flow(doc)
    add_body(doc, "Android 侧建议拆分为“业务应用层、交易编排层、SDK适配层、厂商原生层”四层。业务应用不直接调用 .so；SDK适配层负责JNI、线程模型和错误码转换；交易编排层负责订单幂等、本地日志、状态机和智慧食堂10接口；厂商原生层只处理设备和一卡通协议。")
    add_callout(doc, "架构约束", "真实卡号、PSAM密钥和一卡通授权材料不得上传到普通业务日志。智慧食堂服务端只保存脱敏卡号或不可逆哈希；厂商中心上传所需的完整卡数据只在受控交易进程内短暂使用。", fill=LIGHT_BLUE)

    add_heading(doc, "4. 项目角色与责任边界", 1)
    add_table(doc, ["角色", "主要职责", "完成证据"], [
        ["一卡通厂商", "提供 Android SDK、驱动、授权、协议文档、Demo、错误码及技术支持；确认交易字段和中心上传规则。", "SDK包、版本说明、校验和、Demo运行记录、厂商确认单。"],
        ["Android工程师", "完成AAR/JNI封装、设备权限、SDK适配、本地状态机、安全存储、UI提示及APK交付。", "源码、APK、构建记录、单元测试、实机日志和安装说明。"],
        ["智慧食堂服务端工程师", "维护10接口、任务租约、共享账本、人员映射、对账、告警和管理查询。", "OpenAPI、接口测试、数据库记录、对账结果和部署记录。"],
        ["测试工程师", "执行接口、设备、资金、异常恢复、安全和兼容性测试。", "测试报告、缺陷单、回归记录、证据截图或日志。"],
        ["交付/项目负责人", "协调样机、网络、测试卡、厂商窗口、试点发布、回滚和客户验收。", "现场清单、变更记录、验收单和问题闭环表。"],
    ], [1500, 4300, 2506], font_size=8.8)

    add_heading(doc, "5. 厂商必须交付的 Android SDK 资料", 1)
    add_table(doc, ["编号", "交付项", "最低要求", "验收方式"], [
        ["V-01", "平台支持声明", "明确支持 Android 11、目标设备型号、CPU ABI、读卡器型号、PSAM型号。", "厂商书面确认并与样机一致。"],
        ["V-02", "原生库", "提供 arm64-v8a；如设备需要，再提供 armeabi-v7a。不得只提供 Windows DLL。", "APK安装后成功加载，ABI无缺失。"],
        ["V-03", "AAR/JAR/JNI头文件", "提供稳定Java/Kotlin调用面或完整JNI接口、数据结构、线程及内存规则。", "最小Demo可编译、可运行。"],
        ["V-04", "读卡器与PSAM驱动", "明确USB/串口/HID/厂商服务方式、权限、VID/PID、设备节点和初始化流程。", "实机打开设备并读取健康状态。"],
        ["V-05", "接口文档", "覆盖初始化、寻卡、读卡、扣款、上传、人员、部门、黑名单、关闭和异常处理。", "接口与SDK符号逐项对应。"],
        ["V-06", "数据结构和对齐", "给出字段类型、长度、编码、Pack、单位、时间格式、钱包枚举和返回码。", "结构体/DTO自动校验通过。"],
        ["V-07", "授权与初始化", "说明授权文件、机器绑定、有效期、升级迁移及离线授权流程。", "重装、升级和换机场景可验证。"],
        ["V-08", "中心连接与上传", "说明WebService/接口地址配置、应用、终端、科目、混淆参数及CapUpload等价规则。", "测试中心可查到真实测试流水。"],
        ["V-09", "Demo与测试工具", "提供只读Demo、扣款Demo、上传Demo、人员查询Demo及日志说明。", "厂商和双方工程师共同跑通。"],
        ["V-10", "版本与安全", "提供版本号、发布日期、SHA-256、变更记录、已知问题、签名和回滚包。", "交付包可校验且来源可追溯。"],
        ["V-11", "技术支持", "指定问题受理方式，明确设备、SDK、中心接口问题的责任边界。", "联调期内问题可闭环。"],
    ], [600, 1400, 3900, 2406], font_size=8.2)

    add_heading(doc, "6. Android SDK 最小能力要求", 1)
    add_table(doc, ["能力域", "建议封装接口", "输入/输出要点", "必须满足"], [
        ["生命周期", "initialize / release", "应用上下文、授权、设备配置；返回SDK版本和初始化状态。", "重复初始化安全，释放后资源关闭。"],
        ["设备健康", "getDeviceHealth", "读卡器、PSAM、授权、网络、中心连接状态。", "不得仅返回“库已加载”。"],
        ["打开/关闭", "openReader / closeReader", "设备句柄或明确返回码。", "失败可诊断，进程退出自动释放。"],
        ["寻卡", "queryCard", "返回是否有卡及卡UID。", "无卡不是系统异常。"],
        ["读卡", "readCard", "账户、卡状态、卡类型、卡序列、主/补助余额和操作计数。", "挂失、冻结、无效卡可识别。"],
        ["扣款", "debit", "客户账号、金额分、钱包号、交易时间；返回PSAM、TAC和结果。", "金额、钱包、卡账号和状态校验。"],
        ["写后复读", "readCardAfterDebit", "返回写卡后的余额和opCount。", "上传必须使用写卡后值。"],
        ["上传中心", "uploadTrade", "原交易完整凭据；返回中心业务结果和可查询流水。", "失败不得重新扣卡。"],
        ["人员", "getPerson / getPersons", "按账号单查、按游标批量查询。", "支持增量游标或明确全量策略。"],
        ["组织", "getDepartments", "部门编码、层级和名称。", "编码稳定、中文无乱码。"],
        ["黑名单", "getBlacklist", "黑名单卡号或厂商定义标识。", "同步可重复执行。"],
        ["诊断", "getLastError / exportDiagnostics", "错误码、设备信息和脱敏日志。", "不输出密钥和完整卡号。"],
    ], [1200, 1700, 3200, 2206], font_size=8.2)

    add_heading(doc, "7. 交易数据契约", 1)
    add_heading(doc, "7.1 通用规则", 2)
    add_bullets(doc, [
        "金额统一使用整数分，禁止使用浮点数；扣款金额必须大于0，并受厂商和业务上限约束。",
        "业务订单号 client_order_no 在同一机构内唯一；设备请求号 agent_request_id 全局唯一。",
        "时间使用ISO 8601传输；调用厂商SDK时转换为厂商确认的本地时间格式。",
        "卡号上传智慧食堂服务端时只传不可逆哈希和脱敏显示值；中心上传需要的完整值不离开受控SDK进程。",
        "人员、机构、终端、应用、科目和钱包枚举均需携带明确业务域，禁止仅凭手机号判断一卡通身份。",
    ])
    add_heading(doc, "7.2 扣款结果最小字段", 2)
    add_table(doc, ["字段", "类型", "说明", "要求"], [
        ["client_order_no", "string", "智慧食堂业务订单号", "必填、唯一、不可修改。"],
        ["result", "enum", "success / failed / unknown", "unknown必须进入人工核查。"],
        ["result_code", "string", "厂商原始码或标准映射码", "保留原始码便于追溯。"],
        ["amount_cents", "int64", "实际扣款金额（分）", "必须等于任务金额。"],
        ["wallet_no", "int", "主钱包/补助钱包", "由厂商确认枚举。"],
        ["balance_after", "int64", "写卡后余额（分）", "来自写后复读。"],
        ["op_count_after", "int64", "写卡后操作次数", "上传中心的关键字段。"],
        ["psam_id", "string", "PSAM编号", "按字符串保存，避免精度丢失。"],
        ["psam_trade_no", "string", "PSAM交易流水", "与TAC共同用于查重。"],
        ["tac", "string", "交易认证码", "按厂商格式保存。"],
        ["card_no_hash", "string", "卡号不可逆哈希", "禁止上传完整卡号。"],
        ["occurred_at", "datetime", "实际交易时间", "设备时钟必须校准。"],
    ], [1700, 1000, 3200, 2406], font_size=8.4)
    add_heading(doc, "7.3 中心上传记录", 2)
    add_body(doc, "上传记录至少覆盖终端ID、客户账号、卡号、卡序列、卡类型、卡应用序列、总金额、管理费、钱包号、扣款金额、写后余额、写后opCount、交易时间、PSAM编号、PSAM流水和TAC。字段名称、长度、编码和成功判定由一卡通厂商最终确认。")

    add_heading(doc, "8. 智慧食堂 10 个标准接口", 1)
    add_table(doc, ["序号", "方法与路径", "用途", "幂等/安全要求"], [
        ["1", "GET /api/v1/one-card-agent/tasks", "领取待执行消费任务", "同一任务只能有一个有效设备租约。"],
        ["2", "POST /api/v1/one-card-agent/task-acceptances", "接受并锁定任务", "重复接受返回原租约。"],
        ["3", "POST /api/v1/one-card-agent/consume-results", "回传读卡和扣卡结果", "订单号+PSAM/TAC/opCount去重。"],
        ["4", "POST /api/v1/one-card-agent/upload-results", "回传中心上传结果", "已扣未上传只能重传原记录。"],
        ["5", "GET /api/v1/one-card-agent/recovery-tasks", "领取补传和核查任务", "不得返回重新扣卡动作。"],
        ["6", "POST /api/v1/one-card-agent/heartbeats", "上报设备与队列健康", "不上传密钥和完整卡号。"],
        ["7", "POST /api/v1/one-card-agent/identity-batches", "上报人员和账户批次", "batch_id+cursor幂等。"],
        ["8", "GET /api/v1/one-card-agent/persons/{external_no}", "按人员编号查询智慧食堂人员", "只返回最小必要字段。"],
        ["9", "POST /api/v1/one-card-agent/identity-batch-acknowledgements", "确认人员批次游标", "重复确认安全。"],
        ["10", "POST /api/v1/one-card-agent/reconciliation-results", "回传查单和日终对账", "未知结果不能覆盖已结算事实。"],
    ], [500, 3300, 2200, 2306], font_size=7.9)
    add_callout(doc, "接口方向", "Android应用通过出站HTTPS调用智慧食堂服务端。服务端不主动访问Android设备，不在公网开放读卡器或厂商SDK端口。", fill=LIGHT_BLUE)

    add_heading(doc, "9. 消费交易流程", 1)
    add_bullets(doc, [
        "智慧食堂生成消费订单和唯一 client_order_no。",
        "Android设备领取任务，并用 agent_request_id 接受任务、获得设备租约。",
        "设备写入本地交易日志，状态进入 device_processing，再打开读卡器、寻卡和读卡。",
        "校验客户账号、卡状态、钱包、余额、金额和任务租约；任何不一致均禁止扣款。",
        "调用厂商 debit；明确成功后立即持久化PSAM、TAC和写卡成功事实。",
        "重新读卡，取得写卡后的余额和opCount；回传 consume-results。",
        "连接一卡通中心并上传同一笔原始交易记录；回传 upload-results。",
        "中心确认成功后任务进入 settled；失败进入 upload_retrying，只重传上传记录。",
        "关闭读卡器和中心连接，保留可审计的脱敏日志。",
    ], numbered=True)
    add_callout(doc, "资金红线", "一旦本地日志记录“卡已扣”，后续任何重启、超时、网络失败或服务端重试都不得再次调用扣款函数。结果不明确时进入 manual_review，通过查卡、查中心流水或人工核查确认。")

    add_heading(doc, "10. 人员查询与同步流程", 1)
    add_bullets(doc, [
        "设备或定时任务从厂商SDK按游标读取一卡通人员、账户、卡状态、部门和必要标识。",
        "Android侧将厂商字段映射为 external_no、姓名、部门、账户状态、卡状态等最小字段。",
        "通过 identity-batches 分批上传；同一 batch_id 和 cursor 重放不得重复生成映射。",
        "服务端按机构业务域和人员编号匹配智慧食堂人员，产生成功、待确认或冲突结果。",
        "设备通过 identity-batch-acknowledgements 确认处理游标；失败批次必须可重试。",
        "单人核验通过 persons/{external_no} 查询；服务端只返回最小必要信息和脱敏卡号。",
    ], numbered=True)
    add_table(doc, ["人员字段", "来源", "用途", "隐私要求"], [
        ["external_no", "一卡通人员/学工号", "跨系统主匹配键", "必填，机构内唯一。"],
        ["name", "一卡通人员姓名", "人工核验", "仅授权界面显示。"],
        ["department_code", "一卡通部门编码", "组织映射", "保留编码和层级。"],
        ["account_status", "一卡通账户状态", "判断是否允许消费", "不得由前端自行推断。"],
        ["card_no_masked", "本地脱敏生成", "人工识别", "不上传完整卡号。"],
        ["updated_at/cursor", "厂商同步信息", "增量和幂等", "必须可追溯。"],
    ], [1700, 1900, 2600, 2106], font_size=8.7)

    add_heading(doc, "11. 状态机、幂等与恢复", 1)
    add_body(doc, "主状态链：waiting_device -> device_processing -> card_debited_pending_upload -> settled。")
    add_table(doc, ["状态", "含义", "允许动作", "禁止动作"], [
        ["waiting_device", "等待设备领取", "领取并加租约", "无租约直接扣卡。"],
        ["device_processing", "正在寻卡/读卡/扣卡", "按租约执行一次", "其他设备并发执行。"],
        ["card_write_failed", "明确未扣卡", "结束任务；业务可创建新任务", "把失败当成功。"],
        ["card_debited_pending_upload", "已扣卡、待中心上传", "保存原记录并重传上传", "重新扣卡。"],
        ["upload_retrying", "中心上传失败", "指数退避、人工告警、原记录重传", "修改交易关键字段。"],
        ["manual_review", "扣款结果不明确或数据不一致", "查卡、查流水、人工裁决", "自动再次扣卡。"],
        ["settled", "扣卡和中心入账均确认", "查询、对账、审计", "未知结果覆盖已结算状态。"],
    ], [1700, 2600, 2200, 1806], font_size=8.5)
    add_heading(doc, "11.1 Android 本地交易日志", 2)
    add_bullets(doc, [
        "使用受控本地数据库或日志存储 client_order_no、状态、金额、钱包、SDK结果、PSAM/TAC、opCount、上传结果和重试次数。",
        "密钥由Android Keystore保护；敏感卡数据不以明文持久化。",
        "每次状态变更先持久化再回调服务端；应用崩溃或断电后按日志恢复。",
        "日志清理必须满足账务留存和审计要求；清理周期由项目负责人、财务和安全共同确认。",
    ])

    add_heading(doc, "12. 安全要求", 1)
    add_table(doc, ["安全域", "要求", "验收证据"], [
        ["通信", "生产环境HTTPS；请求使用时间戳、随机数和HMAC-SHA256签名，拒绝重放。", "无签名、错签名、过期和重复nonce均被拒绝。"],
        ["密钥", "设备密钥放入Android Keystore或受控设备配置，不写入APK源码、日志或Git。", "反编译APK不出现明文密钥。"],
        ["组件", "SDK服务、Activity、Receiver默认不导出；必须导出时设置签名级权限。", "Manifest和安全扫描通过。"],
        ["卡数据", "服务端只存哈希/脱敏卡号；日志不记录完整卡号、密钥、授权码。", "日志抽检和隐私扫描通过。"],
        ["SDK来源", "校验厂商签名、版本和SHA-256；升级走受控发布。", "交付清单和校验结果一致。"],
        ["网络边界", "设备不对公网开放读卡、扣款或调试端口；生产关闭可写Swagger/调试入口。", "端口扫描和配置回读通过。"],
        ["权限", "只申请设备真实需要的USB、串口、网络等权限。", "权限清单与SDK文档一致。"],
    ], [1400, 4300, 2606], font_size=8.5)

    add_heading(doc, "13. 非功能要求", 1)
    add_callout(doc, "指标说明", "下列为建议验收目标，最终数值需在样机和厂商测试环境中确认。无法达到时，厂商需给出实测基线和优化建议。", fill=LIGHT_BLUE)
    add_table(doc, ["指标", "建议目标", "测量条件"], [
        ["SDK初始化", "不高于10秒", "冷启动、授权有效、设备连接正常。"],
        ["寻卡响应", "放卡后不高于2秒", "单卡、正常读卡距离。"],
        ["读卡响应", "不高于2秒", "卡状态正常。"],
        ["本地扣款", "不高于3秒", "不含人工放卡和中心网络耗时。"],
        ["中心上传", "正常网络下不高于5秒", "测试中心可达。"],
        ["人员单查", "不高于2秒", "测试数据量和索引正常。"],
        ["1000人批量同步", "不高于60秒", "分批上传，不阻塞消费主链路。"],
        ["故障恢复", "重启后自动识别未完成交易", "任何恢复场景物理扣款调用次数仍为1。"],
        ["并发", "单读卡器交易严格串行", "多业务请求排队且租约不串单。"],
    ], [2200, 2100, 4006], font_size=8.8)

    add_heading(doc, "14. 错误处理与用户提示", 1)
    add_table(doc, ["标准错误", "典型原因", "设备提示", "后台处理"], [
        ["NO_CARD", "未放卡或卡移开", "请将一卡通卡放到感应区", "不记扣款，可重新寻卡。"],
        ["READER_UNAVAILABLE", "驱动、权限或设备异常", "读卡器不可用，请联系工作人员", "告警并上报心跳异常。"],
        ["CARD_INVALID", "挂失、冻结、无效卡", "卡片状态异常，无法消费", "保留厂商码，不扣款。"],
        ["ACCOUNT_MISMATCH", "任务人员与卡账号不一致", "人员与卡片不一致", "进入异常日志，不扣款。"],
        ["INSUFFICIENT_BALANCE", "钱包余额不足", "余额不足，请更换支付方式", "明确失败，不重试扣款。"],
        ["DEBIT_FAILED", "厂商明确写卡失败", "扣款失败，请重新操作", "结束任务；新交易需新订单号。"],
        ["DEBIT_UNKNOWN", "超时、崩溃或结果不明", "交易核查中，请勿重复刷卡", "进入manual_review。"],
        ["UPLOAD_FAILED", "中心网络或业务拒绝", "扣卡成功，记录补传中", "只重传原上传记录。"],
        ["AUTH_FAILED", "SDK授权或设备密钥失效", "设备未授权", "停止交易并告警。"],
    ], [1600, 2100, 2500, 2106], font_size=8.4)

    add_heading(doc, "15. 测试范围与用例矩阵", 1)
    add_table(doc, ["测试域", "必须覆盖", "通过标准"], [
        ["安装与ABI", "Android 11、目标ABI、冷启动、升级、卸载重装、授权迁移。", "SDK加载成功，无UnsatisfiedLinkError。"],
        ["设备", "无读卡器、无PSAM、权限拒绝、拔插、占用、重启。", "错误可诊断，不崩溃、不误扣。"],
        ["卡片", "正常、挂失、冻结、无效、非本系统卡、快速移卡。", "状态识别正确。"],
        ["钱包", "主钱包、补助钱包、余额不足、恰好余额、钱包禁用。", "扣款钱包和金额正确。"],
        ["金额", "1分、小额、上限、0、负数、超限和金额篡改。", "非法金额在扣卡前拒绝。"],
        ["幂等", "重复点击、HTTP重试、同订单重放、双设备抢任务。", "同一订单物理扣款最多1次。"],
        ["断网", "扣前断网、扣后断网、上传中断、中心超时。", "已扣交易只补传，不再扣卡。"],
        ["进程恢复", "扣前杀进程、扣后杀进程、重启、断电模拟。", "按本地日志恢复，账务可追溯。"],
        ["人员", "单查、全量、增量、重复批次、游标回退、冲突、中文。", "映射正确且幂等。"],
        ["安全", "无签名、错签名、重放、越权组件、日志泄露、SDK篡改。", "请求被拒绝且无敏感泄露。"],
        ["中心对账", "扣卡成功上传成功、已扣未传、重复上传、中心拒绝。", "卡、中心和智慧食堂三方一致。"],
    ], [1500, 4300, 2506], font_size=8.5)

    add_heading(doc, "16. 分阶段验收门禁", 1)
    add_table(doc, ["门禁", "验收内容", "通过证据", "未通过处理"], [
        ["G0 资料门禁", "SDK、文档、Demo、授权、样机和测试环境齐全。", "交付清单和SHA-256。", "停止排期承诺。"],
        ["G1 编译门禁", "AAR/JNI/.so在目标ABI编译和加载。", "可复现构建记录。", "由厂商修复SDK。"],
        ["G2 只读门禁", "打开设备、寻卡、读卡、人员查询。", "实机脱敏日志和测试报告。", "禁止进入扣款测试。"],
        ["G3 小额扣款", "授权测试卡、最小批准金额、写后复读。", "卡余额和opCount变化。", "停止并核查卡片。"],
        ["G4 中心入账", "原交易上传且中心可查询。", "中心流水和三方字段核对。", "仅补传，不重复扣款。"],
        ["G5 10接口闭环", "任务、结果、人员、心跳、恢复和对账。", "自动化契约测试和数据库证据。", "不得宣称全链路。"],
        ["G6 异常恢复", "断网、超时、重启、重复和未知结果。", "物理扣款次数=1。", "进入人工核查。"],
        ["G7 安全门禁", "签名、密钥、组件、日志和SDK来源。", "安全检查报告。", "不得试点发布。"],
        ["G8 试点验收", "受控设备、受控人员、连续运行和日终对账。", "客户/项目验收记录。", "回滚并保留账务日志。"],
    ], [1200, 3000, 2500, 1606], font_size=8.3)
    add_callout(doc, "现场资金测试", "必须使用厂商和项目负责人共同授权的测试卡及小额金额。测试前记录初始余额和opCount，测试后核对卡片、中心流水和智慧食堂订单。未经授权不得操作生产卡。")

    add_heading(doc, "17. 发布、运维与回滚要求", 1)
    add_bullets(doc, [
        "SDK版本、AAR/.so、APK和配置模板均纳入版本清单并保存SHA-256；生产配置不得写入源码或APK资源。",
        "使用功能开关按机构、设备逐步启用；默认关闭真实一卡通扣款，完成G0-G7后才能试点。",
        "Android服务需要开机恢复、进程重启恢复、网络恢复和队列告警；不得依赖人工每天启动。",
        "升级前导出未完成交易清单；升级、降级和回滚不得删除本地交易日志。",
        "回滚只停止新交易，不改变已扣卡事实；已扣未传记录必须继续补传或进入人工核查。",
        "每台设备记录设备编号、SDK版本、APK版本、读卡器、PSAM、授权状态和最后心跳。",
    ])

    add_heading(doc, "18. 待厂商确认问题清单", 1)
    add_table(doc, ["编号", "问题", "确认输出", "责任方"], [
        ["Q-01", "Android 11及目标CPU ABI是否正式支持？", "支持矩阵和版本号", "一卡通厂商"],
        ["Q-02", "目标设备内置读卡器和PSAM的型号、接口及驱动方式是什么？", "硬件与驱动说明", "设备厂商/一卡通厂商"],
        ["Q-03", "是否提供arm64-v8a的.so及AAR/JAR？", "SDK交付包", "一卡通厂商"],
        ["Q-04", "扣款调用约定、线程模型、结构体对齐和字符编码是什么？", "接口与ABI文档", "一卡通厂商"],
        ["Q-05", "扣款成功后如何取得写卡后的余额和opCount？", "示例代码和字段说明", "一卡通厂商"],
        ["Q-06", "中心上传成功的准确判定和可查询流水字段是什么？", "返回码和查单说明", "一卡通厂商"],
        ["Q-07", "应用、终端、科目、混淆参数如何配置和轮换？", "配置说明", "一卡通厂商/项目负责人"],
        ["Q-08", "授权是否绑定设备、有效期多久、换机和升级如何迁移？", "授权生命周期说明", "一卡通厂商"],
        ["Q-09", "是否支持主钱包、补助钱包、离线扣款、退款或冲正？", "能力边界", "一卡通厂商"],
        ["Q-10", "人员同步是否有增量游标、更新时间或事件机制？", "同步协议", "一卡通厂商"],
        ["Q-11", "SDK是否支持多进程/多线程？单读卡器是否必须全局串行？", "并发说明", "一卡通厂商"],
        ["Q-12", "厂商能否提供测试中心、测试卡和联调支持？", "测试资源清单", "一卡通厂商/项目负责人"],
    ], [650, 3900, 2400, 1356], font_size=8.2)

    add_heading(doc, "19. 最终交付物清单", 1)
    add_table(doc, ["交付方", "交付物", "完成标准"], [
        ["一卡通厂商", "SDK、驱动、AAR/JAR/.so、头文件、Demo、授权、接口/错误码/升级文档。", "来源、版本、校验和、样机运行和技术支持齐全。"],
        ["Android工程师", "SDK适配模块、JNI/AAR封装、交易编排、本地日志、APK、安装升级说明。", "代码可构建，实机测试通过，无重复扣款。"],
        ["服务端工程师", "10接口、共享账本、人员映射、恢复对账、管理查询和告警。", "契约测试、数据库回读和接口证据通过。"],
        ["测试/交付", "测试矩阵、缺陷记录、实机资金证据、中心查询、部署和回滚记录。", "G0-G8逐项签字，未测项明确。"],
    ], [1500, 4300, 2506], font_size=8.6)

    add_heading(doc, "20. 评审结论记录", 1)
    add_table(doc, ["评审角色", "姓名/单位", "结论", "日期", "备注"], [
        ["一卡通厂商", "", "同意 / 修改后同意 / 不同意", "", ""],
        ["Android负责人", "", "同意 / 修改后同意 / 不同意", "", ""],
        ["服务端负责人", "", "同意 / 修改后同意 / 不同意", "", ""],
        ["测试负责人", "", "同意 / 修改后同意 / 不同意", "", ""],
        ["项目负责人", "", "同意 / 修改后同意 / 不同意", "", ""],
    ], [1500, 1600, 2500, 1200, 1506], font_size=8.6)
    add_callout(doc, "文档状态", "本版本为评审稿。所有标注“待厂商确认”或依赖样机的内容，在取得书面确认和实机证据前均不是上线承诺。", fill=LIGHT_BLUE)

    output.parent.mkdir(parents=True, exist_ok=True)
    doc.save(str(output))


MARKDOWN = r'''# Android 11 一卡通原生 SDK 对接需求说明

- 文档版本：V1.0（评审稿）
- 编制日期：2026年8月21日
- 适用对象：一卡通厂商、Android工程师、智慧食堂服务端工程师、测试与交付人员
- 结论边界：厂商提供可运行的 Android 11 SDK、驱动及授权后方可进入开发；无实机证据不得宣称上线。

## 1. 项目结论与决策条件

Android 11设备要直接完成读卡、扣款和人员查询，必须由一卡通厂商提供适配该设备CPU架构、读卡器和PSAM的Android SDK。现有Windows x86的Change.dll、ChangeRest.exe及其依赖不能直接放入Android，也不能通过改后缀或普通NDK编译转换为可用.so。

判定规则：厂商提供SDK、驱动、Demo、授权和样机后进入原生开发；只提供Windows DLL/EXE时本方案不可实施，应改走Windows中转；仅读卡器Windows驱动时需更换硬件或采用Windows中转。

## 2. 建设目标、范围与非目标

目标：Android 11设备完成读卡和授权扣款；智慧食堂订单与一卡通扣卡、中心上传一一对应；支持人员查询与同步；异常时不重复扣款；形成可复制部署包。

范围：Android应用、JNI/AAR、厂商原生库、读卡器、PSAM、中心系统、智慧食堂10接口、状态机、本地日志、补传与对账。

非目标：不转换Windows DLL；不采用Wine/虚拟机；不擅自承诺离线扣款、退款、充值或发卡；不使用生产卡和大额资金开发测试。

## 3. 总体架构

Android应用 -> JNI/AAR封装 -> 厂商Android SDK(.so) -> 读卡器/PSAM -> 一卡通中心。Android应用同时通过HTTPS调用智慧食堂10个标准接口。

业务应用不直接调用.so。SDK适配层负责JNI和错误码；交易编排层负责幂等、日志、状态机和服务端接口；厂商原生层只处理设备和一卡通协议。

## 4. 责任边界

厂商提供SDK、驱动、授权、Demo、错误码和支持；Android工程师负责JNI、权限、交易编排、本地状态和APK；服务端负责10接口、账本、人员映射、恢复与对账；测试和交付负责实机、资金、安全、发布和回滚证据。

## 5. 厂商交付

必须提供Android 11和ABI支持声明、arm64-v8a原生库、AAR/JAR或JNI头文件、读卡器与PSAM驱动、接口与ABI文档、授权机制、中心上传规则、Demo、版本校验和、变更记录、回滚包和联调支持。

## 6. SDK最小能力

初始化、释放、设备健康、打开/关闭、寻卡、读卡、扣款、写后复读、中心上传、人员单查/批量、部门、黑名单和脱敏诊断。健康检查必须真实覆盖读卡器、PSAM、授权和中心连接，不能只报告库已加载。

## 7. 数据契约

金额为整数分；client_order_no唯一；时间为ISO 8601；服务端只存卡号哈希和脱敏值；扣款结果必须包含金额、钱包、写后余额、写后opCount、PSAM、PSAM流水、TAC、结果码和交易时间；中心上传使用同一笔原始交易记录。

## 8. 智慧食堂10接口

1. GET /api/v1/one-card-agent/tasks
2. POST /api/v1/one-card-agent/task-acceptances
3. POST /api/v1/one-card-agent/consume-results
4. POST /api/v1/one-card-agent/upload-results
5. GET /api/v1/one-card-agent/recovery-tasks
6. POST /api/v1/one-card-agent/heartbeats
7. POST /api/v1/one-card-agent/identity-batches
8. GET /api/v1/one-card-agent/persons/{external_no}
9. POST /api/v1/one-card-agent/identity-batch-acknowledgements
10. POST /api/v1/one-card-agent/reconciliation-results

## 9. 交易流程与资金红线

生成订单、设备领取和接受、写本地日志、寻卡读卡、校验、执行一次扣款、立即持久化成功事实、写后复读、回传扣款结果、上传中心、回传上传结果、结算。

一旦记录卡已扣，任何重启、超时、断网和重试都不得再次调用扣款。已扣未传只能补传原记录；结果不明进入manual_review。

## 10. 人员同步

从厂商SDK按游标读取人员、账户、卡状态和部门，通过identity-batches上传，服务端建立映射，设备确认游标。单人核验使用persons/{external_no}。手机号不得作为一卡通身份的唯一判断依据。

## 11. 状态机与恢复

waiting_device -> device_processing -> card_debited_pending_upload -> settled。异常状态包括card_write_failed、upload_retrying和manual_review。本地日志保存订单、状态、金额、钱包、SDK结果、PSAM/TAC、opCount、上传结果和重试次数，密钥由Android Keystore保护。

## 12. 安全要求

生产使用HTTPS、时间戳、nonce和HMAC-SHA256；设备密钥放Android Keystore；组件默认不导出；日志不记录完整卡号、密钥和授权码；校验SDK签名、版本和SHA-256；设备不对公网开放读卡、扣款或调试端口。

## 13. 非功能目标

建议样机目标：初始化10秒、寻卡2秒、读卡2秒、本地扣款3秒、中心上传5秒、单人查询2秒、1000人批量60秒。最终以厂商和样机实测确认为准。任何故障恢复场景同一订单物理扣款调用次数必须为1。

## 14. 错误处理

统一映射NO_CARD、READER_UNAVAILABLE、CARD_INVALID、ACCOUNT_MISMATCH、INSUFFICIENT_BALANCE、DEBIT_FAILED、DEBIT_UNKNOWN、UPLOAD_FAILED和AUTH_FAILED。错误提示必须可操作，后台保留厂商原始码。

## 15. 测试范围

覆盖Android 11与ABI、驱动和权限、读卡器/PSAM、卡片状态、主/补助钱包、金额边界、重复请求、断网、进程杀死、断电恢复、人员同步、安全、中心入账和三方对账。

## 16. 验收门禁

G0资料、G1编译、G2只读、G3授权小额扣款、G4中心入账、G5十接口、G6异常恢复、G7安全、G8试点验收。前一门禁不通过，不进入下一门禁。

## 17. 发布与回滚

SDK、APK和配置纳入版本清单；功能开关默认关闭真实扣款；升级前导出未完成交易；回滚不删除本地日志，不改变已扣卡事实；设备台账记录版本、读卡器、PSAM、授权和心跳。

## 18. 待厂商确认

确认Android 11/ABI支持、读卡器与PSAM型号、SDK交付、调用约定和ABI、写后opCount、中心成功判定、应用/终端/科目/混淆参数、授权生命周期、钱包/离线/退款边界、人员增量、并发模型及测试资源。

## 19. 最终交付物

厂商交付SDK全套；Android交付适配模块、APK、源码、测试和安装说明；服务端交付10接口、账本、人员映射、恢复与对账；测试交付实机资金、中心查询、部署和回滚证据。

## 20. 文档状态

本版本为评审稿。所有待厂商确认或依赖样机的内容，在取得书面确认和实机证据前均不是上线承诺。
'''


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--template", type=Path, required=True)
    parser.add_argument("--docx", type=Path, required=True)
    parser.add_argument("--markdown", type=Path, required=True)
    args = parser.parse_args()
    args.markdown.parent.mkdir(parents=True, exist_ok=True)
    args.markdown.write_text(MARKDOWN.strip() + "\n", encoding="utf-8")
    build_docx(args.template, args.docx)
    digest = hashlib.sha256(args.docx.read_bytes()).hexdigest()
    print(f"DOCX={args.docx}")
    print(f"MARKDOWN={args.markdown}")
    print(f"SHA256={digest}")


if __name__ == "__main__":
    main()
