完善采购审批流程、企业微信通知和采购专用章

This commit is contained in:
reddaniel110
2026-09-16 11:45:21 +08:00
parent 4876d8805d
commit bf952196fa
58 changed files with 3451 additions and 1909 deletions
+107 -93
View File
@@ -2,6 +2,7 @@
"""按原 Excel 模板版式生成采购单 / 委外生产订单 PDF。"""
import math
import os
import io
from datetime import datetime
from xml.sax.saxutils import escape
@@ -17,19 +18,24 @@ from reportlab.platypus import (BaseDocTemplate, Flowable, Frame, KeepTogether,
Table, TableStyle)
from amount_cn import amount_to_cn
from money import ZERO, format_price, money
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"]
SEAL_CANDIDATES = ["simfang.ttf", "STFANGSO.TTF", "simsun.ttc", "STSONG.TTF"]
SEAL_NUMBER_CANDIDATES = ["arial.ttf", "LiberationSans-Regular.ttf", "DejaVuSans.ttf"]
FONT_BODY = "CN-Body"
FONT_BOLD = "CN-Bold"
FONT_SEAL = "CN-Seal"
FONT_SEAL_NUMBER = "Seal-Number"
# 明细列宽比例,取自两个 xls 模板的实际列宽
# 延续两种模板比例;委外单将过宽的单位列让给规格列。
COL_RATIO = {
"purchase": [1184, 3104, 6528, 3232, 2400, 2976, 2400, 3712, 1728],
"outsourcing": [1184, 5440, 3392, 5408, 2400, 2976, 3840, 3712, 4736],
"outsourcing": [1184, 5440, 7264, 1536, 2400, 2976, 3840, 3712, 4736],
}
TERMS = [
@@ -45,84 +51,105 @@ TERMS = [
STATUS_WATERMARK = {
"draft": u"草稿 · 未提交",
"pending_review": u"待主管审核",
"pending_approval": u"待经理批准",
"pending_review": u"待审批",
"pending_approval": u"待审批",
"rejected": u"已驳回 · 未生效",
"voided": u"已作废 · 禁止使用",
}
SEAL_COLOR = colors.HexColor("#C8102E")
SEAL_NUMBER = "3205070994140"
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 _seal_arc(text, radius, start, span, font, size, bottom=False,
scale_x=1, scale_y=1):
"""Lay out both renderers in a 100 x 100 Cartesian coordinate system.
Fit by glyph width, so seal-number characters do not get the same
angular spacing as Chinese characters. The lower arc remains upright.
"""
length = radius * math.radians(span)
width = pdfmetrics.stringWidth(text, font, size) * scale_x
size *= min(1, length * 0.94 / width) if width else 1
widths = [pdfmetrics.stringWidth(ch, font, size) * scale_x for ch in text]
gap = (length - sum(widths)) / max(len(text), 1)
cursor = gap / 2
letters = []
for ch, width in zip(text, widths):
offset = math.degrees((cursor + width / 2) / radius)
angle = start + offset if bottom else start - offset
rad = math.radians(angle)
letters.append({'text': ch, 'x': 50 + radius * math.cos(rad),
'y': 50 + radius * math.sin(rad),
'rotation': angle + 90 if bottom else angle - 90})
cursor += width + gap
return {'font': font, 'size': size, 'letters': letters,
'scale_x': scale_x, 'scale_y': scale_y}
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)
def seal_layout(company, seal_number=SEAL_NUMBER):
"""Shared geometry for the PDF seal and the inline SVG approval preview."""
register_fonts()
company = (company or COMPANY_NAME).strip()
seal_number = (seal_number or SEAL_NUMBER).strip()
star = []
for i in range(10):
radius = 9.2 if i % 2 == 0 else 9.2 * 0.382
angle = math.radians(90 + i * 36)
star.append((50 + radius * math.cos(angle), 58 + radius * math.sin(angle)))
separators = [(50 + 38 * math.cos(math.radians(a)),
50 + 38 * math.sin(math.radians(a))) for a in (205, 335)]
return {'company': company, 'number': seal_number,
'arcs': [_seal_arc(company, 32.2, 200, 220, FONT_SEAL, 10.4,
scale_x=0.8, scale_y=1.18),
_seal_arc(seal_number, 41.5, 210, 120, FONT_SEAL_NUMBER,
5.8, bottom=True, scale_x=0.9)],
'title': {'font': FONT_SEAL, 'size': 9.2, 'scale_x': 0.94, 'scale_y': 1.12},
'star': star, 'separators': separators}
class SealFlowable(Flowable):
"""红色圆形电子章:外圈 + 上缘公司名 + 五角星 + 中部用途 + 下缘审核人/日期。"""
"""公司名和印章号码环绕一圈的采购章,不含个人及日期。"""
def __init__(self, company, title, who, when, size=28 * mm):
def __init__(self, company, title, seal_number=SEAL_NUMBER, size=32 * 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.layout = seal_layout(company, seal_number)
self.title = title 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.scale(self.width / 100, self.height / 100)
c.setFillColor(SEAL_COLOR)
c.setStrokeColor(SEAL_COLOR)
c.setLineWidth(1.5)
c.circle(cx, cy, r, stroke=1, fill=0)
c.circle(50, 50, 46, 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.circle(50, 50, 44.6, stroke=1, fill=0)
for arc in self.layout['arcs']:
c.setFont(arc['font'], arc['size'])
for letter in arc['letters']:
c.saveState()
c.translate(letter['x'], letter['y'])
c.rotate(letter['rotation'])
c.scale(arc['scale_x'], arc['scale_y'])
c.drawCentredString(0, 0, letter['text'])
c.restoreState()
for x, y in self.layout['separators']:
c.circle(x, y, 0.65, stroke=0, fill=1)
star = c.beginPath()
for i, (x, y) in enumerate(self.layout['star']):
(star.moveTo if i == 0 else star.lineTo)(x, y)
star.close()
c.drawPath(star, stroke=0, fill=1)
title_style = self.layout['title']
c.setFont(title_style['font'], title_style['size'])
c.translate(50, 34)
c.scale(title_style['scale_x'], title_style['scale_y'])
c.drawCentredString(0, 0, self.title)
c.restoreState()
@@ -144,18 +171,25 @@ def register_fonts():
raise RuntimeError(u"未找到中文字体,请确认系统已安装宋体或黑体")
pdfmetrics.registerFont(TTFont(FONT_BODY, body, subfontIndex=0))
pdfmetrics.registerFont(TTFont(FONT_BOLD, bold or body, subfontIndex=0))
pdfmetrics.registerFont(TTFont(FONT_SEAL, _find_font(SEAL_CANDIDATES) or body,
subfontIndex=0))
number_font = _find_font(SEAL_NUMBER_CANDIDATES)
if number_font:
pdfmetrics.registerFont(TTFont(FONT_SEAL_NUMBER, number_font, subfontIndex=0))
else:
pdfmetrics.registerFont(pdfmetrics.Font(FONT_SEAL_NUMBER, 'Helvetica', 'WinAnsiEncoding'))
def _fmt_money(v):
try:
return "{:,.2f}".format(float(v or 0))
return "{:,.2f}".format(money(v))
except (TypeError, ValueError):
return "0.00"
def _fmt_num(v):
try:
return "{:g}".format(round(float(v or 0), 4))
return format_price(v)
except (TypeError, ValueError):
return ""
@@ -169,11 +203,11 @@ def P(text, style):
def build_pdf(order, items, out_path, watermark=None):
register_fonts()
if watermark is None:
watermark = STATUS_WATERMARK.get(order.status)
watermark = None if order.status == 'approved' else STATUS_WATERMARK.get(order.status, '未批准')
page_w, page_h = A4
margin_x = 11 * mm
doc = BaseDocTemplate(out_path, pagesize=A4,
doc = BaseDocTemplate(io.BytesIO(), 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),
@@ -224,7 +258,7 @@ def build_pdf(order, items, out_path, watermark=None):
def make_story():
story = []
story.append(P(COMPANY_NAME, styles["company"]))
story.append(P(order.our_company or 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"]))
@@ -251,7 +285,7 @@ def build_pdf(order, items, out_path, watermark=None):
]
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 = Table(head_data, colWidths=[93 * mm, 93 * mm])
head.setStyle(TableStyle([
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#333333")),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
@@ -276,16 +310,16 @@ def build_pdf(order, items, out_path, watermark=None):
headers = [u"序号", u"物料代码", u"物料规格", u"单位", u"数量", u"含税单价",
u"金额", u"交期", u"备注"]
data = [[P(h, styles["th"]) for h in headers]]
total = 0.0
total = ZERO
for idx, it in enumerate(items, 1):
total += float(it.amount or 0)
total += money(it.amount)
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(format_price(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"]),
@@ -311,16 +345,15 @@ def build_pdf(order, items, out_path, watermark=None):
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"])],
[P("", styles["cell"]), P("", styles["cell"])],
]
sum_t = Table(sum_data, colWidths=col_w, rowHeights=[7.5 * mm, 7.5 * mm])
sum_t = Table(sum_data, colWidths=col_w, rowHeights=[7.5 * mm, 7.5 * mm], splitByRow=0)
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)),
("SPAN", (2, 1), (8, 1)),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
@@ -343,35 +376,15 @@ def build_pdf(order, items, out_path, watermark=None):
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" % (
sign_line = u"制单:%s\n审批:%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""))
# One final approval produces one procurement seal. Historical archived
# PDFs are served unchanged by order_pdf and never regenerated here.
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
seal_cell = SealFlowable(order.our_company or COMPANY_NAME, u"采购专用章")
sign_tbl = Table(
[[P(sign_line, styles["sign"]), seal_cell],
[P(confirm_line, styles["sign"]), P("", styles["sign"])]],
@@ -393,5 +406,6 @@ def build_pdf(order, items, out_path, watermark=None):
doc.build(make_story())
counters["total"] = doc.page
doc.filename = out_path
doc.build(make_story())
return out_path