Lokalen Rechnungsassistenten mit Vorlagen und PDF-Vorschau ergänzen
This commit is contained in:
1 parent
f447603af4
commit
b9f307fc81
15 files changed
+1423
-26
No files matched your search
@@ -0,0 +1,224 @@
|
||||
"""Vorlagen, GUI-Zustände und optional die vollständige echte Rechnung prüfen."""
|
||||
import calendar
|
||||
from datetime import date
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import yaml
|
||||
|
||||
from jr_einvoice.calc import compute_totals
|
||||
from jr_einvoice.create import create_invoice, save_customer
|
||||
from jr_einvoice.models import Invoice
|
||||
|
||||
# Beim Rollback ältere Laufzeiten mit ihrer tatsächlich verfügbaren Funktion prüfen.
|
||||
HAS_GUI_DATA = importlib.util.find_spec('jr_einvoice.gui_data') is not None
|
||||
if HAS_GUI_DATA:
|
||||
from jr_einvoice.gui_data import InvoiceForm, InvoiceTemplate, load_template, save_template
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SAMPLE = Invoice.model_validate(yaml.safe_load((ROOT / 'examples/invoice.sample.yaml').read_text()))
|
||||
HAS_QT = importlib.util.find_spec('PySide6') is not None
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_GUI_DATA, 'Diese Laufzeit enthält noch keinen Desktop-Assistenten')
|
||||
class TemplateTest(unittest.TestCase):
|
||||
def test_roundtrip_excludes_old_number_dates_and_bank_details(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
template = load_template(ROOT / 'examples/invoice.sample.yaml')
|
||||
path = save_template(root / 'templates', 'IT-Beratung', template)
|
||||
self.assertEqual(load_template(path), template)
|
||||
data = yaml.safe_load(path.read_text())
|
||||
for excluded in ('meta', 'number', 'issued', 'start', 'end', 'seller', 'payment'):
|
||||
self.assertNotIn(excluded, data)
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700)
|
||||
with self.assertRaises(FileExistsError):
|
||||
save_template(path.parent, 'IT-Beratung', template)
|
||||
save_template(path.parent, 'IT-Beratung', template, overwrite=True)
|
||||
for name in ('../escape', '/tmp/escape', '', 'a/b', '..', 'trailing.'):
|
||||
with self.assertRaises(ValueError):
|
||||
save_template(path.parent, name, template)
|
||||
|
||||
def test_invalid_template_version_and_position_rejected(self):
|
||||
template = load_template(ROOT / 'examples/invoice.sample.yaml')
|
||||
for changes in ({'schema_version': 2}, {'items': []}, {'old_number': '2020-1234'}):
|
||||
with self.assertRaises(ValueError):
|
||||
InvoiceTemplate.model_validate(template.model_dump() | changes)
|
||||
|
||||
def test_multiple_items_custom_period_and_notes_use_shared_core(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
presets = root / 'presets'
|
||||
presets.mkdir()
|
||||
(presets / 'seller.default.yaml').write_text(yaml.safe_dump({
|
||||
'seller': SAMPLE.seller.model_dump(mode='json'), 'payment': SAMPLE.payment.model_dump(mode='json')}))
|
||||
save_customer(presets, SAMPLE.buyer.name, SAMPLE.buyer.model_dump(mode='json'))
|
||||
items = SAMPLE.items
|
||||
form = InvoiceForm(buyer=SAMPLE.buyer, items=items, start=date(2026, 9, 10),
|
||||
end=date(2026, 9, 20), notes='Individuelle Hinweise', issued=date(2026, 10, 19))
|
||||
invoice = form.invoice(presets)
|
||||
self.assertEqual(compute_totals(invoice).gross_total, compute_totals(SAMPLE).gross_total)
|
||||
jar = root / 'mustang.jar'
|
||||
jar.touch()
|
||||
# Abbruch genau am Übergang zur PDF-Erzeugung: gespeicherte Daten prüfen.
|
||||
with patch('jr_einvoice.create.build_in_folder', side_effect=RuntimeError('Testabbruch')):
|
||||
with self.assertRaises(RuntimeError):
|
||||
create_invoice(root=root / 'invoices', resources=ROOT, presets=presets,
|
||||
customer=SAMPLE.buyer.name, entry='Oktober 2026', volume=1, price=1,
|
||||
items=items, notes=form.notes, jar=jar, logger=logging.getLogger('test'),
|
||||
service_period_start=form.start, service_period_end=form.end)
|
||||
saved = Invoice.model_validate(yaml.safe_load(next((root / 'invoices').glob('*/invoice.yaml')).read_text()))
|
||||
self.assertEqual(saved.items, items)
|
||||
self.assertEqual(saved.meta.service_period_start, form.start)
|
||||
self.assertEqual(saved.meta.service_period_end, form.end)
|
||||
self.assertEqual(saved.meta.notes, form.notes)
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_QT and HAS_GUI_DATA, 'Diese Laufzeit enthält keine installierte Desktop-Oberfläche')
|
||||
class GuiTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
|
||||
from PySide6.QtWidgets import QApplication
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
cls.app.setStyle('Fusion')
|
||||
|
||||
def setUp(self):
|
||||
from jr_einvoice.gui import InvoiceWindow
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.home = Path(self.tmp.name)
|
||||
self.env = patch.dict(os.environ, {'JR_EINVOICE_HOME': str(self.home)})
|
||||
self.env.start()
|
||||
self.addCleanup(self.env.stop)
|
||||
presets = self.home / 'presets'
|
||||
presets.mkdir()
|
||||
(presets / 'seller.default.yaml').write_text(yaml.safe_dump({
|
||||
'seller': SAMPLE.seller.model_dump(mode='json'), 'payment': SAMPLE.payment.model_dump(mode='json')}))
|
||||
self.jar = Path(os.environ.get('JR_EINVOICE_MUSTANG_JAR', ROOT / 'tools/mustang/Mustang-CLI.jar'))
|
||||
self.window = InvoiceWindow(root=self.home / 'invoices', presets=presets, jar=self.jar)
|
||||
self.addCleanup(self.cleanup_window)
|
||||
self.window.confirm_replace = lambda: True
|
||||
self.errors = []
|
||||
self.window.error = self.errors.append
|
||||
|
||||
def cleanup_window(self):
|
||||
self.wait_worker()
|
||||
self.window.dirty = False
|
||||
self.window.close()
|
||||
self.window.deleteLater()
|
||||
self.app.processEvents()
|
||||
|
||||
def wait_worker(self):
|
||||
from PySide6.QtTest import QTest
|
||||
deadline = time.monotonic() + 150
|
||||
while self.window.worker is not None and time.monotonic() < deadline:
|
||||
self.app.processEvents()
|
||||
QTest.qWait(20)
|
||||
self.assertIsNone(self.window.worker, 'Hintergrundauftrag nicht rechtzeitig abgeschlossen')
|
||||
self.app.processEvents()
|
||||
|
||||
def load_sample(self):
|
||||
self.window.apply_template(ROOT / 'examples/invoice.sample.yaml')
|
||||
self.assertFalse(self.errors)
|
||||
|
||||
def test_load_earlier_invoice_resets_dates_but_preserves_details(self):
|
||||
self.load_sample()
|
||||
form = self.window.form()
|
||||
self.assertEqual(form.issued, date.today())
|
||||
self.assertEqual(form.start, date.today().replace(day=1))
|
||||
self.assertEqual(form.end.day, calendar.monthrange(date.today().year, date.today().month)[1])
|
||||
self.assertIsNone(form.number)
|
||||
self.assertEqual(form.buyer, SAMPLE.buyer)
|
||||
self.assertEqual(form.items, SAMPLE.items)
|
||||
self.window.positions[0].quantity.setText('7,5')
|
||||
self.window.positions[0].price.setText('1.234,56 €')
|
||||
self.assertEqual(str(self.window.form().items[0].unit_price_net), '1234.56')
|
||||
self.assertIn('Brutto', self.window.totals.text())
|
||||
|
||||
def test_invalid_fields_do_not_save_customer_or_reserve_number(self):
|
||||
self.load_sample()
|
||||
self.window.positions[0].quantity.setText('-1')
|
||||
self.window.generate()
|
||||
self.assertTrue(self.errors)
|
||||
self.assertIsNone(self.window.worker)
|
||||
self.assertFalse(self.window.root.exists())
|
||||
self.assertEqual(list((self.window.presets / 'customers').glob('*.yaml')), [])
|
||||
|
||||
def test_existing_customer_cannot_be_silently_overwritten(self):
|
||||
save_customer(self.window.presets, SAMPLE.buyer.name,
|
||||
SAMPLE.buyer.model_dump(mode='json') | {'email': 'andere@example.invalid'})
|
||||
self.load_sample()
|
||||
self.window.generate()
|
||||
self.assertIn('anderen Stammdaten', self.errors[0])
|
||||
self.assertIsNone(self.window.worker)
|
||||
self.assertFalse(self.window.root.exists())
|
||||
|
||||
def test_preview_creates_no_customer_or_invoice_number(self):
|
||||
self.load_sample()
|
||||
self.window.preview()
|
||||
self.wait_worker()
|
||||
self.assertFalse(self.errors)
|
||||
self.assertGreater(self.window.document.pageCount(), 0)
|
||||
self.assertIn('ENTWURF', self.window.preview_state.text())
|
||||
self.assertFalse(self.window.root.exists())
|
||||
self.assertEqual(list((self.window.presets / 'customers').glob('*.yaml')), [])
|
||||
self.assertFalse(self.window.open_pdf.isEnabled())
|
||||
|
||||
def test_background_failure_restores_editing_without_success(self):
|
||||
self.load_sample()
|
||||
self.window.jar = self.home / 'kaputte-mustang.jar'
|
||||
self.window.jar.write_text('Keine gültige JAR')
|
||||
self.window.generate()
|
||||
self.wait_worker()
|
||||
self.assertEqual(len(self.errors), 1)
|
||||
self.assertIn('Erstellung fehlgeschlagen', self.errors[0])
|
||||
self.assertTrue(self.window.editor.isEnabled())
|
||||
self.assertTrue(self.window.generate_button.isEnabled())
|
||||
self.assertFalse(self.window.open_pdf.isEnabled())
|
||||
self.assertIsNone(self.window.result)
|
||||
|
||||
def test_worker_start_failure_restores_form(self):
|
||||
self.load_sample()
|
||||
with patch('jr_einvoice.gui.sys.executable', '/nonexistent-jr-einvoice-python'):
|
||||
self.window.preview()
|
||||
self.wait_worker()
|
||||
self.assertEqual(len(self.errors), 1)
|
||||
self.assertIn('Hintergrundprozess fehlgeschlagen', self.errors[0])
|
||||
self.assertTrue(self.window.editor.isEnabled())
|
||||
self.assertTrue(self.window.preview_button.isEnabled())
|
||||
|
||||
@unittest.skipUnless(os.environ.get('JR_EINVOICE_INTEGRATION') == '1', 'Echte Mustang-Prüfung nur nach Opt-in')
|
||||
def test_real_click_to_validated_pdf_preview_and_new_invoice(self):
|
||||
self.load_sample()
|
||||
self.window.generate_button.click()
|
||||
self.assertFalse(self.window.editor.isEnabled())
|
||||
self.wait_worker()
|
||||
self.assertFalse(self.errors)
|
||||
result = self.window.result
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn('status="valid"', result.build.validate_report.read_text())
|
||||
self.assertGreater(self.window.document.pageCount(), 0)
|
||||
self.assertEqual(result.attachment.read_bytes(), result.build.facturx_pdf.read_bytes())
|
||||
self.assertFalse(self.window.generate_button.isEnabled())
|
||||
self.assertFalse(self.window.preview_button.isEnabled())
|
||||
self.assertTrue(self.window.open_pdf.isEnabled())
|
||||
# Doppelaufruf desselben Formulars legt keine zusätzliche Nummer an.
|
||||
self.window.generate()
|
||||
self.assertIsNone(self.window.worker)
|
||||
self.assertEqual(len(list(self.window.root.glob('*/invoice.yaml'))), 1)
|
||||
self.window.notes.setPlainText('Geänderte Hinweise')
|
||||
self.assertIn('vorherigen Stand', self.window.preview_state.text())
|
||||
self.assertFalse(self.window.open_pdf.isEnabled())
|
||||
self.window.new_invoice()
|
||||
self.assertIsNone(self.window.result)
|
||||
self.assertEqual(len(self.window.positions), 1)
|
||||
self.assertEqual(self.window.document.pageCount(), 0)
|
||||
self.assertFalse(self.window.dirty)
|
||||
Reference in new issue
Block a user