修复公示导出:模板残留隐藏行把合计行与图表整段藏掉;红绿条件格式按最终行位重建;净值+仓位双系列带标记图表

This commit is contained in:
zlt 2026-08-28 17:20:58 +08:00
parent 88592bb349
commit 3e2a243678
4 changed files with 141 additions and 13 deletions

View File

@ -1107,6 +1107,41 @@ init_db 幂等带上)。无新 env。
--- ---
## 2026-08-28 · 公示导出正式机实锅: 模板隐藏行把合计与图整段藏掉
**做了什么**
正式机首次导出缺了最底部合计行、图表空框。根因: 公司原表把老平仓行折叠隐藏
(24~97 行 hidden), 裁模板时 openpyxl 的 delete_rows 不搬 row_dimensions, 这批隐藏
标记原样进了模板; 表体一长到 24 行 (真机 7 持仓+10 平仓正好踩中), 卖出小计/存量
合计/底行/图表数据全落隐藏带 —— 此前样张 7+7 只到 23 行, 所以怎么测都完整。
开发机 openpyxl 3.1.5 实验复现坐实。修法两层: 模板重裁 (行属性只留 1~11 行、隐藏
标记清零、剔除全部残留条件格式区间与外部工作簿链接, 顺带补回裁剪时丢的「存量合计」
「初始规模+已回收益后可开仓总金额」两个 B 列标签 —— 与上次「平层卖出」同类伤);
代码兜底 (进场即清模板行位之外的行属性与残留条件格式, 行高重排时强制 hidden=False)。
红绿条件格式按最终行位重建: Q/R/S 红涨绿跌字色、持仓 S 列红绿底色 (0 也算红),
颜色与原表 dxf 逐字节同 (openpyxl 去重后直接命中原 18 个 dxf)。图表按原图重做双系列:
净值主轴(左)+仓位占比副轴(右), 每点带标记 (首日单点也看得见), plotVisOnly=0,
smooth=0。O/P 融资两列原表就是折叠的, 属版式, 保留。
**动了哪些文件**
app/assets/publish_template.xlsx (重裁)、app/services/publish_export.py (卫生+条件
格式+双系列图)、scripts/test_wiring.py (公示导出用例补大表回归: 7+10 越过隐藏带,
三行合计/无隐藏行/条件格式区间/双系列带标记断言钉死)。
**部署方式**
两台都要: git pull 后重新 build 部署 (模板打进镜像, 必须重建)。真机 188 与模拟机
tlai4090 同法。无新 env、无 DDL。
**真机判收**
开发机全部 20 套 588 例 ALL PASS (含新回归); 云端 LibreOffice 重算 92 公式 0 错误,
恒等式核过; 7+10 形状与 30 日净值序列两版渲染逐区目验 (三行合计齐、黄条粉底在、
双系列图两轴两色带标记)。部署后判收: 重新点「导出公示表」, 底部三行 (卖出小计/
存量合计/底行) 与图表单点标记都应出现。
**还欠着什么**
上节点的三个口径拍板照旧欠着; 图表逐像素对齐照旧按需。
---
<!-- <!--
下一条节点从这里往下写,格式照抄上面: 下一条节点从这里往下写,格式照抄上面:
## YYYY-MM-DD · 一句话标题 ## YYYY-MM-DD · 一句话标题

Binary file not shown.

View File

@ -4,7 +4,10 @@
==================================================== ====================================================
公司流程: 每日把持仓与净值导出为 excel 公示版式**不重画**: 仓库里放着由公司模板 公司流程: 每日把持仓与净值导出为 excel 公示版式**不重画**: 仓库里放着由公司模板
(量化数据2026.8.3.xlsx) 裁出来的空模板 app/assets/publish_template.xlsx 字体 (量化数据2026.8.3.xlsx) 裁出来的空模板 app/assets/publish_template.xlsx 字体
颜色边框列宽数字格式全部原样保留, 导出时只做三件事: 插行填数重算合并区 颜色边框列宽数字格式全部原样保留, 导出时做四件事: 插行填数按最终行位重排
合并区 / 行高 / 红绿条件格式画净值+仓位双系列折线图openpyxl 的插行既不搬合并区
也不搬 row_dimensions, 这些全按最终行位重建; 模板行位之外的行属性进场即清 (公司原表
把老平仓行折叠隐藏, 残留 hidden 标记曾把整段合计行与图藏掉, 2026-08-28 正式机实锅)
数据只含**系统接管之后**的账本 (2026-08-28 拍板: 不回填历史, 启用日之前的净值与 数据只含**系统接管之后**的账本 (2026-08-28 拍板: 不回填历史, 启用日之前的净值与
明细继续查人工表格) 明细继续查人工表格)
@ -248,10 +251,11 @@ def _nav_series(snapshot: dict) -> list:
# ---------------------------------------------------------------- 版式 (模板填充) # ---------------------------------------------------------------- 版式 (模板填充)
def _copy_row_style(ws, src_row: int, dst_row: int, cols=range(2, 22)): def _copy_row_style(ws, src_row: int, dst_row: int, cols=range(2, 22)):
"""只拷单元格样式。行高不在这拷 —— openpyxl 插行不搬 row_dimensions, 拷邻行行高
等于拷一个错位的旧值, 行高在 build_workbook 里按最终行位统一重排"""
for col in cols: for col in cols:
ws.cell(row=dst_row, column=col)._style = copy( ws.cell(row=dst_row, column=col)._style = copy(
ws.cell(row=src_row, column=col)._style) ws.cell(row=src_row, column=col)._style)
ws.row_dimensions[dst_row].height = ws.row_dimensions[src_row].height
def _put(ws, row, col, value, fmt=None): def _put(ws, row, col, value, fmt=None):
@ -289,12 +293,26 @@ def build_workbook(s: dict, nav_rows: list) -> bytes:
"""把快照填进公司模板。纯函数 (不碰库), 可离线测试。""" """把快照填进公司模板。纯函数 (不碰库), 可离线测试。"""
import openpyxl import openpyxl
from openpyxl.chart import LineChart, Reference from openpyxl.chart import LineChart, Reference
from openpyxl.styles import Font from openpyxl.chart.marker import Marker
from openpyxl.formatting.formatting import ConditionalFormattingList
from openpyxl.formatting.rule import CellIsRule
from openpyxl.styles import Font, PatternFill
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
wb = openpyxl.load_workbook(TEMPLATE_PATH) wb = openpyxl.load_workbook(TEMPLATE_PATH)
ws = wb["Sheet1"] ws = wb["Sheet1"]
# 进场先做模板卫生 (2026-08-28 正式机实锅): 公司原表把老平仓行折叠隐藏 (24~97 行
# hidden), 裁模板时 openpyxl 的 delete_rows 不搬 row_dimensions, 这批隐藏标记曾
# 原样留在模板里; 表体一长到 24 行, 卖出小计/存量合计/底行/图表数据整段被隐藏。
# 模板已重裁干净, 这里再兜一层底: 模板行位之外的行属性一律不留, 隐藏标记一律清掉;
# 残留的条件格式区间 (原表手工长出来的死区间) 同样清空, 后面按最终行位重建。
for r0 in [r for r in ws.row_dimensions if r > _R_SUM2]:
del ws.row_dimensions[r0]
for r0 in list(ws.row_dimensions):
ws.row_dimensions[r0].hidden = False
ws.conditional_formatting = ConditionalFormattingList()
n_h, n_c = max(1, len(s["holdings"])), max(1, len(s["closed"])) n_h, n_c = max(1, len(s["holdings"])), max(1, len(s["closed"]))
# openpyxl 的插行不搬合并区 —— 先全拆, 插完行按最终行位重新合并 # openpyxl 的插行不搬合并区 —— 先全拆, 插完行按最终行位重新合并
@ -360,7 +378,27 @@ def build_workbook(s: dict, nav_rows: list) -> bytes:
for r0 in list(range(hold_top, hsub + 1)) + list(range(close_top, csub + 1)): for r0 in list(range(hold_top, hsub + 1)) + list(range(close_top, csub + 1)):
hts.setdefault(r0, 20.0) hts.setdefault(r0, 20.0)
for r0, h0 in hts.items(): for r0, h0 in hts.items():
ws.row_dimensions[r0].height = h0 rd = ws.row_dimensions[r0]
rd.height = h0
rd.hidden = False
# ---- 红涨绿跌条件格式 (原表样式按最终行位重建: Q/R/S 红涨绿跌字色,
# 持仓 S 列独一份用红绿底色, 0 也算红; 颜色与原表 dxf 逐字节同) ----
def _font_rule(op, color):
return CellIsRule(operator=op, formula=["0"], font=Font(color=color))
for rng in (f"Q{hold_top}:R{hsub - 1}", f"Q{close_top}:S{csub - 1}",
f"S{hsub} S{csub}"):
ws.conditional_formatting.add(rng, _font_rule("greaterThan", "FFFF0000"))
ws.conditional_formatting.add(rng, _font_rule("lessThan", "FF00B050"))
ws.conditional_formatting.add(
f"S{hold_top}:S{hsub - 1}",
CellIsRule(operator="lessThan", formula=["0"],
fill=PatternFill(fill_type="solid", bgColor="FF00B050")))
ws.conditional_formatting.add(
f"S{hold_top}:S{hsub - 1}",
CellIsRule(operator="greaterThanOrEqual", formula=["0"],
fill=PatternFill(fill_type="solid", bgColor="FFFF0000")))
# ---- 表尾注 (模板之外的诚实行: 缺价与未摊费用不许静默) ---- # ---- 表尾注 (模板之外的诚实行: 缺价与未摊费用不许静默) ----
note_row = sum2 + 1 note_row = sum2 + 1
@ -376,7 +414,8 @@ def build_workbook(s: dict, nav_rows: list) -> bytes:
c.font = Font(name="宋体", size=10) c.font = Font(name="宋体", size=10)
note_row += 1 note_row += 1
# ---- 净值序列 + 折线图 (图占位与标题同模板: 汇总行下方, 横贯 B..U) ---- # ---- 净值序列 + 折线图 (双系列同原表: 净值主轴 + 仓位占比副轴, 标题同原图;
# 每个数据点带标记, 首日只有一个点也看得见; plotVisOnly=0 隐藏单元格照画) ----
base = note_row + 1 base = note_row + 1
NC = 25 # Y 列起三列数据, 在图区右侧, 不碰表体 NC = 25 # Y 列起三列数据, 在图区右侧, 不碰表体
hd = Font(name="宋体", size=10, bold=True) hd = Font(name="宋体", size=10, bold=True)
@ -389,14 +428,30 @@ def build_workbook(s: dict, nav_rows: list) -> bytes:
pr = row.get("pos_ratio") pr = row.get("pos_ratio")
_put(ws, base + i, NC + 2, float(pr) if pr is not None else None, fmt="0.00%") _put(ws, base + i, NC + 2, float(pr) if pr is not None else None, fmt="0.00%")
if nav_rows: if nav_rows:
chart = LineChart() last = base + len(nav_rows)
chart.title = "量化净值数据及持仓仓位变化" cats = Reference(ws, min_col=NC, min_row=base + 1, max_row=last)
chart.height, chart.width = 9.0, 30.0 # 与模板图区 (B..U 宽 × 16 行) 相当 nav_c = LineChart()
data = Reference(ws, min_col=NC + 1, min_row=base, max_row=base + len(nav_rows)) nav_c.title = "量化净值数据及持仓仓位变化"
cats = Reference(ws, min_col=NC, min_row=base + 1, max_row=base + len(nav_rows)) nav_c.height, nav_c.width = 9.0, 30.0 # 与模板图区 (B..U 宽 × 16 行) 相当
chart.add_data(data, titles_from_data=True) nav_c.add_data(Reference(ws, min_col=NC + 1, min_row=base, max_row=last),
chart.set_categories(cats) titles_from_data=True)
ws.add_chart(chart, f"B{base}") nav_c.set_categories(cats)
nav_c.y_axis.number_format = "0.000"
pos_c = LineChart()
pos_c.add_data(Reference(ws, min_col=NC + 2, min_row=base, max_row=last),
titles_from_data=True)
pos_c.set_categories(cats)
pos_c.y_axis.axId = 200 # 仓位占比走副轴 (原图同)
pos_c.y_axis.number_format = "0%"
pos_c.y_axis.majorGridlines = None
pos_c.y_axis.crosses = "max" # 副轴靠右, 净值主轴留在左 (原图同)
for c0, sym in ((nav_c, "circle"), (pos_c, "diamond")):
for ser in c0.series:
ser.smooth = False
ser.marker = Marker(symbol=sym, size=7)
nav_c += pos_c
nav_c.visible_cells_only = False
ws.add_chart(nav_c, f"B{base}")
for col, w in {25: 11, 26: 10, 27: 10}.items(): for col, w in {25: 11, 26: 10, 27: 10}.items():
ws.column_dimensions[get_column_letter(col)].width = w ws.column_dimensions[get_column_letter(col)].width = w

View File

@ -2494,6 +2494,44 @@ def _():
rows = pub._nav_series(s) rows = pub._nav_series(s)
assert rows[-1]["ymd"] == ymd and abs(rows[-1]["nav"] - s["nav"]) < 1e-9, rows[-1] assert rows[-1]["ymd"] == ymd and abs(rows[-1]["nav"] - s["nav"]) < 1e-9, rows[-1]
# --- 大表回归: 表体越过模板隐藏带 (2026-08-28 正式机实锅) ---
# 公司原表把 24~97 行折叠隐藏 (老平仓行), 裁模板时 openpyxl 不搬 row_dimensions,
# 隐藏标记曾原样留在模板里: 7 持仓 + 10 平仓时, 卖出小计/存量合计/底行/图表数据
# 全落进 24 行起的隐藏带, 导出的表"最底部的合计行没了、图也画不出来"。钉死:
# 行一律不许隐藏; 三行合计都在; 红绿条件格式按最终行位重建; 图表净值+仓位双系列
# 带数据点标记 (首日单点也看得见) 且 plotVisOnly=0。
import re as _re
import zipfile as _zip
mkh = [dict(account="", code=f"{600000 + i}", name=f"{i}", amount=10000.0,
open_date=d0, upd_date=date.today(), structure="平层", cost=10.0,
qty=1000, price=10.5, days=10) for i in range(7)]
mkc = [dict(account="", code=f"{1 + i:06d}", name=f"{i}", amount=8000.0,
open_date=d0, close_date=d1, structure="平层", cost=8.0, qty=1000,
settle=8.2, days=6, pnl=200.0 - 40.0 * i) for i in range(10)]
s2 = dict(s, holdings=mkh, closed=mkc, price_missing=[], fee_resid=0.0)
blob2 = pub.build_workbook(s2, [{"ymd": ymd, "nav": 1.001, "pos_ratio": 0.35}])
wb2 = openpyxl.load_workbook(_io.BytesIO(blob2))
ws2 = wb2["Sheet1"]
# 行位: 持仓 5..11, 小计 12, 卖出头 13, 明细 14..23, 小计 24, 存量 25, 底行 26
assert ws2.cell(row=24, column=7).value == "=SUM(G14:G23)", ws2.cell(row=24, column=7).value
assert ws2.cell(row=25, column=2).value == "存量合计", ws2.cell(row=25, column=2).value
assert ws2.cell(row=25, column=19).value == "=S12+S24"
assert ws2.cell(row=26, column=2).value == "初始规模+已回收益后可开仓总金额"
assert ws2.cell(row=26, column=17).value == "=U25"
assert not [r0 for r0, dd in ws2.row_dimensions.items() if dd.hidden], "行不许隐藏"
assert max(ws2.row_dimensions) <= 26, max(ws2.row_dimensions)
assert ws2.row_dimensions[25].height == 18.0 and ws2.row_dimensions[26].height == 40.5
cfsq = sorted(str(cf.sqref) for cf in ws2.conditional_formatting)
assert cfsq == sorted(["Q5:R11", "S5:S11", "Q14:S23", "S12 S24"]), cfsq
zf2 = _zip.ZipFile(_io.BytesIO(blob2))
sx2 = zf2.read("xl/worksheets/sheet1.xml").decode("utf-8")
assert not _re.search('<row [^>]*hidden="1"', sx2), "导出的行带着隐藏标记"
cx2 = zf2.read("xl/charts/chart1.xml").decode("utf-8")
assert cx2.count("<ser>") == 2, cx2.count("<ser>") # 净值 + 仓位占比双系列
assert 'symbol val="circle"' in cx2 and 'symbol val="diamond"' in cx2
assert '<plotVisOnly val="0"' in cx2, "隐藏单元格也必须照画"
assert cx2.count("<valAx>") == 2 # 仓位占比走副轴
def main(): def main():
# 静音日志: 本套里有好几条用例**故意**触发异常与告警来验证「守成」行为 # 静音日志: 本套里有好几条用例**故意**触发异常与告警来验证「守成」行为