init: 博特维采购订单系统 v1.0 源码

This commit is contained in:
star
2026-09-10 16:18:15 +08:00
commit 6327ef7641
35 changed files with 4029 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# ===== Python =====
__pycache__/
*.py[cod]
*.pyo
*.egg-info/
.eggs/
build/
venv/
.venv/
env/
# ===== PyInstaller 构建产物(可重新生成,不入库)=====
dist/
dist_backup_*/
*.manifest
*.pyz
# ===== 运行时数据:含真实业务数据,绝不入库 =====
instance/
*.db
*.sqlite
*.sqlite3
uploads/
exports/
# ===== 测试产生的样张 =====
tools/out/
# ===== 编辑器 / 系统 =====
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
*.log
# ===== 本地工具目录 =====
.workbuddy/
+89
View File
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""人民币金额转中文大写。"""
def amount_to_cn(amount):
"""1234.56 -> 壹仟贰佰叁拾肆元伍角陆分"""
digits = u"零壹贰叁肆伍陆柒捌玖"
units = [u"", u"拾", u"佰", u"仟"]
big_units = [u"", u"万", u"亿", u"兆"]
try:
amount = round(float(amount) + 0.0, 2)
except (TypeError, ValueError):
return u"零元整"
negative = amount < 0
amount = abs(amount)
integer_part = int(amount)
# 避免浮点误差:分位用字符串处理
fen = int(round((amount - integer_part) * 100))
if fen == 100:
integer_part += 1
fen = 0
def int_section(num):
"""0-9999 转中文"""
s = u""
zero_flag = False
str_num = "%04d" % num
for i, ch in enumerate(str_num):
d = int(ch)
pos = 3 - i
if d == 0:
zero_flag = True
else:
if zero_flag and s:
s += digits[0]
zero_flag = False
s += digits[d] + units[pos]
return s
if integer_part == 0:
result = u""
else:
sections = []
n = integer_part
while n > 0:
sections.append(n % 10000)
n //= 10000
parts = []
for idx, sec in enumerate(sections):
if sec == 0:
parts.append(u"")
continue
parts.append(int_section(sec) + big_units[idx])
result = u""
for idx in range(len(parts) - 1, -1, -1):
p = parts[idx]
if not p:
if result and not result.endswith(digits[0]):
result += digits[0]
continue
if result and idx < len(parts) - 1 and sections[idx] < 1000:
result += digits[0]
result += p
result = result.rstrip(digits[0]) or u"零"
if fen == 0:
result += u"元整" if result else u"零元整"
else:
jiao = fen // 10
f = fen % 10
if result:
result += u"元"
if jiao:
result += digits[jiao] + u"角"
elif f and result:
result += digits[0]
if f:
result += digits[f] + u"分"
if negative:
result = u"负" + result
return result
if __name__ == "__main__":
for v in (0, 0.05, 0.5, 1, 10, 100.5, 1234.56, 10000, 1002003, 20340.07,
100000000, 10500.5):
print(v, amount_to_cn(v))
+1071
View File
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
"""数据模型:用户、供应商、物料、订单、明细、审核记录。"""
from datetime import datetime
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Integer,
String, Text, create_engine)
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
Base = declarative_base()
COMPANY_NAME = u"苏州博特维智能科技有限公司"
DEFAULT_DELIVERY_PLACE = (u"苏州市工业园区星汉街5号腾飞新苏工业坊B幢5楼05-06单元 "
u"胡乃琛 13913172516")
# 需方(我方)默认信息:新建单据时自动预置到联系人 / 电话 / 税号 / 税点
DEFAULT_OUR_CONTACT = u"胡乃琛"
DEFAULT_OUR_PHONE = u"13913172516"
DEFAULT_OUR_TAX_NO = u"91320507MA1WW8D15D"
DEFAULT_TAX_RATE = u"13%"
DOC_TYPES = {
"purchase": u"采购单",
"outsourcing": u"委外生产订单",
}
STATUS_TEXT = {
"draft": u"草稿",
"pending_review": u"待主管审核",
"pending_approval": u"待经理批准",
"approved": u"已批准",
"rejected": u"已驳回",
}
ROLE_TEXT = {
"admin": u"系统管理员",
"maker": u"制单员",
"reviewer": u"主管审核",
"approver": u"经理批准",
}
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True, nullable=False)
password_hash = Column(String(200), nullable=False)
name = Column(String(50), nullable=False)
role = Column(String(20), nullable=False, default="maker")
active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
class Supplier(Base):
__tablename__ = "suppliers"
id = Column(Integer, primary_key=True)
code = Column(String(50))
name = Column(String(200), nullable=False)
contact = Column(String(50))
phone = Column(String(50))
tax_no = Column(String(80))
bank_name = Column(String(120))
bank_account = Column(String(80))
payment_terms = Column(String(120))
active = Column(Boolean, default=True)
class Material(Base):
__tablename__ = "materials"
id = Column(Integer, primary_key=True)
code = Column(String(80), nullable=False)
spec = Column(String(300))
unit = Column(String(20))
price = Column(Float, default=0)
active = Column(Boolean, default=True)
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True)
order_no = Column(String(50), nullable=False)
doc_type = Column(String(20), nullable=False, default="purchase")
supplier_name = Column(String(200))
supplier_contact = Column(String(50))
supplier_phone = Column(String(50))
supplier_tax_no = Column(String(80))
supplier_bank_name = Column(String(120))
supplier_bank_account = Column(String(80))
payment_terms = Column(String(120))
our_company = Column(String(200), default=COMPANY_NAME)
our_contact = Column(String(50), default=DEFAULT_OUR_CONTACT)
our_phone = Column(String(50), default=DEFAULT_OUR_PHONE)
our_tax_no = Column(String(80), default=DEFAULT_OUR_TAX_NO)
tax_rate = Column(String(30), default=DEFAULT_TAX_RATE)
order_date = Column(String(20))
delivery_place = Column(String(300), default=DEFAULT_DELIVERY_PLACE)
note = Column(Text)
total_amount = Column(Float, default=0)
status = Column(String(20), default="draft")
created_by = Column(Integer, ForeignKey("users.id"))
created_by_name = Column(String(50))
created_at = Column(DateTime, default=datetime.now)
submitted_at = Column(DateTime)
reviewed_by = Column(String(50))
reviewed_at = Column(DateTime)
approved_by = Column(String(50))
approved_at = Column(DateTime)
reject_reason = Column(Text)
items = relationship("OrderItem", backref="order", cascade="all, delete-orphan",
order_by="OrderItem.seq")
logs = relationship("ApprovalLog", backref="order", cascade="all, delete-orphan",
order_by="ApprovalLog.id")
@property
def status_text(self):
return STATUS_TEXT.get(self.status, self.status)
@property
def doc_type_text(self):
return DOC_TYPES.get(self.doc_type, self.doc_type)
class OrderItem(Base):
__tablename__ = "order_items"
id = Column(Integer, primary_key=True)
order_id = Column(Integer, ForeignKey("orders.id"))
seq = Column(Integer, default=1)
material_code = Column(String(80))
material_spec = Column(String(300))
unit = Column(String(20))
qty = Column(Float, default=0)
price = Column(Float, default=0)
amount = Column(Float, default=0)
delivery_date = Column(String(30))
remark = Column(String(200))
class ApprovalLog(Base):
__tablename__ = "approval_logs"
id = Column(Integer, primary_key=True)
order_id = Column(Integer, ForeignKey("orders.id"))
action = Column(String(50))
user_name = Column(String(50))
comment = Column(Text)
created_at = Column(DateTime, default=datetime.now)
def init_db(db_path):
engine = create_engine("sqlite:///" + db_path, echo=False)
Base.metadata.create_all(engine)
return engine, sessionmaker(bind=engine)
+397
View File
@@ -0,0 +1,397 @@
# -*- coding: utf-8 -*-
"""按原 Excel 模板版式生成采购单 / 委外生产订单 PDF。"""
import math
import os
from datetime import datetime
from xml.sax.saxutils import escape
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import (BaseDocTemplate, Flowable, Frame, KeepTogether,
LongTable, PageTemplate, Paragraph, Spacer,
Table, TableStyle)
from amount_cn import amount_to_cn
from models import COMPANY_NAME, DOC_TYPES
FONT_DIRS = [r"C:\Windows\Fonts", "/usr/share/fonts"]
BODY_CANDIDATES = ["simsun.ttc", "STSONG.TTF", "simfang.ttf", "simkai.ttf", "Deng.ttf"]
BOLD_CANDIDATES = ["simhei.ttf", "msyh.ttc", "msyhbd.ttc", "Dengb.ttf", "STXIHEI.TTF"]
FONT_BODY = "CN-Body"
FONT_BOLD = "CN-Bold"
# 明细列宽比例,取自两个 xls 模板的实际列宽
COL_RATIO = {
"purchase": [1184, 3104, 6528, 3232, 2400, 2976, 2400, 3712, 1728],
"outsourcing": [1184, 5440, 3392, 5408, 2400, 2976, 3840, 3712, 4736],
}
TERMS = [
u"1、因供方交期延误,品质不良,规格不符或数量不准确等情况造成我公司损失,概由供应方承担。",
u"2、供方所交材料均要求符合欧盟环境有害物质管理标准。",
u"3、随货请附出货检验报告。",
u"4、运输与保险:供方负责。",
u"5、违约责任:按经济合同法等相关法律法规处置,供方延期交货,需方有权延期付款,延期付款时间为延期交货时间的2倍。",
u"6、保密协定:供需双方保证对方的商业秘密。",
u"7、合同生效:本合同一式两份,自双方签字盖章起生效,传真件签字盖章亦有效,订单手写无效。",
u"8、售后服务与保修:如产品使用过程中发现质量问题,供方负责调换或退货,并赔偿相应损失,包含但不限于物料和返工费用。",
]
STATUS_WATERMARK = {
"draft": u"草稿 · 未提交",
"pending_review": u"待主管审核",
"pending_approval": u"待经理批准",
"rejected": u"已驳回 · 未生效",
}
SEAL_COLOR = colors.HexColor("#C8102E")
def _arc_text(c, text, cx, cy, radius, start_deg, spread_deg, font, size):
"""沿圆弧排字,用于印章上下缘。角度以度为单位,逆时针为正。"""
if not text:
return
c.setFont(font, size)
n = len(text)
step = spread_deg / float(n - 1) if n > 1 else 0.0
for i, ch in enumerate(text):
ang = start_deg - i * step
rad = math.radians(ang)
c.saveState()
c.translate(cx + radius * math.cos(rad), cy + radius * math.sin(rad))
c.rotate(ang - 90)
c.drawCentredString(0, 0, ch)
c.restoreState()
def _star(c, cx, cy, r_out, points=5, inner_ratio=0.382):
"""画五角星(填充)。"""
path = c.beginPath()
for i in range(points * 2):
r = r_out if i % 2 == 0 else r_out * inner_ratio
ang = math.radians(90 + i * (180.0 / points))
x = cx + r * math.cos(ang)
y = cy + r * math.sin(ang)
if i == 0:
path.moveTo(x, y)
else:
path.lineTo(x, y)
path.close()
c.drawPath(path, stroke=0, fill=1)
class SealFlowable(Flowable):
"""红色圆形电子章:外圈 + 上缘公司名 + 五角星 + 中部用途 + 下缘审核人/日期。"""
def __init__(self, company, title, who, when, size=28 * mm):
Flowable.__init__(self)
self.company = company or u""
self.title = title or u"审核专用章"
self.who = who or u""
self.when = when or u""
self.width = size
self.height = size
def draw(self):
c = self.canv
w, h = self.width, self.height
cx, cy = w / 2.0, h / 2.0
r = min(w, h) / 2.0 - 1.2 * mm
c.saveState()
c.setFillColor(SEAL_COLOR)
c.setStrokeColor(SEAL_COLOR)
c.setLineWidth(1.5)
c.circle(cx, cy, r, stroke=1, fill=0)
c.setLineWidth(0.5)
c.circle(cx, cy, r - 1.1 * mm, stroke=1, fill=0)
# 上缘公司名
_arc_text(c, self.company, cx, cy, r - 3.2 * mm, 152, -124, FONT_BODY, 5.8)
# 中心五角星
_star(c, cx, cy + 2.4 * mm, 2.8 * mm)
# 中部用途
c.setFont(FONT_BOLD, 8.0)
c.drawCentredString(cx, cy - 4.2 * mm, self.title)
# 下缘审核人 + 日期
line = u" ".join(x for x in (self.who, self.when) if x)
if line:
c.setFont(FONT_BODY, 5.4)
c.drawCentredString(cx, cy - 9.0 * mm, line)
c.restoreState()
def _find_font(candidates):
for d in FONT_DIRS:
for name in candidates:
p = os.path.join(d, name)
if os.path.exists(p):
return p
return None
def register_fonts():
if FONT_BODY in pdfmetrics.getRegisteredFontNames():
return
body = _find_font(BODY_CANDIDATES)
bold = _find_font(BOLD_CANDIDATES)
if not body:
raise RuntimeError(u"未找到中文字体,请确认系统已安装宋体或黑体")
pdfmetrics.registerFont(TTFont(FONT_BODY, body, subfontIndex=0))
pdfmetrics.registerFont(TTFont(FONT_BOLD, bold or body, subfontIndex=0))
def _fmt_money(v):
try:
return "{:,.2f}".format(float(v or 0))
except (TypeError, ValueError):
return "0.00"
def _fmt_num(v):
try:
return "{:g}".format(round(float(v or 0), 4))
except (TypeError, ValueError):
return ""
def P(text, style):
"""转义后生成 Paragraph,避免 & < > 破坏 XML。"""
s = escape(u"" if text is None else str(text)).replace("\n", "<br/>")
return Paragraph(s or "&nbsp;", style)
def build_pdf(order, items, out_path, watermark=None):
register_fonts()
if watermark is None:
watermark = STATUS_WATERMARK.get(order.status)
page_w, page_h = A4
margin_x = 11 * mm
doc = BaseDocTemplate(out_path, pagesize=A4,
leftMargin=margin_x, rightMargin=margin_x,
topMargin=11 * mm, bottomMargin=16 * mm,
title=u"%s %s" % (order.doc_type_text, order.order_no),
author=COMPANY_NAME)
frame = Frame(margin_x, 16 * mm, page_w - 2 * margin_x,
page_h - 27 * mm, id="body", showBoundary=0)
counters = {"total": 0}
def on_page(canvas, doc_):
canvas.saveState()
canvas.setFont(FONT_BODY, 8)
canvas.setFillColor(colors.HexColor("#666666"))
footer = u"%s %s 第 %d 页 / 共 %d 页 打印时间 %s" % (
order.doc_type_text, order.order_no, doc_.page,
counters["total"] or doc_.page,
datetime.now().strftime("%Y-%m-%d %H:%M"))
canvas.drawCentredString(page_w / 2.0, 10 * mm, footer)
canvas.setStrokeColor(colors.HexColor("#BBBBBB"))
canvas.setLineWidth(0.4)
canvas.line(margin_x, 13 * mm, page_w - margin_x, 13 * mm)
if watermark:
canvas.setFont(FONT_BOLD, 34)
canvas.setFillColor(colors.Color(0.85, 0.15, 0.15, alpha=0.15))
canvas.saveState()
canvas.translate(page_w / 2.0, page_h / 2.0)
canvas.rotate(28)
canvas.drawCentredString(0, 0, watermark)
canvas.restoreState()
canvas.restoreState()
doc.addPageTemplates([PageTemplate(id="main", frames=[frame], onPage=on_page)])
styles = {
"title": ParagraphStyle("title", fontName=FONT_BOLD, fontSize=17, leading=22,
alignment=TA_CENTER),
"company": ParagraphStyle("company", fontName=FONT_BOLD, fontSize=12.5, leading=17,
alignment=TA_CENTER),
"cell": ParagraphStyle("cell", fontName=FONT_BODY, fontSize=8.6, leading=11.5,
alignment=TA_LEFT),
"cell_c": ParagraphStyle("cell_c", fontName=FONT_BODY, fontSize=8.6, leading=11.5,
alignment=TA_CENTER),
"th": ParagraphStyle("th", fontName=FONT_BOLD, fontSize=9, leading=12,
alignment=TA_CENTER),
"term": ParagraphStyle("term", fontName=FONT_BODY, fontSize=8.8, leading=13.5),
"sign": ParagraphStyle("sign", fontName=FONT_BODY, fontSize=9.5, leading=15),
}
def make_story():
story = []
story.append(P(COMPANY_NAME, styles["company"]))
story.append(Spacer(1, 3 * mm))
story.append(P(order.doc_type_text or DOC_TYPES.get(order.doc_type, u""),
styles["title"]))
story.append(Spacer(1, 4 * mm))
# ---------- 抬头区 ----------
left_rows = [
(u"供应商:", order.supplier_name),
(u"联系人:", order.supplier_contact),
(u"电 话:", order.supplier_phone),
(u"付款方式:", order.payment_terms),
(u"税 号:", order.supplier_tax_no),
(u"银行帐号:", order.supplier_bank_account),
(u"开户银行:", order.supplier_bank_name),
]
right_rows = [
(u"需 方:", order.our_company),
(u"联系人:", order.our_contact),
(u"电 话:", order.our_phone),
(u"税 点:", order.tax_rate),
(u"税 号:", order.our_tax_no),
(u"采购单号:", order.order_no),
(u"采购日期:", order.order_date),
]
head_data = [[P(l1 + (v1 or ""), styles["cell"]), P(l2 + (v2 or ""), styles["cell"])]
for (l1, v1), (l2, v2) in zip(left_rows, right_rows)]
head = Table(head_data, colWidths=[93 * mm, 93 * mm], rowHeights=[6.6 * mm] * 7)
head.setStyle(TableStyle([
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#333333")),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 1.5),
("BOTTOMPADDING", (0, 0), (-1, -1), 1.5),
]))
story.append(head)
story.append(Spacer(1, 4 * mm))
# ---------- 明细表 ----------
ratio = COL_RATIO.get(order.doc_type, COL_RATIO["purchase"])
total_w = 186 * mm
s = float(sum(ratio))
col_w = [total_w * r / s for r in ratio]
# 序号列保底宽度,避免表头换行;差值从最宽的规格列扣除
min_first = 9 * mm
if col_w[0] < min_first:
col_w[2] -= (min_first - col_w[0])
col_w[0] = min_first
headers = [u"序号", u"物料代码", u"物料规格", u"单位", u"数量", u"含税单价",
u"金额", u"交期", u"备注"]
data = [[P(h, styles["th"]) for h in headers]]
total = 0.0
for idx, it in enumerate(items, 1):
total += float(it.amount or 0)
data.append([
P(str(idx), styles["cell_c"]),
P(it.material_code, styles["cell"]),
P(it.material_spec, styles["cell"]),
P(it.unit, styles["cell_c"]),
P(_fmt_num(it.qty), styles["cell_c"]),
P(_fmt_money(it.price), styles["cell_c"]),
P(_fmt_money(it.amount), styles["cell_c"]),
P(it.delivery_date, styles["cell_c"]),
P(it.remark, styles["cell"]),
])
while len(data) - 1 < 5:
data.append([P("", styles["cell"]) for _ in range(9)])
detail = LongTable(data, colWidths=col_w, repeatRows=1)
detail.setStyle(TableStyle([
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#333333")),
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#EFEFEF")),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]))
story.append(detail)
# ---------- 合计区 ----------
cn_total = amount_to_cn(total)
sum_data = [
[P(u"合计(小写)", styles["cell"]), P("", styles["cell"]), P("", styles["cell"]),
P(_fmt_money(total), styles["cell_c"])] + [P("", styles["cell"]) for _ in range(5)],
[P(u"合计(人民币)", styles["cell"]), P("", styles["cell"]),
P(cn_total, styles["cell_c"])] + [P("", styles["cell"]) for _ in range(4)] +
[P(u"元整", styles["cell"]), P("", styles["cell"])],
]
sum_t = Table(sum_data, colWidths=col_w, rowHeights=[7.5 * mm, 7.5 * mm])
sum_t.setStyle(TableStyle([
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#333333")),
("SPAN", (0, 0), (2, 0)),
("SPAN", (3, 0), (8, 0)),
("SPAN", (0, 1), (1, 1)),
("SPAN", (2, 1), (6, 1)),
("SPAN", (7, 1), (8, 1)),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
]))
story.append(sum_t)
story.append(Spacer(1, 3 * mm))
# ---------- 备注条款 ----------
terms_block = [P(u"备注:", styles["term"])]
for t in TERMS:
terms_block.append(P(t, styles["term"]))
if order.delivery_place:
terms_block.append(P(u"9、交货地点:%s" % order.delivery_place, styles["term"]))
if order.note:
terms_block.append(P(u"补充说明:%s" % order.note, styles["term"]))
story.append(KeepTogether(terms_block))
story.append(Spacer(1, 8 * mm))
# ---------- 签署区 ----------
def stamp(name, dt):
return (u"%s %s" % (name, dt.strftime("%Y-%m-%d %H:%M"))) if dt else (name or u"待处理")
sign_line = u"制单:%s 主管审核:%s 经理批准:%s" % (
stamp(order.created_by_name, order.created_at),
stamp(order.reviewed_by, order.reviewed_at),
stamp(order.approved_by, order.approved_at))
confirm_line = (u"供方确认(盖章):__________________ "
u"需方确认(盖章):__________________")
# 已审核 / 已批准 → 在签署区右侧盖章(两枚时并排)
seals = []
if order.reviewed_by:
seals.append(SealFlowable(
order.our_company or COMPANY_NAME, u"审核专用章", order.reviewed_by,
order.reviewed_at.strftime("%Y-%m-%d") if order.reviewed_at else u""))
if order.approved_by:
seals.append(SealFlowable(
order.our_company or COMPANY_NAME, u"批准专用章", order.approved_by,
order.approved_at.strftime("%Y-%m-%d") if order.approved_at else u""))
if seals:
if len(seals) == 1:
seal_cell = seals[0]
else:
inner = Table([seals], colWidths=[29 * mm, 29 * mm])
inner.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "CENTER"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
]))
seal_cell = inner
sign_tbl = Table(
[[P(sign_line, styles["sign"]), seal_cell],
[P(confirm_line, styles["sign"]), P("", styles["sign"])]],
colWidths=[128 * mm, 58 * mm])
sign_tbl.setStyle(TableStyle([
("VALIGN", (0, 0), (0, -1), "TOP"),
("VALIGN", (1, 0), (1, -1), "CENTER"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
]))
story.append(KeepTogether(sign_tbl))
else:
story.append(P(sign_line, styles["sign"]))
story.append(Spacer(1, 3 * mm))
story.append(P(confirm_line, styles["sign"]))
return story
doc.build(make_story())
counters["total"] = doc.page
doc.build(make_story())
return out_path
+5
View File
@@ -0,0 +1,5 @@
Flask>=3.0
SQLAlchemy>=2.0
reportlab>=4.0
openpyxl>=3.1
xlrd>=2.0
+121
View File
@@ -0,0 +1,121 @@
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Microsoft YaHei", "SimHei", "PingFang SC", sans-serif;
background: #f4f6f8;
color: #222;
font-size: 14px;
}
a { color: #1668dc; text-decoration: none; }
a:hover { text-decoration: underline; }
.topbar {
background: #123a6b; color: #fff; padding: 0 20px; height: 52px;
display: flex; align-items: center; gap: 22px;
}
.topbar .brand { font-size: 16px; font-weight: 600; letter-spacing: .5px; }
.topbar nav a { color: #d8e6f7; font-size: 14px; }
.topbar nav a:hover, .topbar nav a.active { color: #fff; text-decoration: none; }
.topbar .spacer { flex: 1; }
.topbar .me { color: #cfe0f5; font-size: 13px; }
.wrap { max-width: 1180px; margin: 18px auto 40px; padding: 0 16px; }
.card {
background: #fff; border: 1px solid #e2e6ea; border-radius: 6px;
padding: 16px 18px; margin-bottom: 16px;
}
.card h2 { margin: 0 0 12px; font-size: 15px; font-weight: 600; }
.card h3 { margin: 18px 0 8px; font-size: 14px; font-weight: 600; color: #444; }
h1.page { font-size: 18px; margin: 4px 0 14px; }
table.grid { width: 100%; border-collapse: collapse; font-size: 13px; }
table.grid th, table.grid td {
border: 1px solid #dfe3e8; padding: 6px 8px; text-align: left; vertical-align: middle;
}
table.grid th { background: #f0f3f7; font-weight: 600; white-space: nowrap; }
table.grid tr:hover td { background: #fafbfc; }
table.grid td.num, table.grid th.num { text-align: right; }
.form-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 16px; }
.form-grid.two { grid-template-columns: repeat(2, 1fr); }
label { display: block; font-size: 12px; color: #666; margin-bottom: 3px; }
input[type=text], input[type=password], input[type=number], input[type=date], select, textarea {
width: 100%; padding: 6px 8px; border: 1px solid #ccd3da; border-radius: 4px;
font-size: 13px; font-family: inherit; background: #fff;
}
textarea { min-height: 60px; resize: vertical; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #1668dc; }
.btn {
display: inline-block; padding: 7px 14px; border: 1px solid #c4ccd4; border-radius: 4px;
background: #fff; color: #333; font-size: 13px; cursor: pointer; font-family: inherit;
}
.btn:hover { background: #f2f5f8; text-decoration: none; }
.btn.primary { background: #1668dc; border-color: #1668dc; color: #fff; }
.btn.primary:hover { background: #0f57bd; }
.btn.ok { background: #12a05c; border-color: #12a05c; color: #fff; }
.btn.ok:hover { background: #0e8a4f; }
.btn.danger { background: #d9363e; border-color: #d9363e; color: #fff; }
.btn.danger:hover { background: #bc2b32; }
.btn.sm { padding: 4px 9px; font-size: 12px; }
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 12px; }
.toolbar .grow { flex: 1; }
.flash { padding: 9px 12px; border-radius: 4px; margin-bottom: 12px; font-size: 13px; }
.flash.ok { background: #e8f6ee; border: 1px solid #b7e2cb; color: #12703f; }
.flash.error { background: #fdecec; border: 1px solid #f4bcbc; color: #a32026; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; }
.badge.draft { background: #eef0f2; color: #666; }
.badge.pending_review { background: #fff4e0; color: #a35c00; }
.badge.pending_approval { background: #e6f0ff; color: #14509b; }
.badge.approved { background: #e8f6ee; color: #12703f; }
.badge.rejected { background: #fdecec; color: #a32026; }
.stat-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 16px; }
.stat { background: #fff; border: 1px solid #e2e6ea; border-radius: 6px; padding: 12px 14px; }
.stat .v { font-size: 22px; font-weight: 600; }
.stat .k { font-size: 12px; color: #777; margin-top: 2px; }
.muted { color: #888; font-size: 12px; }
.right { text-align: right; }
.center { text-align: center; }
.nowrap { white-space: nowrap; }
.login-box { width: 340px; margin: 90px auto; background: #fff; padding: 24px;
border: 1px solid #e2e6ea; border-radius: 8px; }
.login-box h2 { margin: 0 0 16px; font-size: 17px; text-align: center; }
.login-box .field { margin-bottom: 12px; }
.timeline { list-style: none; margin: 0; padding: 0; font-size: 13px; }
.timeline li { padding: 6px 0; border-bottom: 1px dashed #e5e8eb; }
.timeline li:last-child { border-bottom: none; }
.kv-table td { padding: 5px 8px; border: 1px solid #dfe3e8; font-size: 13px; }
.kv-table td.k { background: #f5f7f9; color: #555; width: 110px; white-space: nowrap; }
.total-bar { display: flex; gap: 18px; align-items: center; margin: 10px 0; font-size: 14px; }
.total-bar .amt { font-size: 18px; font-weight: 600; color: #c0392b; }
.hint { background: #f7f9fb; border-left: 3px solid #1668dc; padding: 8px 12px;
font-size: 12.5px; color: #555; margin-bottom: 12px; }
footer.foot { text-align: center; color: #999; font-size: 12px; margin: 24px 0; }
/* 卡片标题栏:标题 + 右侧操作按钮 */
.card-head { display: flex; align-items: center; justify-content: space-between;
gap: 12px; margin-bottom: 12px; }
.card-head h2 { margin: 0; }
.field { margin-bottom: 12px; }
/* 模态弹窗 */
.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,.45); z-index: 1000;
display: flex; align-items: center; justify-content: center; }
.modal-box { background: #fff; border-radius: 8px; width: 520px; max-width: 92vw;
max-height: 88vh; overflow: auto; box-shadow: 0 8px 32px rgba(0,0,0,.25); }
.modal-head { padding: 12px 16px; border-bottom: 1px solid #e5e8eb; font-size: 15px;
font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.modal-close { cursor: pointer; color: #888; font-size: 22px; line-height: 1; padding: 0 4px; }
.modal-close:hover { color: #333; }
.modal-body { padding: 16px; }
.modal-foot { padding: 12px 16px; border-top: 1px solid #e5e8eb;
display: flex; justify-content: flex-end; gap: 8px; }
/* 停用记录:整行置灰 */
table.grid tr.row-off td,
table.grid tr.row-off:hover td { color: #a3a8ad; background: #f5f6f7; }
table.grid tr.row-off td a, table.grid tr.row-off td b { color: #a3a8ad; }
table.grid tr.row-off .btn { opacity: .8; }
/* 电子章预览(页面上的红色圆形章) */
.seal-wrap { display: inline-block; padding: 6px 10px; border: 1px dashed #d9b3b3;
border-radius: 6px; background: #fffafa; }
.seal-tip { color: #12703f; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
.seal-circle { width: 126px; height: 126px; border: 3px solid #C8102E; border-radius: 50%;
color: #C8102E; display: flex; flex-direction: column; align-items: center;
justify-content: center; text-align: center; }
.seal-circle .co { font-size: 9.5px; letter-spacing: .5px; line-height: 13px; padding: 0 8px; }
.seal-circle .txt { font-size: 16px; font-weight: 700; letter-spacing: 3px; margin: 5px 0; }
.seal-circle .who { font-size: 8.5px; line-height: 12px; }
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{% block title %}采购订单管理系统{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
{% if session.get('user_id') %}
<div class="topbar">
<span class="brand">博特维 · 采购订单管理</span>
<nav style="display:flex;gap:18px;">
<a href="{{ url_for('index') }}">工作台</a>
<a href="{{ url_for('orders') }}">订单列表</a>
<a href="{{ url_for('order_new') }}">新建订单</a>
<a href="{{ url_for('suppliers') }}">供应商</a>
<a href="{{ url_for('materials') }}">物料库</a>
<a href="{{ url_for('stats') }}">统计</a>
{% if session.get('role') == 'admin' %}<a href="{{ url_for('users') }}">用户</a>{% endif %}
</nav>
<span class="spacer"></span>
<span class="me">{{ session.get('user_name') }}({{ ROLE_TEXT.get(session.get('role'), '') }})</span>
<a href="{{ url_for('logout') }}" style="color:#d8e6f7;">退出</a>
</div>
{% endif %}
<div class="wrap">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, msg in msgs %}
<div class="flash {{ cat }}">{{ msg }}</div>
{% endfor %}
{% endwith %}
{% block body %}{% endblock %}
</div>
<footer class="foot">{{ COMPANY_NAME }} · 采购订单管理系统 v1.0</footer>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block body %}
<h1 class="page">工作台</h1>
<div class="stat-row">
<div class="stat"><div class="v">{{ counts.get('pending_review', 0) }}</div><div class="k">待主管审核</div></div>
<div class="stat"><div class="v">{{ counts.get('pending_approval', 0) }}</div><div class="k">待经理批准</div></div>
<div class="stat"><div class="v">{{ counts.get('approved', 0) }}</div><div class="k">已批准</div></div>
<div class="stat"><div class="v">{{ counts.get('draft', 0) }}</div><div class="k">草稿</div></div>
<div class="stat"><div class="v">{{ '%.2f'|format(month_total) }}</div><div class="k">已批准订单总额(元)</div></div>
</div>
{% if todo %}
<div class="card">
<h2>待我处理({{ todo|length }})</h2>
<table class="grid">
<tr><th>单号</th><th>类型</th><th>供应商</th><th>金额</th><th>状态</th><th>提交时间</th><th>操作</th></tr>
{% for o in todo %}
<tr>
<td><a href="{{ url_for('order_view', oid=o.id) }}">{{ o.order_no }}</a></td>
<td>{{ o.doc_type_text }}</td>
<td>{{ o.supplier_name }}</td>
<td class="num">{{ '%.2f'|format(o.total_amount) }}</td>
<td><span class="badge {{ o.status }}">{{ o.status_text }}</span></td>
<td>{{ o.submitted_at.strftime('%m-%d %H:%M') if o.submitted_at else '-' }}</td>
<td><a class="btn sm primary" href="{{ url_for('order_view', oid=o.id) }}">去处理</a></td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
<div class="card">
<h2>我的订单</h2>
{% if my_orders %}
<table class="grid">
<tr><th>单号</th><th>类型</th><th>供应商</th><th>金额</th><th>状态</th><th>创建时间</th></tr>
{% for o in my_orders %}
<tr>
<td><a href="{{ url_for('order_view', oid=o.id) }}">{{ o.order_no }}</a></td>
<td>{{ o.doc_type_text }}</td>
<td>{{ o.supplier_name }}</td>
<td class="num">{{ '%.2f'|format(o.total_amount) }}</td>
<td><span class="badge {{ o.status }}">{{ o.status_text }}</span></td>
<td>{{ o.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="muted">暂无订单,点右上角「新建订单」开始。</p>{% endif %}
</div>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>登录 · 采购订单管理系统</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="login-box">
<h2>博特维采购订单管理系统</h2>
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, msg in msgs %}<div class="flash {{ cat }}">{{ msg }}</div>{% endfor %}
{% endwith %}
<form method="post">
<div class="field">
<label>用户名</label>
<input type="text" name="username" autofocus>
</div>
<div class="field">
<label>密码</label>
<input type="password" name="password">
</div>
<button class="btn primary" style="width:100%;">登 录</button>
</form>
<div class="hint" style="margin-top:14px;">
演示账号:admin / admin123(管理员),maker / 123456(制单员),
reviewer / 123456(主管审核),approver / 123456(经理批准)
</div>
</div>
</body>
</html>
+125
View File
@@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block body %}
<h1 class="page">物料库</h1>
<div class="card">
<h2>新增物料</h2>
<form method="post">
<div class="form-grid">
<div><label>物料代码 *</label><input type="text" name="code" required placeholder="M-1004"></div>
<div><label>物料规格 / 名称</label><input type="text" name="spec"></div>
<div><label>单位</label><input type="text" name="unit" placeholder="件"></div>
<div><label>默认含税单价</label><input type="number" step="0.01" name="price" value="0"></div>
</div>
<div style="margin-top:12px;"><button class="btn primary">新增</button></div>
</form>
</div>
<div class="card">
<div class="card-head">
<h2>物料列表</h2>
<button type="button" class="btn primary sm" onclick="document.getElementById('matImpModal').style.display='flex'">批量导入 Excel</button>
</div>
<table class="grid">
<tr><th>物料代码</th><th>物料规格</th><th>单位</th><th class="num">默认含税单价</th><th>操作</th></tr>
{% for m in rows %}
<tr class="{% if not m.active %}row-off{% endif %}">
<td>{{ m.code }}</td><td>{{ m.spec }}</td><td class="center">{{ m.unit }}</td>
<td class="num">{{ '%.2f'|format(m.price) }}</td>
<td class="nowrap">
<button type="button" class="btn sm" onclick="openEdit(this)"
data-id="{{ m.id }}" data-code="{{ m.code }}"
data-spec="{{ m.spec or '' }}" data-unit="{{ m.unit or '' }}"
data-price="{{ m.price or 0 }}">编辑</button>
<form method="post" action="{{ url_for('material_toggle', mid=m.id) }}" style="display:inline;">
<button class="btn sm {% if m.active %}danger{% endif %}"
onclick="return confirm('确定{{ '停用' if m.active else '启用' }}该物料?')">
{{ '停用' if m.active else '启用' }}
</button>
</form>
{% if user.role == 'admin' %}
<form method="post" action="{{ url_for('material_delete', mid=m.id) }}" style="display:inline;">
<button class="btn sm danger"
onclick="return confirm('确定彻底删除该物料?删除后不可恢复!')">删除</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
<div id="matImpModal" class="modal-mask" style="display:none;" onclick="if(event.target===this) this.style.display='none'">
<div class="modal-box">
<div class="modal-head">
<span>批量导入物料</span>
<span class="modal-close" onclick="document.getElementById('matImpModal').style.display='none'">&times;</span>
</div>
<form method="post" action="{{ url_for('material_import_excel') }}" enctype="multipart/form-data">
<div class="modal-body">
<div class="hint">先下载模板按格式填好再导入。<b>物料代码</b>必填;已存在的代码按下方方式处理。</div>
<div class="field">
<label>1. 下载模板</label>
<a href="{{ url_for('material_import_template') }}">物料导入模板.xlsx</a>
</div>
<div class="field">
<label>2. 选择 Excel 文件(.xls / .xlsx)</label>
<input type="file" name="file" accept=".xls,.xlsx,.xlsm" required>
</div>
<div class="field">
<label>3. 遇到已存在的物料代码</label>
<select name="mode">
<option value="update">更新该物料的规格 / 单位 / 单价</option>
<option value="skip">跳过,不改动已有数据</option>
</select>
</div>
</div>
<div class="modal-foot">
<button type="button" class="btn" onclick="document.getElementById('matImpModal').style.display='none'">取消</button>
<button type="submit" class="btn primary">开始导入</button>
</div>
</form>
</div>
</div>
<div id="editModal" class="modal-mask" style="display:none;" onclick="if(event.target===this) this.style.display='none'">
<div class="modal-box">
<div class="modal-head">
<span>编辑物料</span>
<span class="modal-close" onclick="document.getElementById('editModal').style.display='none'">&times;</span>
</div>
<form method="post" action="{{ url_for('materials') }}">
<input type="hidden" name="id" id="edit_id">
<div class="modal-body">
<div class="field">
<label>物料代码 *</label>
<input type="text" name="code" id="edit_code" required>
</div>
<div class="field">
<label>物料规格 / 名称</label>
<input type="text" name="spec" id="edit_spec">
</div>
<div class="form-grid two">
<div><label>单位</label><input type="text" name="unit" id="edit_unit"></div>
<div><label>默认含税单价</label><input type="number" step="0.01" name="price" id="edit_price" value="0"></div>
</div>
</div>
<div class="modal-foot">
<button type="button" class="btn" onclick="document.getElementById('editModal').style.display='none'">取消</button>
<button type="submit" class="btn primary">保存</button>
</div>
</form>
</div>
</div>
<script>
function openEdit(btn){
const d = btn.dataset;
document.getElementById('edit_id').value = d.id || '';
document.getElementById('edit_code').value = d.code || '';
document.getElementById('edit_spec').value = d.spec || '';
document.getElementById('edit_unit').value = d.unit || '';
document.getElementById('edit_price').value = d.price || 0;
document.getElementById('editModal').style.display = 'flex';
}
</script>
{% endblock %}
+201
View File
@@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block body %}
<h1 class="page">编辑订单 · {{ o.order_no }}({{ o.doc_type_text }})</h1>
<div class="card">
<h2>单据信息</h2>
<form method="post" id="orderForm">
<div class="form-grid">
<div><label>采购单号</label><input type="text" value="{{ o.order_no }}" disabled></div>
<div><label>采购日期</label><input type="date" name="order_date" value="{{ o.order_date or '' }}"></div>
<div><label>税点</label><input type="text" name="tax_rate" value="{{ o.tax_rate or '' }}" placeholder="如 13%"></div>
</div>
<h3>供方(供应商)</h3>
<div class="form-grid">
<div>
<label>供应商</label>
<input type="text" name="supplier_name" id="supplier_name" value="{{ o.supplier_name or '' }}">
<select id="supplierPick" style="margin-top:4px;">
<option value="">-- 从供应商库带入 --</option>
{% for s in suppliers %}<option value="{{ s.id }}">{{ s.name }}</option>{% endfor %}
</select>
</div>
<div><label>联系人</label><input type="text" name="supplier_contact" id="supplier_contact" value="{{ o.supplier_contact or '' }}"></div>
<div><label>电话</label><input type="text" name="supplier_phone" id="supplier_phone" value="{{ o.supplier_phone or '' }}"></div>
<div><label>付款方式</label><input type="text" name="payment_terms" id="payment_terms" value="{{ o.payment_terms or '' }}"></div>
<div><label>税号</label><input type="text" name="supplier_tax_no" id="supplier_tax_no" value="{{ o.supplier_tax_no or '' }}"></div>
<div><label>银行帐号</label><input type="text" name="supplier_bank_account" id="supplier_bank_account" value="{{ o.supplier_bank_account or '' }}"></div>
<div><label>开户银行</label><input type="text" name="supplier_bank_name" id="supplier_bank_name" value="{{ o.supplier_bank_name or '' }}"></div>
</div>
<h3>需方(本公司)</h3>
<div class="form-grid">
<div><label>需方名称</label><input type="text" name="our_company" value="{{ o.our_company or '' }}"></div>
<div><label>联系人</label><input type="text" name="our_contact" value="{{ o.our_contact or '' }}"></div>
<div><label>电话</label><input type="text" name="our_phone" value="{{ o.our_phone or '' }}"></div>
<div><label>税号</label><input type="text" name="our_tax_no" value="{{ o.our_tax_no or '' }}"></div>
<div style="grid-column: span 2;">
<label>交货地点</label>
<input type="text" name="delivery_place" value="{{ o.delivery_place or '' }}">
</div>
</div>
<h3>物料明细</h3>
<table class="grid" id="itemTable">
<tr>
<th style="width:38px;">序</th>
<th style="width:150px;">物料代码</th>
<th>物料规格</th>
<th style="width:62px;">单位</th>
<th style="width:78px;">数量</th>
<th style="width:92px;">含税单价</th>
<th style="width:96px;">金额</th>
<th style="width:118px;">交期</th>
<th style="width:130px;">备注</th>
<th style="width:44px;">删</th>
</tr>
{% for it in o.items %}
<tr>
<td class="center seq">{{ loop.index }}</td>
<td><input type="text" name="item_code" value="{{ it.material_code or '' }}">
<select class="matpick" style="margin-top:3px;">
<option value="">-- 物料库 --</option>
{% for m in materials %}<option value="{{ m.id }}" data-code="{{ m.code }}" data-spec="{{ m.spec }}" data-unit="{{ m.unit }}" data-price="{{ m.price }}">{{ m.code }} {{ m.spec }}</option>{% endfor %}
</select>
</td>
<td><input type="text" name="item_spec" value="{{ it.material_spec or '' }}"></td>
<td><input type="text" name="item_unit" value="{{ it.unit or '' }}"></td>
<td><input type="number" step="0.0001" name="item_qty" class="qty" value="{{ it.qty or 0 }}"></td>
<td><input type="number" step="0.0001" name="item_price" class="price" value="{{ it.price or 0 }}"></td>
<td><input type="text" class="amt" value="{{ '%.2f'|format(it.amount or 0) }}" disabled></td>
<td><input type="date" name="item_date" value="{{ it.delivery_date or '' }}"></td>
<td><input type="text" name="item_remark" value="{{ it.remark or '' }}"></td>
<td class="center"><button type="button" class="btn sm danger" onclick="delRow(this)">×</button></td>
</tr>
{% endfor %}
</table>
<template id="rowTpl">
<tr>
<td class="center seq">1</td>
<td><input type="text" name="item_code" value="">
<select class="matpick" style="margin-top:3px;">
<option value="">-- 物料库 --</option>
{% for m in materials %}<option value="{{ m.id }}" data-code="{{ m.code }}" data-spec="{{ m.spec }}" data-unit="{{ m.unit }}" data-price="{{ m.price }}">{{ m.code }} {{ m.spec }}</option>{% endfor %}
</select>
</td>
<td><input type="text" name="item_spec" value=""></td>
<td><input type="text" name="item_unit" value=""></td>
<td><input type="number" step="0.0001" name="item_qty" class="qty" value="0"></td>
<td><input type="number" step="0.0001" name="item_price" class="price" value="0"></td>
<td><input type="text" class="amt" value="0.00" disabled></td>
<td><input type="date" name="item_date" value=""></td>
<td><input type="text" name="item_remark" value=""></td>
<td class="center"><button type="button" class="btn sm danger" onclick="delRow(this)">×</button></td>
</tr>
</template>
<div class="toolbar" style="margin-top:10px;">
<button type="button" class="btn" onclick="addRow()">+ 添加一行</button>
<span class="grow"></span>
<span class="total-bar">合计:<span class="amt" id="totalAmt">{{ '%.2f'|format(o.total_amount) }}</span> 元
<span class="muted" id="totalCn">{{ total_cn }}</span></span>
</div>
<h3>补充说明</h3>
<textarea name="note" placeholder="选填,会打印在 PDF 备注区">{{ o.note or '' }}</textarea>
<div style="margin-top:16px;">
<button class="btn primary" type="submit">保存</button>
<a class="btn" href="{{ url_for('order_view', oid=o.id) }}">查看订单</a>
<a class="btn" href="{{ url_for('order_pdf', oid=o.id) }}" target="_blank">预览 PDF</a>
</div>
</form>
<form method="post" action="{{ url_for('order_submit', oid=o.id) }}" style="margin-top:10px;">
<div class="toolbar">
<input type="text" name="comment" placeholder="提交说明(选填)" style="width:260px;">
<button class="btn ok">提交主管审核</button>
</div>
</form>
</div>
<div class="card">
<h2>从 Excel 导入明细</h2>
<div class="hint">
支持 .xlsx / .xls。系统自动识别表头(物料代码、物料规格、单位、数量、含税单价、交期、备注),
没有表头则按前 7 列顺序读取。行数超出模板也没关系,PDF 会自动分页。
<a href="{{ url_for('import_template') }}">下载导入模板</a>
</div>
<form method="post" action="{{ url_for('import_excel', oid=o.id) }}" enctype="multipart/form-data" class="toolbar">
<input type="file" name="file" accept=".xls,.xlsx" required style="width:280px;">
<select name="mode" style="width:140px;">
<option value="append">追加到现有明细</option>
<option value="replace">替换全部明细</option>
</select>
<button class="btn primary">导入</button>
</form>
</div>
<script>
const MATERIALS = {{ materials|map(attribute='id')|list|tojson }};
function bindRow(tr){
tr.querySelectorAll('.qty, .price').forEach(i => i.addEventListener('input', calc));
const pick = tr.querySelector('.matpick');
if (pick) pick.addEventListener('change', function(){
const opt = this.selectedOptions[0];
if (!opt || !opt.value) return;
tr.querySelector('[name=item_code]').value = opt.dataset.code || '';
tr.querySelector('[name=item_spec]').value = opt.dataset.spec || '';
tr.querySelector('[name=item_unit]').value = opt.dataset.unit || '';
tr.querySelector('.price').value = opt.dataset.price || 0;
calc();
});
}
function calc(){
let total = 0;
document.querySelectorAll('#itemTable tr').forEach((tr, idx) => {
const q = parseFloat(tr.querySelector('.qty')?.value || 0);
const p = parseFloat(tr.querySelector('.price')?.value || 0);
const a = (isNaN(q) ? 0 : q) * (isNaN(p) ? 0 : p);
const amt = tr.querySelector('.amt');
if (amt) amt.value = a.toFixed(2);
total += a;
const seq = tr.querySelector('.seq');
if (seq) seq.textContent = idx;
});
document.getElementById('totalAmt').textContent = total.toFixed(2);
}
function addRow(){
// 从 <template> 克隆,避免"明细为空时没有可复制的行"导致点击无反应
const t = document.getElementById('itemTable');
const tr = document.getElementById('rowTpl').content.firstElementChild.cloneNode(true);
tr.querySelectorAll('input').forEach(i => { if (i.type !== 'date') i.value = ''; });
const q = tr.querySelector('.qty'); if (q) q.value = 0;
const p = tr.querySelector('.price'); if (p) p.value = 0;
const a = tr.querySelector('.amt'); if (a) a.value = '0.00';
const sel = tr.querySelector('.matpick'); if (sel) sel.selectedIndex = 0;
t.appendChild(tr);
bindRow(tr); calc();
}
function delRow(btn){
btn.closest('tr').remove(); calc();
}
document.querySelectorAll('#itemTable tr').forEach(bindRow);
calc();
document.getElementById('supplierPick').addEventListener('change', async function(){
if (!this.value) return;
const r = await fetch('/api/suppliers').then(r => r.json());
const s = r.find(x => String(x.id) === this.value);
if (!s) return;
document.getElementById('supplier_name').value = s.name || '';
document.getElementById('supplier_contact').value = s.contact || '';
document.getElementById('supplier_phone').value = s.phone || '';
document.getElementById('supplier_tax_no').value = s.tax_no || '';
document.getElementById('supplier_bank_name').value = s.bank_name || '';
document.getElementById('supplier_bank_account').value = s.bank_account || '';
document.getElementById('payment_terms').value = s.payment_terms || '';
});
</script>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block body %}
<h1 class="page">新建订单</h1>
<div class="card">
<div class="hint">先选单据类型与供应商,系统会带出供应商资料并生成单号,下一步填写物料明细。</div>
<form method="post">
<div class="form-grid two">
<div>
<label>单据类型</label>
<select name="doc_type">
{% for k, v in DOC_TYPES.items() %}<option value="{{ k }}">{{ v }}</option>{% endfor %}
</select>
</div>
<div>
<label>供应商(留空则手工填写)</label>
<select name="supplier_id">
<option value="">-- 手工填写 --</option>
{% for s in suppliers %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div style="margin-top:16px;">
<button class="btn primary">创建并填写明细</button>
<a class="btn" href="{{ url_for('orders') }}">取消</a>
</div>
</form>
</div>
{% endblock %}
+203
View File
@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% block body %}
<h1 class="page">{{ o.doc_type_text }} · {{ o.order_no }}
<span class="badge {{ o.status }}" style="font-size:13px;vertical-align:middle;">{{ o.status_text }}</span>
</h1>
<div class="toolbar">
{% if editable %}<a class="btn primary" href="{{ url_for('order_edit', oid=o.id) }}">编辑</a>{% endif %}
<a class="btn" href="{{ url_for('order_pdf', oid=o.id) }}" target="_blank">导出 / 预览 PDF</a>
<a class="btn" href="{{ url_for('order_pdf', oid=o.id) }}?dl=1" download>下载 PDF</a>
<form method="post" action="{{ url_for('order_copy', oid=o.id) }}" style="display:inline;">
<button class="btn">复制为新订单</button>
</form>
<span class="grow"></span>
<a class="btn" href="{{ url_for('orders') }}">返回列表</a>
</div>
<div class="card">
<h2>单据信息</h2>
<table class="kv-table" style="width:100%;">
<tr>
<td class="k">供应商</td><td>{{ o.supplier_name }}</td>
<td class="k">需方</td><td>{{ o.our_company }}</td>
</tr>
<tr>
<td class="k">联系人</td><td>{{ o.supplier_contact }}</td>
<td class="k">联系人</td><td>{{ o.our_contact }}</td>
</tr>
<tr>
<td class="k">电话</td><td>{{ o.supplier_phone }}</td>
<td class="k">电话</td><td>{{ o.our_phone }}</td>
</tr>
<tr>
<td class="k">付款方式</td><td>{{ o.payment_terms }}</td>
<td class="k">税点</td><td>{{ o.tax_rate }}</td>
</tr>
<tr>
<td class="k">税号</td><td>{{ o.supplier_tax_no }}</td>
<td class="k">税号</td><td>{{ o.our_tax_no }}</td>
</tr>
<tr>
<td class="k">银行帐号</td><td>{{ o.supplier_bank_account }}</td>
<td class="k">采购单号</td><td>{{ o.order_no }}</td>
</tr>
<tr>
<td class="k">开户银行</td><td>{{ o.supplier_bank_name }}</td>
<td class="k">采购日期</td><td>{{ o.order_date }}</td>
</tr>
<tr><td class="k">交货地点</td><td colspan="3">{{ o.delivery_place }}</td></tr>
</table>
<h3>物料明细</h3>
<table class="grid">
<tr><th>序号</th><th>物料代码</th><th>物料规格</th><th>单位</th><th class="num">数量</th>
<th class="num">含税单价</th><th class="num">金额</th><th>交期</th><th>备注</th></tr>
{% for it in o.items %}
<tr>
<td class="center">{{ loop.index }}</td>
<td>{{ it.material_code }}</td>
<td>{{ it.material_spec }}</td>
<td class="center">{{ it.unit }}</td>
<td class="num">{{ it.qty }}</td>
<td class="num">{{ '%.2f'|format(it.price) }}</td>
<td class="num">{{ '%.2f'|format(it.amount) }}</td>
<td>{{ it.delivery_date }}</td>
<td>{{ it.remark }}</td>
</tr>
{% else %}
<tr><td colspan="9" class="center muted">暂无明细</td></tr>
{% endfor %}
</table>
<div class="total-bar">
<span>合计(小写):<span class="amt">{{ '%.2f'|format(o.total_amount) }}</span> 元</span>
<span>大写:<b>{{ total_cn }}</b></span>
</div>
{% if o.note %}<p class="muted">补充说明:{{ o.note }}</p>{% endif %}
</div>
<div class="card">
<h2>审核流程</h2>
<table class="grid">
<tr><th>环节</th><th>操作人</th><th>时间</th></tr>
<tr><td>制单</td><td>{{ o.created_by_name }}</td><td>{{ o.created_at.strftime('%Y-%m-%d %H:%M') if o.created_at else '-' }}</td></tr>
<tr><td>主管审核</td><td>{{ o.reviewed_by or '—' }}</td><td>{{ o.reviewed_at.strftime('%Y-%m-%d %H:%M') if o.reviewed_at else '—' }}</td></tr>
<tr><td>经理批准</td><td>{{ o.approved_by or '—' }}</td><td>{{ o.approved_at.strftime('%Y-%m-%d %H:%M') if o.approved_at else '—' }}</td></tr>
</table>
{% if user.role in ['reviewer','admin'] and o.status == 'pending_review' %}
<div style="margin-top:12px;">
<div id="stampBox" style="display:none; margin-bottom:10px;">
<div class="seal-wrap">
<div class="seal-tip">已加盖主管审核章</div>
<div class="seal-circle">
<span class="co">{{ o.our_company or COMPANY_NAME }}</span>
<span class="txt">审核专用</span>
<span class="who">{{ user.name }}<br><span id="stampTime"></span></span>
</div>
</div>
</div>
<form method="post" action="{{ url_for('order_review', oid=o.id) }}" class="toolbar"
onsubmit="return checkStamp()">
<input type="hidden" name="stamped" id="stampedInput" value="0">
<input type="text" name="comment" placeholder="审核意见(选填)" style="width:280px;">
<button type="button" class="btn" id="stampBtn" onclick="applyStamp()">加盖审核章</button>
<button type="submit" class="btn ok" id="confirmBtn" disabled
title="请先加盖审核章">确认审核通过 → 提交经理批准</button>
</form>
<div class="muted" style="margin-top:6px;">流程:先点「加盖审核章」,确认无误后再点「确认审核通过」。</div>
</div>
<script>
function applyStamp(){
document.getElementById('stampedInput').value = '1';
document.getElementById('stampBox').style.display = 'block';
const d = new Date(), p = n => String(n).padStart(2, '0');
document.getElementById('stampTime').textContent =
d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
' ' + p(d.getHours()) + ':' + p(d.getMinutes());
const c = document.getElementById('confirmBtn');
c.disabled = false;
const s = document.getElementById('stampBtn');
s.disabled = true;
s.textContent = '已加章';
}
function checkStamp(){
if (document.getElementById('stampedInput').value !== '1'){
alert('请先点击「加盖审核章」,再确认审核。');
return false;
}
return confirm('确认审核通过并提交经理批准?');
}
</script>
{% endif %}
{% if user.role in ['approver','admin'] and o.status == 'pending_approval' %}
<div style="margin-top:12px;">
<div id="apprStampBox" style="display:none; margin-bottom:10px;">
<div class="seal-wrap">
<div class="seal-tip">已加盖经理批准章</div>
<div class="seal-circle">
<span class="co">{{ o.our_company or COMPANY_NAME }}</span>
<span class="txt">批准专用</span>
<span class="who">{{ user.name }}<br><span id="apprStampTime"></span></span>
</div>
</div>
</div>
<form method="post" action="{{ url_for('order_approve', oid=o.id) }}" class="toolbar"
onsubmit="return checkStampA()">
<input type="hidden" name="stamped" id="apprStampedInput" value="0">
<input type="text" name="comment" placeholder="批准意见(选填)" style="width:280px;">
<button type="button" class="btn" id="apprStampBtn" onclick="applyStampA()">加盖批准章</button>
<button type="submit" class="btn ok" id="apprConfirmBtn" disabled
title="请先加盖批准章">确认批准通过</button>
</form>
<div class="muted" style="margin-top:6px;">流程:先点「加盖批准章」,确认无误后再点「确认批准通过」。</div>
</div>
<script>
function applyStampA(){
document.getElementById('apprStampedInput').value = '1';
document.getElementById('apprStampBox').style.display = 'block';
const d = new Date(), p = n => String(n).padStart(2, '0');
document.getElementById('apprStampTime').textContent =
d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
' ' + p(d.getHours()) + ':' + p(d.getMinutes());
document.getElementById('apprConfirmBtn').disabled = false;
const s = document.getElementById('apprStampBtn');
s.disabled = true;
s.textContent = '已加章';
}
function checkStampA(){
if (document.getElementById('apprStampedInput').value !== '1'){
alert('请先点击「加盖批准章」,再确认批准。');
return false;
}
return confirm('确认批准通过?批准后即可导出正式 PDF。');
}
</script>
{% endif %}
{% if user.role in ['reviewer','approver','admin'] and o.status in ['pending_review','pending_approval'] %}
<form method="post" action="{{ url_for('order_reject', oid=o.id) }}" class="toolbar" style="margin-top:8px;">
<input type="text" name="comment" placeholder="驳回原因(必填)" style="width:280px;" required>
<button class="btn danger">驳回给制单人</button>
</form>
{% endif %}
{% if editable and o.status in ['draft','rejected'] %}
<form method="post" action="{{ url_for('order_submit', oid=o.id) }}" class="toolbar" style="margin-top:8px;">
<input type="text" name="comment" placeholder="提交说明(选填)" style="width:280px;">
<button class="btn primary">提交审核</button>
</form>
{% endif %}
{% if o.status == 'rejected' and o.reject_reason %}
<div class="flash error">驳回原因:{{ o.reject_reason }}</div>
{% endif %}
<h3>审核记录</h3>
<ul class="timeline">
{% for lg in o.logs %}
<li>{{ lg.created_at.strftime('%Y-%m-%d %H:%M') }} · {{ lg.user_name }} · {{ lg.action }}
{% if lg.comment %}({{ lg.comment }}){% endif %}</li>
{% else %}
<li class="muted">暂无记录</li>
{% endfor %}
</ul>
</div>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block body %}
<h1 class="page">订单列表</h1>
<div class="card">
<form class="toolbar" method="get">
<input type="text" name="kw" placeholder="单号 / 供应商" value="{{ args.get('kw','') }}" style="width:180px;">
<select name="status" style="width:140px;">
<option value="">全部状态</option>
{% for k, v in STATUS_TEXT.items() %}
<option value="{{ k }}" {% if args.get('status')==k %}selected{% endif %}>{{ v }}</option>
{% endfor %}
</select>
<select name="doc_type" style="width:150px;">
<option value="">全部类型</option>
{% for k, v in DOC_TYPES.items() %}
<option value="{{ k }}" {% if args.get('doc_type')==k %}selected{% endif %}>{{ v }}</option>
{% endfor %}
</select>
<input type="date" name="date_from" value="{{ args.get('date_from','') }}" style="width:150px;">
<span>~</span>
<input type="date" name="date_to" value="{{ args.get('date_to','') }}" style="width:150px;">
<button class="btn primary">查询</button>
<span class="grow"></span>
<a class="btn" href="{{ url_for('export_ledger') }}">导出 Excel 台账</a>
<a class="btn primary" href="{{ url_for('order_new') }}">新建订单</a>
</form>
<table class="grid">
<tr>
<th>单号</th><th>类型</th><th>供应商</th><th>采购日期</th><th class="num">金额</th>
<th>状态</th><th>制单</th><th>审核</th><th>批准</th><th class="nowrap">操作</th>
</tr>
{% for o in rows %}
<tr>
<td class="nowrap">{{ o.order_no }}</td>
<td>{{ o.doc_type_text }}</td>
<td>{{ o.supplier_name }}</td>
<td class="nowrap">{{ o.order_date }}</td>
<td class="num">{{ '%.2f'|format(o.total_amount) }}</td>
<td><span class="badge {{ o.status }}">{{ o.status_text }}</span></td>
<td>{{ o.created_by_name }}</td>
<td>{{ o.reviewed_by or '-' }}</td>
<td>{{ o.approved_by or '-' }}</td>
<td class="nowrap">
<a class="btn sm" href="{{ url_for('order_view', oid=o.id) }}">查看</a>
<a class="btn sm" href="{{ url_for('order_pdf', oid=o.id) }}" target="_blank">PDF</a>
</td>
</tr>
{% else %}
<tr><td colspan="10" class="center muted">没有符合条件的订单</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block body %}
<h1 class="page">统计</h1>
<div class="card">
<h2>按状态</h2>
<table class="grid">
<tr><th>状态</th><th class="num">订单数</th><th class="num">金额合计(元)</th></tr>
{% for st, cnt, amt in by_status %}
<tr>
<td>{{ status_text.get(st, st) }}</td>
<td class="num">{{ cnt }}</td>
<td class="num">{{ '%.2f'|format(amt or 0) }}</td>
</tr>
{% endfor %}
</table>
</div>
<div class="card">
<h2>按供应商(仅已批准)</h2>
<table class="grid">
<tr><th>供应商</th><th class="num">订单数</th><th class="num">金额合计(元)</th></tr>
{% for name, cnt, amt in by_supplier %}
<tr><td>{{ name }}</td><td class="num">{{ cnt }}</td><td class="num">{{ '%.2f'|format(amt or 0) }}</td></tr>
{% else %}<tr><td colspan="3" class="center muted">暂无数据</td></tr>{% endfor %}
</table>
</div>
<div class="card">
<h2>按月份</h2>
<table class="grid">
<tr><th>月份</th><th class="num">订单数</th><th class="num">金额合计(元)</th></tr>
{% for m, cnt, amt in by_month %}
<tr><td>{{ m }}</td><td class="num">{{ cnt }}</td><td class="num">{{ '%.2f'|format(amt or 0) }}</td></tr>
{% endfor %}
</table>
<div style="margin-top:12px;">
<a class="btn" href="{{ url_for('export_ledger') }}">导出 Excel 台账</a>
</div>
</div>
{% endblock %}
+132
View File
@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block body %}
<h1 class="page">供应商管理</h1>
<div class="card">
<h2>新增供应商</h2>
<form method="post">
<div class="form-grid">
<div><label>编号</label><input type="text" name="code" placeholder="S003"></div>
<div><label>名称 *</label><input type="text" name="name" required></div>
<div><label>联系人</label><input type="text" name="contact"></div>
<div><label>电话</label><input type="text" name="phone"></div>
<div><label>付款方式</label><input type="text" name="payment_terms" placeholder="月结30天"></div>
<div><label>税号</label><input type="text" name="tax_no"></div>
<div><label>开户银行</label><input type="text" name="bank_name"></div>
<div><label>银行帐号</label><input type="text" name="bank_account"></div>
</div>
<div style="margin-top:12px;"><button class="btn primary">新增</button></div>
</form>
</div>
<div class="card">
<div class="card-head">
<h2>供应商列表</h2>
<button type="button" class="btn primary sm" onclick="document.getElementById('supImpModal').style.display='flex'">批量导入 Excel</button>
</div>
<table class="grid">
<tr><th>编号</th><th>名称</th><th>联系人</th><th>电话</th><th>付款方式</th><th>税号</th><th>开户银行</th><th>银行帐号</th><th>操作</th></tr>
{% for s in rows %}
<tr class="{% if not s.active %}row-off{% endif %}">
<td>{{ s.code }}</td><td>{{ s.name }}</td><td>{{ s.contact }}</td><td>{{ s.phone }}</td>
<td>{{ s.payment_terms }}</td><td>{{ s.tax_no }}</td><td>{{ s.bank_name }}</td>
<td>{{ s.bank_account }}</td>
<td class="nowrap">
<button type="button" class="btn sm" onclick="openEdit(this)"
data-id="{{ s.id }}" data-code="{{ s.code or '' }}"
data-name="{{ s.name or '' }}" data-contact="{{ s.contact or '' }}"
data-phone="{{ s.phone or '' }}"
data-payment_terms="{{ s.payment_terms or '' }}"
data-tax_no="{{ s.tax_no or '' }}"
data-bank_name="{{ s.bank_name or '' }}"
data-bank_account="{{ s.bank_account or '' }}">编辑</button>
<form method="post" action="{{ url_for('supplier_toggle', sid=s.id) }}" style="display:inline;">
<button class="btn sm {% if s.active %}danger{% endif %}"
onclick="return confirm('确定{{ '停用' if s.active else '启用' }}该供应商?')">
{{ '停用' if s.active else '启用' }}
</button>
</form>
{% if user.role == 'admin' %}
<form method="post" action="{{ url_for('supplier_delete', sid=s.id) }}" style="display:inline;">
<button class="btn sm danger"
onclick="return confirm('确定彻底删除该供应商?删除后不可恢复!')">删除</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
<div id="supImpModal" class="modal-mask" style="display:none;" onclick="if(event.target===this) this.style.display='none'">
<div class="modal-box">
<div class="modal-head">
<span>批量导入供应商</span>
<span class="modal-close" onclick="document.getElementById('supImpModal').style.display='none'">&times;</span>
</div>
<form method="post" action="{{ url_for('supplier_import_excel') }}" enctype="multipart/form-data">
<div class="modal-body">
<div class="hint">先下载模板按格式填好再导入。<b>名称</b>必填;已存在的按下方方式处理(优先按编号匹配,无编号则按名称)。</div>
<div class="field">
<label>1. 下载模板</label>
<a href="{{ url_for('supplier_import_template') }}">供应商导入模板.xlsx</a>
</div>
<div class="field">
<label>2. 选择 Excel 文件(.xls / .xlsx)</label>
<input type="file" name="file" accept=".xls,.xlsx,.xlsm" required>
</div>
<div class="field">
<label>3. 遇到已存在的供应商</label>
<select name="mode">
<option value="update">更新该供应商的资料</option>
<option value="skip">跳过,不改动已有数据</option>
</select>
</div>
</div>
<div class="modal-foot">
<button type="button" class="btn" onclick="document.getElementById('supImpModal').style.display='none'">取消</button>
<button type="submit" class="btn primary">开始导入</button>
</div>
</form>
</div>
</div>
<div id="editModal" class="modal-mask" style="display:none;" onclick="if(event.target===this) this.style.display='none'">
<div class="modal-box">
<div class="modal-head">
<span>编辑供应商</span>
<span class="modal-close" onclick="document.getElementById('editModal').style.display='none'">&times;</span>
</div>
<form method="post" action="{{ url_for('suppliers') }}">
<input type="hidden" name="id" id="edit_id">
<div class="modal-body">
<div class="form-grid two">
<div><label>编号</label><input type="text" name="code" id="edit_code"></div>
<div><label>名称 *</label><input type="text" name="name" id="edit_name" required></div>
<div><label>联系人</label><input type="text" name="contact" id="edit_contact"></div>
<div><label>电话</label><input type="text" name="phone" id="edit_phone"></div>
<div><label>付款方式</label><input type="text" name="payment_terms" id="edit_payment_terms"></div>
<div><label>税号</label><input type="text" name="tax_no" id="edit_tax_no"></div>
<div><label>开户银行</label><input type="text" name="bank_name" id="edit_bank_name"></div>
<div><label>银行帐号</label><input type="text" name="bank_account" id="edit_bank_account"></div>
</div>
</div>
<div class="modal-foot">
<button type="button" class="btn" onclick="document.getElementById('editModal').style.display='none'">取消</button>
<button type="submit" class="btn primary">保存</button>
</div>
</form>
</div>
</div>
<script>
function openEdit(btn){
const d = btn.dataset;
['id','code','name','contact','phone','payment_terms','tax_no','bank_name','bank_account']
.forEach(k => {
const el = document.getElementById('edit_' + k);
if (el) el.value = d[k] || '';
});
document.getElementById('editModal').style.display = 'flex';
}
</script>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block body %}
<h1 class="page">用户与权限</h1>
<div class="card">
<h2>新增用户</h2>
<form method="post">
<div class="form-grid">
<div><label>登录名 *</label><input type="text" name="username" required></div>
<div><label>姓名</label><input type="text" name="name"></div>
<div><label>角色</label>
<select name="role">
{% for k, v in roles.items() %}<option value="{{ k }}">{{ v }}</option>{% endfor %}
</select>
</div>
<div><label>初始密码(默认 123456)</label><input type="text" name="password" placeholder="123456"></div>
</div>
<div style="margin-top:12px;"><button class="btn primary">新增</button></div>
</form>
</div>
<div class="card">
<h2>用户列表</h2>
<table class="grid">
<tr><th>登录名</th><th>姓名</th><th>角色</th><th>状态</th><th>创建时间</th></tr>
{% for t in rows %}
<tr>
<td>{{ t.username }}</td><td>{{ t.name }}</td>
<td>{{ roles.get(t.role, t.role) }}</td>
<td>{{ '启用' if t.active else '停用' }}</td>
<td>{{ t.created_at.strftime('%Y-%m-%d %H:%M') if t.created_at else '-' }}</td>
</tr>
{% endfor %}
</table>
<div class="hint" style="margin-top:12px;">
制单员:新建 / 编辑 / 提交;主管审核:审核通过或驳回;经理批准:批准后订单生效并可导出正式 PDF;
管理员:全部权限,含用户、供应商、物料维护。
</div>
</div>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
# 博特维采购订单管理系统 v1.0
基于贵司两个 Excel 模板(采购合同模板 / 委外生产订单模板)开发的本地采购订单管理系统:
制单 → 主管审核 → 经理批准 → 导出 PDF。
## 一、启动
三种方式,任选其一:
1. **单文件 EXE(推荐分发)**:`dist\博特维采购订单系统_单文件版.exe`(36.7 MB),拷到任意文件夹双击即用,免 Python 环境
2. **文件夹版 EXE(启动最快)**:`dist\博特维采购订单系统\` 整个文件夹,双击里面的 exe 秒开
3. **源码运行**:双击 `启动.bat`,或命令行 `python app.py 8080`
访问地址 http://127.0.0.1:8080;局域网其他电脑把 `127.0.0.1` 换成本机 IP(需放行 8080 端口)。
EXE 版数据在 exe 同级自动生成的 `instance\po.db`,备份即复制该文件夹。
> 源码方式依赖:Flask、SQLAlchemy、reportlab、openpyxl、xlrd(`pip install -r requirements.txt`)
## 二、初始账号
| 账号 | 密码 | 角色 | 权限 |
|---|---|---|---|
| admin | admin123 | 系统管理员 | 全部功能,含用户/供应商/物料维护 |
| maker | 123456 | 制单员 | 新建、编辑、提交订单 |
| reviewer | 123456 | 主管审核 | 审核通过或驳回 |
| approver | 123456 | 经理批准 | 批准后订单生效、可出正式 PDF |
> 正式使用前请在「用户」页改掉默认密码。
## 三、流程
```
制单员新建订单 → 填写抬头与明细 → 提交
↓
主管审核(通过 / 驳回并填原因)
↓
经理批准(通过 / 驳回并填原因)
↓
状态 = 已批准 → 导出 PDF(无水印,带审核人姓名与时间)
```
- 草稿 / 待审核 / 待批准状态导出的 PDF 会带斜向水印(草稿、待审核等),避免误用
- 被驳回的订单回到制单员名下,可修改后再次提交
- 每个动作都会写入「审核记录」,可追溯
## 四、功能说明
| 功能 | 说明 |
|---|---|
| 两种单据 | 采购单 / 委外生产订单共用数据,版式按原模板分别套打 |
| Excel 导入明细 | 编辑页上传 .xls/.xlsx,自动识别表头(代码/规格/单位/数量/单价/交期/备注);行数不限,PDF 自动分页 |
| 供应商库 | 制单时下拉带入名称、联系人、电话、税号、银行、付款方式 |
| 物料库 | 明细行下拉带入代码、规格、单位、单价 |
| 金额大写 | 合计自动转人民币大写(壹万贰仟叁佰肆拾伍元整) |
| 历史查询 | 按单号、供应商、状态、单据类型、日期区间筛选 |
| 统计 | 按状态 / 供应商 / 月份汇总金额 |
| 台账导出 | 一键导出全部订单为 Excel |
## 五、PDF 版式
完全复刻原 Excel 模板:
1. 公司名 + 单据名称(采购单 / 委外生产订单)
2. 抬头区:左供方(供应商、联系人、电话、付款方式、税号、银行帐号、开户银行)、右需方(需方、联系人、电话、税点、税号、采购单号、采购日期)
3. 明细表 9 列:序号、物料代码、物料规格、单位、数量、含税单价、金额、交期、备注(列宽取自原模板,两种单据各自比例)
4. 合计:小写 + 人民币大写 + 元整
5. 备注条款 9 条 + 交货地点(苏州工业园区星汉街 5 号腾飞新苏工业坊 B 幢 5 楼 05-06 单元 胡乃琛 13913172516)
6. 签署区:制单 / 主管审核 / 经理批准(含姓名与时间)+ 双方盖章栏
7. 页脚:单据类型、单号、页码、打印时间
明细超过一页时自动分页并重复表头,合计与条款跟随在最后一页。
## 六、数据文件
- 数据库:`instance/po.db`(SQLite,整个文件夹复制即可备份)
- 导出的 PDF:`exports/`
- 上传的明细文件:`uploads/`
+51
View File
@@ -0,0 +1,51 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
datas = [('templates', 'templates'), ('static', 'static')]
binaries = []
hiddenimports = ['sqlalchemy.dialects.sqlite', 'openpyxl', 'xlrd']
tmp_ret = collect_all('reportlab')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
['app.py'],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='博特维采购订单系统',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='博特维采购订单系统',
)
@@ -0,0 +1,45 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
datas = [('templates', 'templates'), ('static', 'static')]
binaries = []
hiddenimports = ['sqlalchemy.dialects.sqlite', 'openpyxl', 'xlrd']
tmp_ret = collect_all('reportlab')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
['app.py'],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='博特维采购订单系统_单文件版',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+16
View File
@@ -0,0 +1,16 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
set "PY=%USERPROFILE%\.workbuddy\binaries\python\envs\default\Scripts\python.exe"
if not exist "%PY%" set "PY=python"
echo ============================================
echo 博特维采购订单管理系统
echo 浏览器访问: http://127.0.0.1:8080
echo 关闭本窗口即停止服务
echo ============================================
start "" http://127.0.0.1:8080
"%PY%" app.py 8080
pause
+40
View File
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""Dump .xls (BIFF) content: sheets, merged cells, all non-empty cell values."""
import sys
import xlrd
def dump(path):
print("=" * 100)
print("FILE:", path)
try:
bk = xlrd.open_workbook(path, formatting_info=True)
fi = True
except Exception as e:
print(" [formatting_info unavailable: %s]" % e)
bk = xlrd.open_workbook(path)
fi = False
print("sheets:", bk.sheet_names(), " nsheets:", bk.nsheets)
for sh in bk.sheets():
print("-" * 100)
print("SHEET: %s (%d rows x %d cols)" % (sh.name, sh.nrows, sh.ncols))
if fi:
try:
merged = sh.merged_cells
if merged:
print("MERGED:", merged)
except Exception:
pass
for r in range(sh.nrows):
row_vals = []
for c in range(sh.ncols):
v = sh.cell_value(r, c)
if isinstance(v, float) and v == int(v):
v = int(v)
s = str(v).strip()
if s:
row_vals.append("%s%d=%s" % (chr(65 + c) if c < 26 else "c" + str(c), r + 1, s))
if row_vals:
print(" | ".join(row_vals))
for p in sys.argv[1:]:
dump(p)
+99
View File
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""端到端冒烟:制单 → 提交 → 主管审核 → 经理批准 → 导出 PDF。"""
import http.cookiejar
import os
import re
import urllib.parse
import urllib.request
BASE = "http://127.0.0.1:8080"
OUT = r"D:\2026-09-09-task-18\tools\out"
os.makedirs(OUT, exist_ok=True)
def new_session():
cj = http.cookiejar.CookieJar()
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
def post(op, path, data):
body = urllib.parse.urlencode(data).encode()
req = urllib.request.Request(BASE + path, data=body)
r = op.open(req, timeout=30)
return r.geturl(), r.read().decode("utf-8", "replace")
def get(op, path):
r = op.open(BASE + path, timeout=30)
return r.geturl(), r.read()
def login(user, pwd):
op = new_session()
url, html = post(op, "/login", {"username": user, "password": pwd})
assert "/login" not in url, "%s 登录失败" % user
print(" login %s ok" % user)
return op
def make_order(op, doc_type, supplier_id, n_items):
url, _ = post(op, "/orders/new", {"doc_type": doc_type, "supplier_id": supplier_id})
m = re.search(r"/orders/(\d+)", url)
oid = int(m.group(1))
form = [
("order_date", "2026-09-09"), ("tax_rate", "13%"),
("supplier_name", "苏州精锐五金制品有限公司"),
("supplier_contact", "周经理"), ("supplier_phone", "13800000001"),
("payment_terms", "月结30天"), ("supplier_tax_no", "91320500MA1XXXXXXX"),
("supplier_bank_account", "4562 1088 0012 3456"),
("supplier_bank_name", "中国银行苏州工业园区支行"),
("our_company", "苏州博特维智能科技有限公司"),
("our_contact", "胡乃琛"), ("our_phone", "13913172516"),
("our_tax_no", "91320594MA1YYYYYYY"),
("delivery_place", "苏州市工业园区星汉街5号腾飞新苏工业坊B幢5楼05-06单元 胡乃琛 13913172516"),
("note", "本单为系统测试单,含 & <特殊> 字符校验"),
]
for i in range(n_items):
form += [
("item_code", "M-10%02d" % (i + 1)),
("item_spec", "测试物料 规格 A&B <%d>" % (i + 1)),
("item_unit", "件"), ("item_qty", "12.5"), ("item_price", "38.5"),
("item_date", "2026-09-20"), ("item_remark", "行%d" % (i + 1)),
]
post(op, "/orders/%d" % oid, form)
return oid
def run(tag, doc_type, n_items):
print("[%s] doc=%s items=%d" % (tag, doc_type, n_items))
maker = login("maker", "123456")
oid = make_order(maker, doc_type, 1, n_items)
print(" 制单 ok, id=%d" % oid)
post(maker, "/orders/%d/submit" % oid, {"comment": "请审核"})
rv = login("reviewer", "123456")
post(rv, "/orders/%d/review" % oid, {"comment": "同意"})
ap = login("approver", "123456")
post(ap, "/orders/%d/approve" % oid, {"comment": "批准"})
print(" 审核流程 ok")
_, data = get(ap, "/orders/%d/pdf" % oid)
path = os.path.join(OUT, "%s.pdf" % tag)
with open(path, "wb") as f:
f.write(data)
print(" PDF -> %s (%d bytes)" % (path, len(data)))
# 草稿水印版
oid2 = make_order(maker, doc_type, 1, 2)
_, data2 = get(maker, "/orders/%d/pdf" % oid2)
p2 = os.path.join(OUT, "%s_draft.pdf" % tag)
with open(p2, "wb") as f:
f.write(data2)
print(" 草稿 PDF -> %s (%d bytes)" % (p2, len(data2)))
if __name__ == "__main__":
run("purchase", "purchase", 3)
run("outsourcing_30rows", "outsourcing", 30)
print("ALL DONE")
+49
View File
@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""Init local git repo + commit source (respecting .gitignore) using dulwich.
No network/credentials needed. Push happens separately once remote URL+token are known.
"""
import os, sys
from dulwich import porcelain
from dulwich.repo import Repo
ROOT = r"D:\2026-09-09-task-18"
GITDIR = os.path.join(ROOT, ".git")
# 1) init (skip if already a repo)
if os.path.exists(GITDIR):
print("repo already exists, reusing")
repo = Repo(GITDIR)
else:
repo = porcelain.init(ROOT)
print("repo initialized at", GITDIR)
# 2) stage everything; dulwich add honors .gitignore via ignore manager
porcelain.add(repo, [ROOT])
print("staged files (count):", len(repo.open_index()))
# 3) show what got staged (verify no db / dist / backups)
staged = list(repo.open_index())
bad = [p for p in staged if
p.endswith(b".db") or b"\\dist\\" in p or p.startswith(b"dist/") or
b"dist_backup" in p or b"\\instance\\" in p or b"/instance/" in p or
p.startswith(b".workbuddy")]
print("SUSPECT_STAGED:", [p.decode("utf-8","replace") for p in bad])
if bad:
print("ABORT: ignored files would be committed; fix .gitignore")
sys.exit(2)
# 4) commit (with identity)
porcelain.commit(repo,
message=b"init: \xe5\x8d\x9a\xe7\x89\xb9\xe7\xbb\xb4\xe9\x87\x87\xe8\xb4\xad\xe8\xae\xa2\xe5\x8d\x95\xe7\xb3\xbb\xe7\xbb\x9f v1.0 \xe6\xba\x90\xe7\xa0\x81",
author=b"star <star@btw-ocr.com>",
committer=b"star <star@btw-ocr.com>")
print("COMMITTED. HEAD:", repo.head().decode())
# 5) report tracked file list
tracked = []
for e in repo.open_index().iteritems():
tracked.append(e[0].decode("utf-8","replace"))
print("TRACKED_FILES:")
for f in sorted(tracked):
print(" ", f)
+28
View File
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""把 PDF 指定页渲染成 PNG,用于版式校验。"""
import os
import sys
import pymupdf
OUT = r"D:\2026-09-09-task-18\tools\out"
def render(pdf, pages=(0,), dpi=110, prefix=None):
doc = pymupdf.open(pdf)
prefix = prefix or os.path.splitext(os.path.basename(pdf))[0]
for p in pages:
if p >= len(doc):
continue
page = doc[p]
pix = page.get_pixmap(dpi=dpi)
path = os.path.join(OUT, "%s_p%d.png" % (prefix, p + 1))
pix.save(path)
print(path)
print("pages:", len(doc))
if __name__ == "__main__":
name = sys.argv[1]
pages = [int(x) for x in sys.argv[2].split(",")] if len(sys.argv) > 2 else [0]
render(os.path.join(OUT, name), pages)
+13
View File
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
import subprocess
import sys
PY = r"C:\Users\wps18\.workbuddy\binaries\python\envs\default\Scripts\python.exe"
CWD = r"D:\2026-09-09-task-18\po_app"
p = subprocess.run([PY, "-c", "import app; print('IMPORT OK')"], cwd=CWD,
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=60)
print("RC", p.returncode)
print("STDOUT:", p.stdout[-4000:])
print("STDERR:", p.stderr[-4000:])
+100
View File
@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
"""模拟:经理批准也需先加章;批准通过后 PDF 上审核章与批准章并排。
用法:python test_approve_stamp.py 8099
"""
import os
import re
import sqlite3
import sys
import requests
BASE = "http://127.0.0.1:%s" % (sys.argv[1] if len(sys.argv) > 1 else "8099")
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "out")
os.makedirs(OUT, exist_ok=True)
DB = os.path.join(os.path.dirname(HERE), "po_app", "instance", "po.db")
FAILS = []
def check(label, cond, extra=""):
print((" OK " if cond else " FAIL ") + label + (" " + str(extra) if extra else ""))
if not cond:
FAILS.append(label)
def sess(u, p):
s = requests.Session()
s.post(BASE + "/login", data={"username": u, "password": p})
return s
def flash_of(h):
return " | ".join(re.sub(r"<[^>]+>", "", m.group(1)).strip()
for m in re.finditer(r'class="flash[^"]*"[^>]*>(.*?)</div>', h, re.S))
def dbq(sql, a=()):
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute(sql, a)
r = cur.fetchone()
con.close()
return r
def main():
admin = sess("admin", "admin123")
r = admin.post(BASE + "/orders/new", data={"doc_type": "purchase"})
oid = int(re.search(r"/orders/(\d+)/edit", r.url).group(1))
admin.post(BASE + "/orders/%d" % oid, data={
"supplier_name": u"测试供应商",
"item_code": ["M-1001"], "item_spec": [u"测试物料"], "item_unit": [u"件"],
"item_qty": ["10"], "item_price": ["12.5"], "item_date": [""], "item_remark": [""],
})
admin.post(BASE + "/orders/%d/submit" % oid, data={"comment": u"模拟"})
print("\n[1] 主管审核(加章)")
rv = sess("reviewer", "123456")
rv.post(BASE + "/orders/%d/review" % oid, data={"comment": u"ok", "stamped": "1"})
check("审核通过", dbq("SELECT status FROM orders WHERE id=?", (oid,))[0] == "pending_approval")
print("\n[2] 经理批准:未加章应被拒绝")
ap = sess("approver", "123456")
h = ap.post(BASE + "/orders/%d/approve" % oid, data={"comment": u"直接批", "stamped": "0"}).text
check("未加章 → 提示", u"请先加盖批准章" in flash_of(h), flash_of(h))
check("状态未变", dbq("SELECT status FROM orders WHERE id=?", (oid,))[0] == "pending_approval")
print("\n[3] 加章后批准")
ap.post(BASE + "/orders/%d/approve" % oid, data={"comment": u"同意", "stamped": "1"})
row = dbq("SELECT status, approved_by FROM orders WHERE id=?", (oid,))
check("已批准", row[0] == "approved", row)
print("\n[4] 导出 PDF(应含两枚章)")
b = admin.get(BASE + "/orders/%d/pdf" % oid).content
p = os.path.join(OUT, "seal_both.pdf")
open(p, "wb").write(b)
check("PDF 生成", len(b) > 10000, "%d bytes" % len(b))
try:
import pymupdf
doc = pymupdf.open(p)
doc[-1].get_pixmap(dpi=130).save(os.path.join(OUT, "seal_both.png"))
print(" 预览:", os.path.join(OUT, "seal_both.png"), "%d 页" % doc.page_count)
doc.close()
except Exception as e:
print(" 渲染失败:", e)
print("\n[5] 清理")
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute("DELETE FROM order_items WHERE order_id=?", (oid,))
cur.execute("DELETE FROM orders WHERE id=?", (oid,))
con.commit()
con.close()
print(" 已删除订单 %d" % oid)
print("\n" + ("全部通过" if not FAILS else "失败: " + "; ".join(FAILS)))
if __name__ == "__main__":
main()
+208
View File
@@ -0,0 +1,208 @@
# -*- coding: utf-8 -*-
"""批量导入 + 需方预置 + PO 号规则 E2E 测试。
用法:先启动服务(python app.py 8099),再运行本脚本。
覆盖:物料/供应商导入的 新增 / 更新 / 跳过 / 失败 四条路径,
模板下载,PO 号 4 位规则,以及旧格式单号的兼容解析。
"""
import datetime
import os
import re
import sqlite3
import sys
import openpyxl
import requests
PORT = sys.argv[1] if len(sys.argv) > 1 else "8099"
BASE = "http://127.0.0.1:%s" % PORT
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "out")
os.makedirs(OUT, exist_ok=True)
DB = os.path.join(os.path.dirname(HERE), "po_app", "instance", "po.db")
s = requests.Session()
FAILS = []
def check(label, cond, extra=""):
print((" OK " if cond else " FAIL ") + label + (" " + str(extra) if extra else ""))
if not cond:
FAILS.append(label)
def login():
r = s.post(BASE + "/login",
data={"username": "admin", "password": "admin123"},
allow_redirects=False)
return r.status_code in (200, 302)
def make_material_xlsx(path):
wb = openpyxl.Workbook()
ws = wb.active
ws.append([u"物料代码", u"物料规格", u"单位", u"默认含税单价"])
ws.append(["M-9001", u"测试新增物料A", u"件", 12.5]) # 新增
ws.append(["M-9002", u"测试新增物料B", u"台", 88.0]) # 新增
ws.append(["M-1001", u"铝合金支架(已存在-应被更新)", u"件", 99.9]) # 已存在
ws.append(["", u"缺代码应失败", u"件", 1.0]) # 失败
wb.save(path)
def make_supplier_xlsx(path):
wb = openpyxl.Workbook()
ws = wb.active
ws.append([u"编号", u"名称", u"联系人", u"电话", u"付款方式", u"税号",
u"开户银行", u"银行帐号"])
ws.append(["S901", u"测试新增供应商A", u"王经理", "13700000001",
u"月结30天", "91320500TEST001", u"工商银行苏州分行", "1111 2222 3333"])
ws.append(["S001", u"苏州精锐五金制品有限公司", u"周经理(已更新)", "13800000001",
u"月结60天", "91320500MA1XXXXXXX", u"中国银行苏州工业园区支行",
"4562 1088 0012 3456"]) # 已存在
ws.append(["S902", u"", u"缺名称", "13900000009", u"", u"", u"", u""]) # 失败
wb.save(path)
def post_import(url, path, mode):
with open(path, "rb") as f:
return s.post(url,
files={"file": (os.path.basename(path), f,
"application/vnd.openxmlformats-officedocument."
"spreadsheetml.sheet")},
data={"mode": mode})
def flash_texts(html):
out = []
for m in re.finditer(r'class="flash[^"]*"[^>]*>(.*?)</div>', html, re.S):
out.append(re.sub(r"<[^>]+>", "", m.group(1)).strip())
return out
def main():
print("登录:", "OK" if login() else "FAIL")
# 预清理:让脚本可重复运行(上次残留的测试数据不影响本次断言)
try:
today0 = datetime.datetime.now().strftime("%Y%m%d")
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute("UPDATE materials SET spec=?, unit=?, price=? WHERE code=?",
(u"铝合金支架 6061-T6 200×80×10mm", u"件", 38.5, "M-1001"))
cur.execute("UPDATE suppliers SET contact=?, payment_terms=? WHERE code=?",
(u"周经理", u"月结30天", "S001"))
cur.execute("DELETE FROM materials WHERE code IN ('M-9001','M-9002')")
cur.execute("DELETE FROM suppliers WHERE code IN ('S901','S902')")
for p in ("PO", "WO"):
cur.execute("DELETE FROM order_items WHERE order_id IN "
"(SELECT id FROM orders WHERE order_no LIKE ?)",
(p + today0 + "%",))
cur.execute("DELETE FROM orders WHERE order_no LIKE ?", (p + today0 + "%",))
con.commit()
con.close()
except Exception as e:
print("预清理失败:", e)
mp = os.path.join(OUT, "materials_test.xlsx")
sp = os.path.join(OUT, "suppliers_test.xlsx")
make_material_xlsx(mp)
make_supplier_xlsx(sp)
print("\n[1] 物料导入 update 模式")
r = post_import(BASE + "/materials/import_excel", mp, "update")
t = " | ".join(flash_texts(r.text))
print(" flash:", t)
check("新增2 / 更新1 / 失败1", u"新增 2" in t and u"更新 1" in t and u"失败 1" in t)
print("\n[2] 供应商导入 update 模式")
r = post_import(BASE + "/suppliers/import_excel", sp, "update")
t = " | ".join(flash_texts(r.text))
print(" flash:", t)
check("新增1 / 更新1 / 失败1", u"新增 1" in t and u"更新 1" in t and u"失败 1" in t)
print("\n[3] 再导一次,skip 模式(应全部跳过)")
r = post_import(BASE + "/materials/import_excel", mp, "skip")
t = " | ".join(flash_texts(r.text))
print(" 物料 flash:", t)
check("物料跳过3(M-9001/M-9002/M-1001 此时都已存在)", u"跳过 3" in t)
r = post_import(BASE + "/suppliers/import_excel", sp, "skip")
t = " | ".join(flash_texts(r.text))
print(" 供应商 flash:", t)
check("供应商跳过2(S901/S001 此时都已存在)", u"跳过 2" in t)
print("\n[4] 列表核对")
html = s.get(BASE + "/materials").text
check("M-9001 已新增", "M-9001" in html)
check("M-1001 单价已更新为 99.90", "99.90" in html)
html = s.get(BASE + "/suppliers").text
check("S901 已新增", "S901" in html)
check("联系人已更新为 周经理(已更新)", u"周经理(已更新)" in html)
print("\n[5] 模板下载")
for u in ("/materials/import_template.xlsx", "/suppliers/import_template.xlsx"):
rr = s.get(BASE + u)
check(u, rr.status_code == 200 and len(rr.content) > 3000,
"%s bytes" % len(rr.content))
print("\n[6] 新建采购单:PO 号规则 + 需方预置")
created = []
r = s.post(BASE + "/orders/new", data={"doc_type": "purchase"})
m = re.search(r"PO\d{12}", r.text)
no = m.group(0) if m else ""
created.append(no)
print(" 单号:", no)
today = datetime.datetime.now().strftime("%Y%m%d")
check("格式为 PO+年月日+4位", bool(re.match(r"^PO\d{8}\d{4}$", no)))
check("日期为今天 " + today, no.startswith("PO" + today))
for k, v in (("our_contact", u"胡乃琛"), ("our_phone", u"13913172516"),
("our_tax_no", u"91320507MA1WW8D15D"), ("tax_rate", u"13%")):
check("预置 %s = %s" % (k, v), v in r.text)
print("\n[7] 旧格式单号兼容(插入带横杠的 PO%s-0007)" % today)
legacy = "PO%s-0007" % today
try:
con = sqlite3.connect(DB)
con.execute("INSERT INTO orders (order_no, doc_type, status, order_date) "
"VALUES (?,?,?,?)",
(legacy, "purchase", "draft",
datetime.datetime.now().strftime("%Y-%m-%d")))
con.commit()
con.close()
r = s.post(BASE + "/orders/new", data={"doc_type": "purchase"})
m2 = re.search(r"PO\d{12}", r.text)
got = m2.group(0) if m2 else ""
print(" 新单号:", got, " 期望: PO%s0008" % today)
check("旧格式未导致序号重置", got == "PO%s0008" % today)
created.extend([got, legacy])
except Exception as e:
print(" 跳过,原因:", e)
print("\n[8] 清理测试数据(还原被更新的既有记录)")
try:
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute("UPDATE materials SET spec=?, unit=?, price=? WHERE code=?",
(u"铝合金支架 6061-T6 200×80×10mm", u"件", 38.5, "M-1001"))
cur.execute("UPDATE suppliers SET contact=?, payment_terms=? WHERE code=?",
(u"周经理", u"月结30天", "S001"))
cur.execute("DELETE FROM materials WHERE code IN ('M-9001','M-9002')")
cur.execute("DELETE FROM suppliers WHERE code IN ('S901','S902')")
for no_ in [x for x in created if x]:
cur.execute("DELETE FROM order_items WHERE order_id IN "
"(SELECT id FROM orders WHERE order_no=?)", (no_,))
cur.execute("DELETE FROM orders WHERE order_no=?", (no_,))
con.commit()
cur.execute("SELECT COUNT(*) FROM materials WHERE code LIKE 'M-900%'")
n1 = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM suppliers WHERE code IN ('S901','S902')")
n2 = cur.fetchone()[0]
con.close()
check("测试物料已清除", n1 == 0)
check("测试供应商已清除", n2 == 0)
except Exception as e:
print(" 清理失败:", e)
print("\n" + ("全部通过" if not FAILS else "失败项: " + "; ".join(FAILS)))
if __name__ == "__main__":
main()
+119
View File
@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
"""编辑功能 + 明细行模板 冒烟测试。
覆盖:
1) 新建订单(0 条明细,正是"点添加一行没反应"的场景)页面必须带 rowTpl 模板行
2) 物料列表编辑:POST /materials 带 id 能改库
3) 供应商列表编辑:POST /suppliers 带 id 能改库
用法:先启动服务(python app.py 8099),再 python test_edit_row.py 8099
"""
import os
import re
import sqlite3
import sys
import requests
PORT = sys.argv[1] if len(sys.argv) > 1 else "8099"
BASE = "http://127.0.0.1:%s" % PORT
HERE = os.path.dirname(os.path.abspath(__file__))
DB = os.path.join(os.path.dirname(HERE), "po_app", "instance", "po.db")
s = requests.Session()
FAILS = []
def check(label, cond, extra=""):
print((" OK " if cond else " FAIL ") + label + (" " + str(extra) if extra else ""))
if not cond:
FAILS.append(label)
def main():
r = s.post(BASE + "/login", data={"username": "admin", "password": "admin123"})
check("登录", r.status_code == 200, r.status_code)
print("\n[1] 新建订单(0 明细)—— 明细行模板")
r = s.post(BASE + "/orders/new", data={"doc_type": "purchase"})
html = r.text
no = ""
m = re.search(r"PO\d{12}", html)
if m:
no = m.group(0)
check("新建订单页含 rowTpl 模板", 'id="rowTpl"' in html)
tpl = re.search(r'<template id="rowTpl">(.*?)</template>', html, re.S)
check("模板内含 tr 行", bool(tpl) and "<tr>" in tpl.group(1))
check("模板含 item_code 输入框", bool(tpl) and 'name="item_code"' in tpl.group(1))
check("模板含 matpick 下拉", bool(tpl) and "matpick" in tpl.group(1))
n_code = html.count('name="item_code"')
check("当前确实 0 条明细(模板是唯一来源)", n_code == 1,
"item_code 出现 %d 次" % n_code)
print("\n[2] 物料列表编辑")
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute("SELECT id FROM materials WHERE code='M-1002'")
row = cur.fetchone()
con.close()
if not row:
check("找到物料 M-1002", False)
else:
mid = row[0]
r = s.post(BASE + "/materials",
data={"id": mid, "code": "M-1002", "spec": u"步进电机(已编辑)",
"unit": u"台", "price": "299.5"})
check("物料编辑请求成功", r.status_code == 200, r.status_code)
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute("SELECT spec, unit, price FROM materials WHERE id=?", (mid,))
got = cur.fetchone()
check("物料已更新", got == (u"步进电机(已编辑)", u"台", 299.5), got)
cur.execute("UPDATE materials SET spec=?, price=? WHERE id=?",
(u"步进电机 57BYG250 2相", 265.0, mid))
con.commit()
con.close()
print(" 已还原 M-1002")
print("\n[3] 供应商列表编辑")
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute("SELECT id FROM suppliers WHERE code='S002'")
row = cur.fetchone()
con.close()
if not row:
check("找到供应商 S002", False)
else:
sid = row[0]
r = s.post(BASE + "/suppliers",
data={"id": sid, "code": "S002", "name": u"昆山宏远电子科技有限公司",
"contact": u"陈主管(已编辑)", "phone": "13900000002",
"payment_terms": u"货到付款", "tax_no": "91320583MA2XXXXXXX",
"bank_name": u"建设银行昆山分行",
"bank_account": "3220 1988 2233 4455"})
check("供应商编辑请求成功", r.status_code == 200, r.status_code)
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute("SELECT contact, payment_terms FROM suppliers WHERE id=?", (sid,))
got = cur.fetchone()
check("供应商已更新", got == (u"陈主管(已编辑)", u"货到付款"), got)
cur.execute("UPDATE suppliers SET contact=? WHERE id=?", (u"陈主管", sid))
con.commit()
con.close()
print(" 已还原 S002")
print("\n[4] 清理测试订单 " + no)
if no:
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute("DELETE FROM order_items WHERE order_id IN "
"(SELECT id FROM orders WHERE order_no=?)", (no,))
cur.execute("DELETE FROM orders WHERE order_no=?", (no,))
con.commit()
con.close()
print("\n" + ("全部通过" if not FAILS else "失败: " + "; ".join(FAILS)))
if __name__ == "__main__":
main()
+73
View File
@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""测试 Excel 明细导入接口(multipart 上传)。"""
import io
import os
import re
import urllib.parse
import urllib.request
import openpyxl
BASE = "http://127.0.0.1:8080"
OUT = r"D:\2026-09-09-task-18\tools\out"
os.makedirs(OUT, exist_ok=True)
def new_session():
import http.cookiejar
cj = http.cookiejar.CookieJar()
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
def post_form(op, path, data):
body = urllib.parse.urlencode(data).encode()
r = op.open(urllib.request.Request(BASE + path, data=body), timeout=30)
return r.geturl(), r.read()
def multipart_upload(op, path, field, filename, content, extra=None):
boundary = "----PoTestBoundary1234"
buf = io.BytesIO()
for k, v in (extra or {}).items():
buf.write(("--%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n%s\r\n"
% (boundary, k, v)).encode("utf-8"))
buf.write(("--%s\r\nContent-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n"
"Content-Type: application/octet-stream\r\n\r\n" % (boundary, field, filename))
.encode("utf-8"))
buf.write(content)
buf.write(("\r\n--%s--\r\n" % boundary).encode())
req = urllib.request.Request(BASE + path, data=buf.getvalue(), method="POST")
req.add_header("Content-Type", "multipart/form-data; boundary=%s" % boundary)
r = op.open(req, timeout=30)
return r.geturl(), r.read().decode("utf-8", "replace")
def main():
op = new_session()
post_form(op, "/login", {"username": "maker", "password": "123456"})
wb = openpyxl.Workbook()
ws = wb.active
ws.append([u"物料代码", u"物料规格", u"单位", u"数量", u"含税单价", u"交期", u"备注"])
ws.append([u"X-001", u"导入测试件 & <一>", u"件", 10, 99.9, "2026-10-01", u"首件"])
ws.append([u"X-002", u"导入测试件 二", u"套", 3, 128.5, "2026-10-05", u""])
buf = io.BytesIO()
wb.save(buf)
xlsx = buf.getvalue()
url, _ = post_form(op, "/orders/new", {"doc_type": "purchase", "supplier_id": ""})
oid = int(re.search(r"/orders/(\d+)", url).group(1))
print("order id", oid)
url, html = multipart_upload(op, "/orders/%d/import_excel" % oid, "file",
"test.xlsx", xlsx, {"mode": "replace"})
ok = "已导入 2 行明细" in html
print("import rows ok:", ok)
ok2 = "99.90" in html or "99.9" in html
print("price present:", ok2)
assert ok and ok2
print("IMPORT TEST PASSED")
if __name__ == "__main__":
main()
+28
View File
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""快速检查统计页与台账导出。"""
import http.cookiejar
import urllib.parse
import urllib.request
BASE = "http://127.0.0.1:8080"
def main():
cj = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
op.open(urllib.request.Request(
BASE + "/login",
data=urllib.parse.urlencode({"username": "admin", "password": "admin123"}).encode()),
timeout=30)
for path, expect in [("/stats", b"200"), ("/export/ledger", b"xlsx"),
("/orders", b"200"), ("/suppliers", b"200"),
("/materials", b"200"), ("/users", b"200"), ("/", b"200")]:
r = op.open(BASE + path, timeout=30)
data = r.read()
print(path, r.status, len(data))
assert r.status == 200, path
print("PAGES OK")
if __name__ == "__main__":
main()
+156
View File
@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""模拟完整流程:停用/启用/删除 + 主管审核加章,并导出带章 PDF 渲染成 PNG。
用法:先启动服务(python app.py 8099),再 python test_stamp_toggle.py 8099
"""
import os
import re
import sqlite3
import sys
import requests
PORT = sys.argv[1] if len(sys.argv) > 1 else "8099"
BASE = "http://127.0.0.1:%s" % PORT
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "out")
os.makedirs(OUT, exist_ok=True)
DB = os.path.join(os.path.dirname(HERE), "po_app", "instance", "po.db")
FAILS = []
def check(label, cond, extra=""):
print((" OK " if cond else " FAIL ") + label + (" " + str(extra) if extra else ""))
if not cond:
FAILS.append(label)
def new_sess(u, p):
s = requests.Session()
r = s.post(BASE + "/login", data={"username": u, "password": p})
assert r.status_code == 200, r.status_code
return s
def flash_of(html):
return " | ".join(re.sub(r"<[^>]+>", "", m.group(1)).strip()
for m in re.finditer(r'class="flash[^"]*"[^>]*>(.*?)</div>',
html, re.S))
def row_html(html, key):
"""取出包含指定关键字的那个 <tr>...</tr>。"""
for m in re.finditer(r"<tr[^>]*>(.*?)</tr>", html, re.S):
if key in m.group(1):
return m.group(0)
return ""
def dbq(sql, args=()):
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute(sql, args)
r = cur.fetchone()
con.close()
return r
def main():
admin = new_sess("admin", "admin123")
print("\n[1] 供应商:停用 → 整行置灰 → 启用 → 彻底删除")
admin.post(BASE + "/suppliers",
data={"code": "S-T1", "name": u"测试供应商T1", "contact": u"测试联系人"})
sid = dbq("SELECT id FROM suppliers WHERE code='S-T1'")[0]
h = admin.get(BASE + "/suppliers").text
check("S-T1 已创建", "S-T1" in h)
check("初始为启用态(未置灰)", "row-off" not in row_html(h, "S-T1"))
admin.post(BASE + "/suppliers/%d/toggle" % sid)
h = admin.get(BASE + "/suppliers").text
check("停用后整行置灰", "row-off" in row_html(h, "S-T1"))
check("停用后按钮变为『启用』", u"启用" in row_html(h, "S-T1"))
check("停用后仍可编辑(保留入口)", "openEdit" in row_html(h, "S-T1"))
admin.post(BASE + "/suppliers/%d/toggle" % sid)
h = admin.get(BASE + "/suppliers").text
check("启用后恢复(不再置灰)", "row-off" not in row_html(h, "S-T1"))
admin.post(BASE + "/suppliers/%d/delete" % sid)
h = admin.get(BASE + "/suppliers").text
check("删除后从列表消失", "S-T1" not in h)
print("\n[2] 物料:停用 → 整行置灰 → 启用 → 彻底删除")
admin.post(BASE + "/materials",
data={"code": "M-T1", "spec": u"测试物料T1", "unit": u"件", "price": "9.9"})
mid = dbq("SELECT id FROM materials WHERE code='M-T1'")[0]
h = admin.get(BASE + "/materials").text
check("M-T1 已创建", "M-T1" in h)
check("初始为启用态(未置灰)", "row-off" not in row_html(h, "M-T1"))
admin.post(BASE + "/materials/%d/toggle" % mid)
h = admin.get(BASE + "/materials").text
check("停用后整行置灰", "row-off" in row_html(h, "M-T1"))
admin.post(BASE + "/materials/%d/toggle" % mid)
h = admin.get(BASE + "/materials").text
check("启用后恢复", "row-off" not in row_html(h, "M-T1"))
admin.post(BASE + "/materials/%d/delete" % mid)
h = admin.get(BASE + "/materials").text
check("删除后从列表消失", "M-T1" not in h)
print("\n[3] 主管审核:确认前必须先加章")
r = admin.post(BASE + "/orders/new", data={"doc_type": "purchase"})
m = re.search(r"/orders/(\d+)/edit", r.url)
oid = int(m.group(1))
admin.post(BASE + "/orders/%d" % oid, data={
"supplier_name": u"测试供应商",
"item_code": ["M-1001"], "item_spec": [u"测试物料"],
"item_unit": [u"件"], "item_qty": ["10"], "item_price": ["12.5"],
"item_date": [""], "item_remark": [""],
})
admin.post(BASE + "/orders/%d/submit" % oid, data={"comment": u"模拟提交"})
st = dbq("SELECT status FROM orders WHERE id=?", (oid,))[0]
check("订单已提交,状态待审核", st == "pending_review", st)
rv = new_sess("reviewer", "123456")
r = rv.post(BASE + "/orders/%d/review" % oid,
data={"comment": u"直接点确认试试", "stamped": "0"})
check("未加章 → 被拒绝并提示", u"请先加盖审核章" in flash_of(r.text),
flash_of(r.text))
st = dbq("SELECT status FROM orders WHERE id=?", (oid,))[0]
check("未加章时状态不变", st == "pending_review", st)
r = rv.post(BASE + "/orders/%d/review" % oid,
data={"comment": u"已核对无误", "stamped": "1"})
row = dbq("SELECT status, reviewed_by FROM orders WHERE id=?", (oid,))
check("加章后审核通过", row[0] == "pending_approval", row)
check("记录了审核人", bool(row[1]), row[1])
print("\n[4] 导出带章 PDF 并渲染预览")
pdf_bytes = admin.get(BASE + "/orders/%d/pdf" % oid).content
pdf_path = os.path.join(OUT, "seal_review.pdf")
with open(pdf_path, "wb") as f:
f.write(pdf_bytes)
check("PDF 已生成", len(pdf_bytes) > 10000, "%d bytes" % len(pdf_bytes))
try:
import fitz
doc = fitz.open(pdf_path)
pix = doc[-1].get_pixmap(dpi=130)
png_path = os.path.join(OUT, "seal_review.png")
pix.save(png_path)
print(" 渲染预览:", png_path, "共 %d 页" % doc.page_count)
doc.close()
except Exception as e:
print(" 渲染预览失败:", e)
print("\n[5] 清理测试订单")
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute("DELETE FROM order_items WHERE order_id=?", (oid,))
cur.execute("DELETE FROM orders WHERE id=?", (oid,))
con.commit()
con.close()
print(" 已删除订单 %d" % oid)
print("\n" + ("模拟全部通过" if not FAILS else "失败: " + "; ".join(FAILS)))
if __name__ == "__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""打包后 EXE 冒烟测试。
确认这些确实进了包:批量导入入口与模板、列表编辑入口、弹窗样式,
以及「新建订单明细为空时仍能添加行」所依赖的 rowTpl 模板。
用法:python verify_exe.py 8080 [数据库路径]
不传数据库路径时默认 po_app/dist/instance/po.db
"""
import os
import re
import sqlite3
import sys
import requests
BASE = "http://127.0.0.1:%s" % (sys.argv[1] if len(sys.argv) > 1 else "8080")
HERE = os.path.dirname(os.path.abspath(__file__))
DB = sys.argv[2] if len(sys.argv) > 2 else os.path.join(
os.path.dirname(HERE), "po_app", "dist", "instance", "po.db")
s = requests.Session()
FAILS = []
def check(label, cond, extra=""):
print((" OK " if cond else " FAIL ") + label + (" " + str(extra) if extra else ""))
if not cond:
FAILS.append(label)
def main():
r = s.post(BASE + "/login", data={"username": "admin", "password": "admin123"})
check("登录", r.status_code == 200, r.status_code)
# 注意:url_for() 会被 Jinja 渲染成实际路径,页面里不会出现函数名
for path, marks in (
("/materials", [u"批量导入 Excel", "matImpModal", "/materials/import_excel",
"/materials/import_template.xlsx", "openEdit", "editModal",
"/toggle", "/delete", u"停用", u"删除"]),
("/suppliers", [u"批量导入 Excel", "supImpModal", "/suppliers/import_excel",
"/suppliers/import_template.xlsx", "openEdit", "editModal",
"/toggle", "/delete", u"停用", u"删除"]),
):
h = s.get(BASE + path).text
missing = [m for m in marks if m not in h]
check("%s 含导入/编辑/停用删除/置灰" % path, not missing, missing)
css = s.get(BASE + "/static/style.css").text
check("style.css 含弹窗样式", "modal-mask" in css and "modal-box" in css)
for u in ("/materials/import_template.xlsx", "/suppliers/import_template.xlsx"):
r = s.get(BASE + u)
check("模板下载 %s" % u, r.status_code == 200 and len(r.content) > 3000,
"%s bytes" % len(r.content))
# 新建订单:明细为空时必须仍能添加行(依赖 rowTpl 模板存在)
r = s.post(BASE + "/orders/new", data={"doc_type": "purchase"})
h = r.text
no = ""
m = re.search(r"PO\d{12}", h)
if m:
no = m.group(0)
check("新建订单页含 rowTpl 明细行模板", 'id="rowTpl"' in h)
tpl = re.search(r'<template id="rowTpl">(.*?)</template>', h, re.S)
check("rowTpl 内含完整空行", bool(tpl) and 'name="item_code"' in tpl.group(1))
if no and os.path.exists(DB):
con = sqlite3.connect(DB)
cur = con.cursor()
cur.execute("DELETE FROM order_items WHERE order_id IN "
"(SELECT id FROM orders WHERE order_no=?)", (no,))
cur.execute("DELETE FROM orders WHERE order_no=?", (no,))
con.commit()
con.close()
print(" 已清理测试订单 %s" % no)
print("\n" + ("打包版冒烟全部通过" if not FAILS else "失败: " + "; ".join(FAILS)))
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""Dump .xls layout: row heights, col widths, print setup, print area, formulas."""
import sys
import xlrd
def dump(path):
print("=" * 90)
print("FILE:", path)
bk = xlrd.open_workbook(path, formatting_info=True)
try:
names = {k: (v.name, getattr(v, 'result', None)) for k, v in list(bk.name_map.items())}
print("NAMES:", names)
except Exception as e:
print("NAMES err", e)
for sh in bk.sheets():
print("-" * 90)
print("SHEET", sh.name, "nrows", sh.nrows, "ncols", sh.ncols)
print("defcolwidth", sh.defcolwidth, "standardwidth", getattr(sh, 'standardwidth', None))
print("colwidths:", [(c, sh.colinfo_map[c].width if c in sh.colinfo_map else None) for c in range(sh.ncols)])
print("rowheights:", [(r + 1, sh.rowinfo_map[r].height if r in sh.rowinfo_map else None) for r in range(sh.nrows)])
try:
pa = sh.phony_dim_rows
print("phony_dim_rows", pa)
except Exception:
pass
for attr in ("print_area", "print_titles"):
print(attr, getattr(bk, attr, None))
for p in sys.argv[1:]:
dump(p)