Files

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-v5"
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 "&nbsp;", 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=20, leading=26,
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