完善采购审批流程、企业微信通知和采购专用章
This commit is contained in:
+107
-93
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user