Files
reimburse/native-engine/exports.py
T
2026-09-13 15:20:40 +08:00

315 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import copy
import io
import math
import posixpath
import zipfile
from decimal import Decimal
from pathlib import Path
from xml.etree import ElementTree as XML
from PIL import Image
from domain import material_date, text
DEFAULT_SIGNATURES = ['部门长', '剧组出纳', '制片主任', '剧组会计', '执行制片人', '制片人']
def image_for(item):
return Image.open(item.get('previewPath') or item['path']).convert('RGB')
def export_ppt(state, destination, classified):
from pptx import Presentation
from pptx.util import Pt
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN
presentation = Presentation()
presentation.slide_width, presentation.slide_height = Pt(1280), Pt(720)
def slide():
return presentation.slides.add_slide(presentation.slide_layouts[6])
def picture(page, item, box):
image = image_for(item)
left, top, width, height = box
scale = min(width / image.width, height / image.height)
actual_width, actual_height = int(image.width * scale), int(image.height * scale)
content = io.BytesIO()
image.save(content, format='PNG')
content.seek(0)
page.shapes.add_picture(content, Pt(left + (width - actual_width) // 2), Pt(top + (height - actual_height) // 2), Pt(actual_width), Pt(actual_height))
def payment_page(payments):
page = slide()
height = 660 if 'approvedRecords' in state else 680
if len(payments) == 1:
picture(page, payments[0], (300, 20, 680, height))
else:
width = (1200 - 24 * (len(payments) - 1)) // len(payments)
for index, item in enumerate(payments):
picture(page, item, (40 + index * (width + 24), 20, width, height))
def label(page, box, value, size, title=False):
shape = page.shapes.add_shape(MSO_SHAPE.RECTANGLE, *(Pt(value) for value in box))
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor(245, 248, 247) if title else RGBColor(255, 255, 255)
shape.line.color.rgb = RGBColor(216, 226, 222) if title else RGBColor(170, 188, 182)
shape.line.width = Pt(1 if title else 1.5)
shape.text = value
for paragraph in shape.text_frame.paragraphs:
paragraph.alignment = PP_ALIGN.CENTER
for run in paragraph.runs:
run.font.name = 'PingFang SC'
run.font.size = Pt(size)
run.font.color.rgb = RGBColor(39, 91, 78) if title else RGBColor(112, 132, 126)
def photos(count):
for offset in range(0, count, 4):
slots = min(4, count - offset)
page = slide()
label(page, (55, 28, 1170, 52), f'实物照片粘贴区(请手动粘贴) {offset + 1}-{min(count, offset + 4)} / {count}', 18, True)
for index in range(slots):
box = (250, 105, 780, 555) if slots == 1 else (55 + index * 610, 105, 560, 555) if slots == 2 else (55 + index % 2 * 610, 105 + index // 2 * 285, 560, 255)
label(page, box, f'实物照片 {offset + index + 1}\n请在此处粘贴', 16)
def requires_photo(match):
return match['category'].strip() not in ['交通', '住宿']
def simple_batch(batch):
if not batch:
return
for match in batch:
picture(slide(), match['invoices'][0], (40, 25, 1200, 670))
payment_page([match['payments'][0] for match in batch])
photos(sum(len(match['payments']) for match in batch if requires_photo(match)))
if 'approvedRecords' in state:
import contextlib
import tempfile
import pypdfium2 as pdfium
from pptx.enum.text import MSO_AUTO_SIZE
records = state['approvedRecords']
if not records or len(records) > 100:
raise ValueError('每批请选择 1 至 100 笔已批准申请')
with tempfile.TemporaryDirectory(prefix='approved-ppt-pages-', dir=Path(destination).parent) as directory:
page_count = 0
for record in records:
summary = slide()
label(summary, (55, 28, 1170, 64), '已批准报销 · #' + record['id'], 26, True)
details = [record['projectName'] + ' / ' + record['team'],
record['name'] + ' · ' + record['type'],
'提交日期:' + record['date'] + ' 报销金额:¥' + record['amountText']]
for index, detail in enumerate(details):
label(summary, (55, 120 + index * 70, 1170, 60), detail, 22)
note = record.get('note', '').strip()
for offset in range(0, len(note), 240):
note_page = summary if offset == 0 else slide()
label(note_page, (55, 360 if offset == 0 else 100, 1170, 300),
('备注:' if offset == 0 else '备注(续):') + note[offset:offset + 240], 18)
groups = {}
kind_names = {}
for material in record['materials']:
kind_names[material['kind']] = material.get('kindName', '其他材料')
path = Path(material['path'])
images = []
try:
if path.suffix.lower() == '.pdf':
with contextlib.closing(pdfium.PdfDocument(str(path))) as document:
if not 0 < len(document) <= 100:
raise ValueError('单份 PDF 需为 1100 页,请拆分后导出')
for page_index in range(len(document)):
with contextlib.closing(document[page_index]) as pdf_page:
scale = min(140 / 72, 2600 / max(pdf_page.get_size()))
with contextlib.closing(pdf_page.render(scale=scale)) as bitmap:
preview = Path(directory) / f'{page_count}.png'
bitmap.to_pil().convert('RGB').save(preview)
images.append({'path': str(preview)})
page_count += 1
else:
with Image.open(path) as image:
image.verify()
images.append(material)
page_count += 1
except Exception as error:
raise ValueError(f"申请 #{record['id']} 的“{material['name']}”无法导出:{error}") from error
if page_count > 1000:
raise ValueError('材料展开后超过 1,000 页,请减少选择并分批导出')
groups.setdefault(material['kind'], []).extend(images)
if not groups:
label(summary, (55, 660, 1170, 36), '该申请没有上传附件', 16)
for item in groups.pop('invoice', []):
picture(slide(), item, (40, 25, 1200, 670))
payments = groups.pop('payment', [])
for offset in range(0, len(payments), 4):
payment_page(payments[offset:offset + 4])
for kind, materials in sorted(groups.items()):
for offset in range(0, len(materials), 4):
page = slide()
title = '实拍照片' if kind in ('receipt', 'photo') else kind_names[kind]
label(page, (55, 20, 1170, 50), title, 20, True)
batch = materials[offset:offset + 4]
for index, item in enumerate(batch):
box = (250, 90, 780, 570) if len(batch) == 1 else (55 + index % 2 * 610, 90 + index // 2 * 290, 560, 270)
picture(page, item, box)
start = list(presentation.slides).index(summary)
for page in list(presentation.slides)[start + 1:]:
label(page, (40, 696, 1200, 22),
f"{record['projectName']} / {record['team']} · {record['name']} · {record['date']} · #{record['id']}", 10, True)
for page in presentation.slides:
for shape in page.shapes:
if shape.has_text_frame:
shape.text_frame.word_wrap = True
shape.text_frame.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
presentation.save(destination)
return
matches = sorted(state['matches'], key=lambda match: (min(material_date(item, True) for item in match['invoices']), match['category'] if classified else ''))
batch = []
for match in matches:
if len(match['invoices']) != 1 or len(match['payments']) != 1:
simple_batch(batch)
batch = []
for item in sorted(match['invoices'], key=material_date):
picture(slide(), item, (40, 25, 1200, 670))
payments = sorted(match['payments'], key=material_date)
for index in range(0, len(payments), 4):
payment_page(payments[index:index + 4])
if requires_photo(match):
photos(len(payments))
else:
batch.append(match)
if len(batch) == 2:
simple_batch(batch)
batch = []
simple_batch(batch)
presentation.save(destination)
def export_travel(state, destination):
import mimetypes
mimetypes.knownfiles = []
mimetypes.init()
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter
invoices = [item for match in state['matches'] for item in match['invoices'] if item['ocr']['travel']['type']]
if not invoices:
raise ValueError('已核对材料中没有识别到火车票或航班行程')
workbook = Workbook()
sheet = workbook.active
sheet.title = '火车及航班行程'
sheet.append(['序号', '交通类型', '姓名', '出发地', '目的地', '出发时间', '车次/航班号', '金额', '发票文件'])
for cell in sheet[1]:
cell.font = Font(bold=True, color='FFFFFF')
cell.fill = PatternFill('solid', fgColor='006633')
cell.alignment = Alignment(horizontal='center')
for index, item in enumerate(invoices, 1):
travel = item['ocr']['travel']
values = [index, '火车' if travel['type'] == 'train' else '航班', travel['travelerName'], travel['departure'], travel['destination'], travel['departureTime'], travel['transportNumber'], next(iter(item['ocr']['amounts']), ''), item['name']]
sheet.append(values)
for cell in sheet[sheet.max_row]:
if isinstance(cell.value, str):
cell.data_type = 's'
for index, width in enumerate([8, 12, 16, 18, 18, 22, 18, 14, 38], 1):
sheet.column_dimensions[get_column_letter(index)].width = width
sheet.freeze_panes = 'A2'
workbook.save(destination)
def export_expense(state, destination, template, purposes, signatures):
from lxml import etree as ET
namespace = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
relationships = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
package = 'http://schemas.openxmlformats.org/package/2006/relationships'
content_namespace = 'http://schemas.openxmlformats.org/package/2006/content-types'
tag = lambda name: '{' + namespace + '}' + name
with zipfile.ZipFile(template) as source:
parts = {name: source.read(name) for name in source.namelist()}
workbook = ET.fromstring(parts['xl/workbook.xml'])
relations = ET.fromstring(parts['xl/_rels/workbook.xml.rels'])
content_types = ET.fromstring(parts['[Content_Types].xml'])
sheets = workbook.find(tag('sheets'))
original = next(sheet for sheet in sheets if sheet.get('name') == '1个人报销单')
original_index = list(sheets).index(original)
relation = next(item for item in relations if item.get('Id') == original.get('{' + relationships + '}id'))
sheet_path = posixpath.normpath(posixpath.join('xl', relation.get('Target'))) if not relation.get('Target').startswith('/') else relation.get('Target').lstrip('/')
sheet_bytes = parts[sheet_path]
names = workbook.find(tag('definedNames'))
retained_names = [copy.deepcopy(item) for item in names if item.get('localSheetId') == str(original_index)] if names is not None else []
if names is not None:
names.clear()
sheets.clear()
signatures = signatures or DEFAULT_SIGNATURES
page_count = math.ceil(len(state['matches']) / 13)
def set_cell(document, reference, value):
cell = document.find('.//' + tag('c') + '[@r="' + reference + '"]')
if cell is None:
row_number = ''.join(character for character in reference if character.isdigit())
row = document.find('.//' + tag('row') + '[@r="' + row_number + '"]')
if row is None:
raise ValueError('报销模板缺少明细行:' + row_number)
cell = ET.SubElement(row, tag('c'), r=reference)
for child in list(cell):
cell.remove(child)
if isinstance(value, (int, float, Decimal)):
cell.set('t', 'n')
ET.SubElement(cell, tag('v')).text = str(value)
else:
cell.set('t', 'inlineStr')
inline = ET.SubElement(cell, tag('is'))
ET.SubElement(inline, tag('t')).text = str(value)
for page in range(page_count):
document = ET.fromstring(sheet_bytes)
for row_number in range(9, 22):
for column in ['B', 'C', 'G', 'H', 'I']:
set_cell(document, column + str(row_number), '')
set_cell(document, 'A' + str(row_number), row_number - 8)
for index, match in enumerate(state['matches'][page * 13:(page + 1) * 13]):
invoice_type = '普票'
for invoice in match['invoices']:
content = text(invoice)
if '专用发票' in content:
invoice_type = '专票'
break
if 'invoice' in content.lower():
invoice_type = 'Invoice'
break
if '押金' in content and '收据' in content:
invoice_type = '押金收据'
break
total = sum(max((abs(Decimal(value.replace(',', ''))) for value in item['ocr']['amounts']), default=Decimal(0)) for item in match['invoices'])
for column, value in [('B', invoice_type), ('C', purposes.get(match['id'], '').strip()), ('G', len(match['invoices'])), ('H', total)]:
set_cell(document, column + str(index + 9), value)
for index, cell in enumerate(['A30', 'D30', 'A31', 'D31', 'A32', 'D32']):
value = signatures[index].strip() if index < len(signatures) else ''
set_cell(document, cell, value + '' if value else '')
name = '个人报销单' if page_count == 1 else f'个人报销单-{page + 1}'
output_path = f'xl/worksheets/native-expense-{page + 1}.xml'
relation_id = f'rIdNativeExpense{page + 1}'
ET.SubElement(sheets, tag('sheet'), name=name, sheetId=str(page + 1), attrib={'{' + relationships + '}id': relation_id})
ET.SubElement(relations, '{' + package + '}Relationship', Id=relation_id, Type=relationships + '/worksheet', Target=output_path[3:])
ET.SubElement(content_types, '{' + content_namespace + '}Override', PartName='/' + output_path, ContentType='application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml')
parts[output_path] = ET.tostring(document, xml_declaration=True, encoding='UTF-8', standalone=True)
rel_path = posixpath.dirname(sheet_path) + '/_rels/' + posixpath.basename(sheet_path) + '.rels'
if rel_path in parts:
parts['xl/worksheets/_rels/' + posixpath.basename(output_path) + '.rels'] = parts[rel_path]
for definition in retained_names:
cloned = copy.deepcopy(definition)
cloned.set('localSheetId', str(page))
cloned.text = (cloned.text or '').replace("'1个人报销单'!", "'" + name + "'!").replace('1个人报销单!', "'" + name + "'!")
names.append(cloned)
calc = workbook.find(tag('calcPr'))
if calc is None:
calc = ET.SubElement(workbook, tag('calcPr'))
calc.set('fullCalcOnLoad', '1')
calc.set('forceFullCalc', '1')
for key, document in [('xl/workbook.xml', workbook), ('xl/_rels/workbook.xml.rels', relations), ('[Content_Types].xml', content_types)]:
parts[key] = ET.tostring(document, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as output:
for name, content in parts.items():
output.writestr(name, content)