441 lines
19 KiB
Python
441 lines
19 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""按原 Excel 模板版式生成采购单 / 委外生产订单 PDF。"""
|
|
import math
|
|
import os
|
|
import io
|
|
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 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"
|
|
|
|
# 延续两种模板比例;委外单将过宽的单位列让给规格列。
|
|
COL_RATIO = {
|
|
"purchase": [1184, 3104, 6528, 3232, 2400, 2976, 2400, 3712, 1728],
|
|
"outsourcing": [1184, 5440, 7264, 1536, 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"已驳回 · 未生效",
|
|
"voided": u"已作废 · 禁止使用",
|
|
}
|
|
|
|
|
|
SEAL_COLOR = colors.HexColor("#C8102E")
|
|
SEAL_NUMBER = "3205070994140"
|
|
PDF_TEMPLATE_VERSION = "po-v4"
|
|
PDF_SEAL_SIZE = 32 * 1.3 * mm
|
|
|
|
|
|
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 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, seal_number=SEAL_NUMBER, size=PDF_SEAL_SIZE):
|
|
Flowable.__init__(self)
|
|
self.layout = seal_layout(company, seal_number)
|
|
self.title = title or u"采购专用章"
|
|
self.width = size
|
|
self.height = size
|
|
|
|
def draw(self):
|
|
c = self.canv
|
|
c.saveState()
|
|
c.scale(self.width / 100, self.height / 100)
|
|
c.setFillColor(SEAL_COLOR)
|
|
c.setStrokeColor(SEAL_COLOR)
|
|
c.setLineWidth(1.5)
|
|
c.circle(50, 50, 46, stroke=1, fill=0)
|
|
c.setLineWidth(0.5)
|
|
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()
|
|
|
|
|
|
class ConfirmationFlowable(Flowable):
|
|
"""Keep each party's label and signing space in the same fixed-size cell."""
|
|
|
|
def __init__(self, party, width, seal=None):
|
|
Flowable.__init__(self)
|
|
self.label = u"%s确认(盖章):" % party
|
|
self.width = width
|
|
self.height = PDF_SEAL_SIZE + 4 * mm
|
|
self.seal = seal
|
|
|
|
def draw(self):
|
|
c = self.canv
|
|
c.saveState()
|
|
font_size = 9.5
|
|
center_y = self.height / 2
|
|
baseline = center_y - font_size * 0.35
|
|
c.setFont(FONT_BODY, font_size)
|
|
c.setFillColor(colors.black)
|
|
c.drawString(0, baseline, self.label)
|
|
signing_start = pdfmetrics.stringWidth(self.label, FONT_BODY, font_size) + 2 * mm
|
|
signing_end = self.width - 1 * mm
|
|
if self.seal:
|
|
# Center the seal in the buyer's signing space, next to its label.
|
|
self.seal.drawOn(c, (signing_start + signing_end - self.seal.width) / 2,
|
|
center_y - self.seal.height / 2)
|
|
else:
|
|
c.setStrokeColor(colors.HexColor("#888888"))
|
|
c.setLineWidth(0.4)
|
|
c.line(signing_start, baseline - 1.5, signing_end, baseline - 1.5)
|
|
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))
|
|
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(money(v))
|
|
except (TypeError, ValueError):
|
|
return "0.00"
|
|
|
|
|
|
def _fmt_num(v):
|
|
try:
|
|
return format_price(v)
|
|
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 " ", style)
|
|
|
|
|
|
def build_pdf(order, items, out_path, watermark=None):
|
|
register_fonts()
|
|
if watermark is None:
|
|
watermark = None if order.status == 'approved' else STATUS_WATERMARK.get(order.status, '未批准')
|
|
|
|
page_w, page_h = A4
|
|
margin_x = 11 * mm
|
|
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),
|
|
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(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"]))
|
|
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])
|
|
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 = ZERO
|
|
for idx, it in enumerate(items, 1):
|
|
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(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"]),
|
|
])
|
|
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("", styles["cell"]), P("", styles["cell"])],
|
|
]
|
|
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), (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\n审批:%s" % (
|
|
stamp(order.created_by_name, order.created_at),
|
|
stamp(order.approved_by, order.approved_at))
|
|
# One final approval produces one procurement seal. Historical archived
|
|
# PDFs are served unchanged by order_pdf and never regenerated here.
|
|
seal = (SealFlowable(order.our_company or COMPANY_NAME, u"采购专用章")
|
|
if order.approved_by else None)
|
|
party_width = 83 * mm
|
|
confirmations = Table(
|
|
[[ConfirmationFlowable(u"供方", party_width), "",
|
|
ConfirmationFlowable(u"需方", party_width, seal)]],
|
|
colWidths=[party_width, 20 * mm, party_width])
|
|
confirmations.setStyle(TableStyle([
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("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([P(sign_line, styles["sign"]),
|
|
Spacer(1, 3 * mm), confirmations]))
|
|
return story
|
|
|
|
doc.build(make_story())
|
|
counters["total"] = doc.page
|
|
doc.filename = out_path
|
|
doc.build(make_story())
|
|
return out_path
|