修复公示导出:模板残留隐藏行把合计行与图表整段藏掉;红绿条件格式按最终行位重建;净值+仓位双系列带标记图表
This commit is contained in:
parent
88592bb349
commit
3e2a243678
35
DEVLOG.md
35
DEVLOG.md
|
|
@ -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.
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
# 静音日志: 本套里有好几条用例**故意**触发异常与告警来验证「守成」行为
|
# 静音日志: 本套里有好几条用例**故意**触发异常与告警来验证「守成」行为
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue