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

280 lines
14 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_approved_ppt(records, destination, classified):
import contextlib
import tempfile
import pypdfium2 as pdfium
if not records or len(records) > 100:
raise ValueError('每批请选择 1 至 100 笔已批准申请')
categories = {'交通差旅': '交通', '住宿费用': '住宿'}
with tempfile.TemporaryDirectory(prefix='approved-ppt-pages-', dir=Path(destination).parent) as directory:
matches = []
for record in records:
match = dict(invoices=[], payments=[], category=categories.get(record['type'], record['type']))
match['scope'] = (record.get('groupId', record['team']), record.get('userId', record['name']))
for material in record['materials']:
if material['kind'] not in ('invoice', 'payment'):
continue
path = Path(material['path'])
try:
if path.suffix.lower() == '.pdf':
with contextlib.closing(pdfium.PdfDocument(str(path))) as document:
if not len(document):
raise ValueError('PDF 没有可预览页面')
with contextlib.closing(document[0]) as page:
with contextlib.closing(page.render(scale=140 / 72)) as bitmap:
preview = Path(directory) / f'{len(matches)}-{len(match["invoices"])}-{len(match["payments"])}.png'
bitmap.to_pil().convert('RGB').save(preview)
path = preview
else:
with Image.open(path) as image:
image.verify()
except Exception as error:
raise ValueError(f"申请 #{record['id']} 的“{material['name']}”无法导出:{error}") from error
item = dict(path=str(path), ocr=dict(rawText='', dates=[record['date']]))
match['invoices' if material['kind'] == 'invoice' else 'payments'].append(item)
if not match['invoices'] and not match['payments']:
raise ValueError(f"申请 #{record['id']} 没有发票或付款截图,无法按本地贴票格式导出,请取消选择该申请")
matches.append(match)
export_ppt(dict(matches=matches), destination, classified, preserve_order=True)
def export_ppt(state, destination, classified, preserve_order=False):
if 'approvedRecords' in state:
return export_approved_ppt(state['approvedRecords'], 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 = 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)))
matches = state['matches'] if preserve_order else 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 batch and batch[-1].get('scope') != match.get('scope'):
simple_batch(batch)
batch = []
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)