This commit is contained in:
Mateusz Gruszczyński
2026-03-13 11:03:13 +01:00
commit 35571df778
132 changed files with 11197 additions and 0 deletions

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,19 @@
from flask import request
from flask_login import current_user
from app.extensions import db
from app.models.audit_log import AuditLog
class AuditService:
def log(self, action, target_type, target_id=None, details=''):
entry = AuditLog(
user_id=current_user.id if getattr(current_user, 'is_authenticated', False) else None,
action=action,
target_type=target_type,
target_id=target_id,
remote_addr=request.remote_addr if request else None,
details=details,
)
db.session.add(entry)
db.session.commit()
return entry

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import shutil
from flask import current_app
class BackupService:
def create_backup(self):
target = Path(current_app.config['BACKUP_PATH']) / f'backup_{datetime.utcnow().strftime("%Y%m%d_%H%M%S")}'
target.mkdir(parents=True, exist_ok=True)
base_dir = Path(current_app.root_path).parent
for part in ['instance', 'storage/archive', 'storage/pdf']:
src = base_dir / part
if src.exists():
shutil.copytree(src, target / Path(part).name, dirs_exist_ok=True)
archive = shutil.make_archive(str(target), 'zip', root_dir=target)
return archive
def get_database_backup_meta(self) -> dict:
uri = current_app.config.get('SQLALCHEMY_DATABASE_URI', '')
backup_dir = Path(current_app.config['BACKUP_PATH'])
engine = 'unknown'
if '://' in uri:
engine = uri.split('://', 1)[0]
sqlite_supported = uri.startswith('sqlite:///') and not uri.endswith(':memory:')
sqlite_path = None
sqlite_exists = False
if sqlite_supported:
sqlite_path = Path(uri.replace('sqlite:///', '', 1))
sqlite_exists = sqlite_path.exists()
return {
'engine': engine,
'backup_dir': str(backup_dir),
'sqlite_supported': sqlite_supported,
'sqlite_path': str(sqlite_path) if sqlite_path else None,
'sqlite_exists': sqlite_exists,
'notes': [
'Kopia z panelu działa plikowo dla SQLite.',
'Dla PostgreSQL, MySQL i innych silników wymagany jest natywny dump bazy poza aplikacją.',
],
}
def create_database_backup(self) -> str:
target_dir = Path(current_app.config['BACKUP_PATH'])
target_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
uri = current_app.config.get('SQLALCHEMY_DATABASE_URI', '')
if uri.startswith('sqlite:///') and not uri.endswith(':memory:'):
source = Path(uri.replace('sqlite:///', '', 1))
if not source.exists():
raise FileNotFoundError(f'Plik bazy nie istnieje: {source}')
target = target_dir / f'db_backup_{timestamp}.sqlite3'
shutil.copy2(source, target)
return str(target)
target = target_dir / f'db_backup_{timestamp}.txt'
target.write_text(
"""Automatyczna kopia DB dla bieżącego silnika nie jest obsługiwana plikowo.
W panelu admina kopia działa bezpośrednio tylko dla SQLite.
Wykonaj backup natywnym narzędziem bazy danych.
""",
encoding='utf-8',
)
return str(target)

View File

@@ -0,0 +1,334 @@
from __future__ import annotations
import json
import re
from typing import Any
import requests
from app.models.setting import AppSetting
class CeidgService:
DEFAULT_TIMEOUT = 12
API_URLS = {
'production': 'https://dane.biznes.gov.pl/api/ceidg/v3/firmy',
'test': 'https://test-dane.biznes.gov.pl/api/ceidg/v3/firmy',
}
@staticmethod
def _digits(value: str | None) -> str:
return re.sub(r'\D+', '', value or '')
@staticmethod
def _clean_text(value: str | None) -> str:
if value is None:
return ''
value = re.sub(r'\s+', ' ', str(value))
return value.strip(' ,;:-')
@staticmethod
def _normalize_empty(value: Any) -> Any:
if value is None:
return ''
if isinstance(value, dict):
return {key: CeidgService._normalize_empty(item) for key, item in value.items()}
if isinstance(value, list):
return [CeidgService._normalize_empty(item) for item in value]
return value
@classmethod
def get_environment(cls) -> str:
environment = AppSetting.get('ceidg.environment', 'production')
return environment if environment in cls.API_URLS else 'production'
@classmethod
def get_api_url(cls, environment: str | None = None) -> str:
env = environment or cls.get_environment()
return cls.API_URLS.get(env, cls.API_URLS['production'])
@staticmethod
def get_api_key() -> str:
return (AppSetting.get('ceidg.api_key', '', decrypt=True) or '').strip()
@classmethod
def has_api_key(cls) -> bool:
return bool(cls.get_api_key())
@classmethod
def _headers(cls) -> dict[str, str]:
headers = {'Accept': 'application/json', 'User-Agent': 'KSeF Manager/1.0'}
api_key = cls.get_api_key()
if api_key:
token = api_key.strip()
headers['Authorization'] = token if token.lower().startswith('bearer ') else f'Bearer {token}'
return headers
@staticmethod
def _safe_payload(response: requests.Response):
try:
return CeidgService._normalize_empty(response.json())
except Exception:
return response.text[:1000]
def fetch_company(self, identifier: str | None = None, *, nip: str | None = None) -> dict:
nip = self._digits(nip or identifier)
if len(nip) != 10:
return {'ok': False, 'message': 'Podaj poprawny 10-cyfrowy NIP.'}
if not self.has_api_key():
return {
'ok': False,
'message': 'Brak API KEY do CEIDG. Uzupełnij klucz w panelu admina.',
'fallback': {'tax_id': nip},
}
environment = self.get_environment()
api_url = self.get_api_url(environment)
try:
response = requests.get(api_url, headers=self._headers(), params={'nip': nip}, timeout=self.DEFAULT_TIMEOUT)
if response.status_code in {401, 403}:
return {
'ok': False,
'message': 'CEIDG odrzucił token. Sprawdź klucz w panelu admina.',
'error': response.text[:500],
'debug': {
'environment': environment,
'url': api_url,
'auth_prefix': 'Bearer' if self._headers().get('Authorization', '').startswith('Bearer ') else 'raw',
'token_len': len(self.get_api_key()),
},
'fallback': {'tax_id': nip},
}
if response.status_code == 404:
return {'ok': False, 'message': 'Nie znaleziono firmy o podanym NIP w CEIDG.', 'fallback': {'tax_id': nip}}
response.raise_for_status()
parsed = self._parse_payload(response.text, nip)
if parsed:
parsed['ok'] = True
parsed['source_url'] = api_url
parsed['environment'] = environment
parsed['message'] = 'Pobrano dane z API CEIDG.'
return parsed
return {
'ok': False,
'message': 'Nie znaleziono firmy w CEIDG. Podmiot może być zarejestrowany w KRS.',
'sample': self._safe_payload(response),
'fallback': {'tax_id': nip},
}
except requests.exceptions.Timeout as exc:
error = f'Timeout: {exc}'
except requests.exceptions.RequestException as exc:
error = str(exc)
return {
'ok': False,
'message': 'Nie udało się pobrać danych z API CEIDG. Sprawdź konfigurację połączenia i API KEY w panelu admina.',
'error': error,
'fallback': {'tax_id': nip},
}
def diagnostics(self) -> dict:
environment = self.get_environment()
api_url = self.get_api_url(environment)
if not self.has_api_key():
return {
'status': 'error',
'message': 'Brak API KEY do CEIDG.',
'environment': environment,
'url': api_url,
'sample': {'error': 'missing_api_key'},
'technical_details': None,
'token_length': 0,
}
try:
headers = self._headers()
response = requests.get(api_url, headers=headers, params={'nip': '3563457932'}, timeout=self.DEFAULT_TIMEOUT)
payload = self._safe_payload(response)
status = 'ok' if response.status_code not in {401, 403} and response.status_code < 500 else 'error'
technical_details = None
if response.status_code in {401, 403}:
technical_details = 'Autoryzacja odrzucona przez CEIDG.'
elif response.status_code in {400, 404, 422}:
technical_details = 'Połączenie działa, ale zapytanie diagnostyczne nie zwróciło danych testowych.'
return {
'status': status,
'message': f'HTTP {response.status_code}',
'environment': environment,
'url': api_url,
'sample': payload,
'technical_details': technical_details,
'token_length': len(self.get_api_key()),
'authorization_preview': headers.get('Authorization', '')[:20],
}
except Exception as exc:
message = f'Timeout: {exc}' if isinstance(exc, requests.exceptions.Timeout) else str(exc)
return {
'status': 'error',
'message': message,
'environment': environment,
'url': api_url,
'sample': {'error': str(exc)},
'technical_details': None,
'token_length': len(self.get_api_key()),
}
def _parse_payload(self, payload: str, nip: str) -> dict | None:
try:
obj = json.loads(payload)
except Exception:
return None
found = self._walk_candidate(obj, nip)
if not found:
return None
found = self._normalize_empty(found)
found['tax_id'] = nip
found['name'] = self._clean_text(found.get('name'))
found['regon'] = self._digits(found.get('regon'))
found['address'] = self._clean_text(found.get('address'))
found['phone'] = self._clean_text(found.get('phone'))
found['email'] = self._clean_text(found.get('email'))
if not found['phone']:
found.pop('phone', None)
if not found['email']:
found.pop('email', None)
return found if found['name'] else None
def _walk_candidate(self, candidate: Any, nip: str) -> dict | None:
if isinstance(candidate, dict):
candidate_nip = self._extract_nip(candidate)
if candidate_nip == nip:
return {
'name': self._extract_name(candidate),
'regon': self._extract_regon(candidate),
'address': self._compose_address(candidate),
'phone': self._extract_phone(candidate),
'email': self._extract_email(candidate),
}
for value in candidate.values():
nested = self._walk_candidate(value, nip)
if nested:
return nested
elif isinstance(candidate, list):
for item in candidate:
nested = self._walk_candidate(item, nip)
if nested:
return nested
return None
def _extract_nip(self, candidate: dict) -> str:
owner = candidate.get('wlasciciel') or candidate.get('owner') or {}
values = [
candidate.get('nip'),
candidate.get('Nip'),
candidate.get('taxId'),
candidate.get('tax_id'),
candidate.get('NIP'),
owner.get('nip') if isinstance(owner, dict) else '',
]
for value in values:
digits = self._digits(value)
if digits:
return digits
return ''
def _extract_name(self, candidate: dict) -> str:
owner = candidate.get('wlasciciel') or candidate.get('owner') or {}
values = [
candidate.get('firma'),
candidate.get('nazwa'),
candidate.get('name'),
candidate.get('nazwaFirmy'),
candidate.get('przedsiebiorca'),
candidate.get('entrepreneurName'),
owner.get('nazwa') if isinstance(owner, dict) else '',
owner.get('nazwaSkrocona') if isinstance(owner, dict) else '',
owner.get('imieNazwisko') if isinstance(owner, dict) else '',
]
for value in values:
cleaned = self._clean_text(value)
if cleaned:
return cleaned
return ''
def _extract_regon(self, candidate: dict) -> str:
owner = candidate.get('wlasciciel') or candidate.get('owner') or {}
values = [
candidate.get('regon'),
candidate.get('REGON'),
candidate.get('regon9'),
owner.get('regon') if isinstance(owner, dict) else '',
]
for value in values:
digits = self._digits(value)
if digits:
return digits
return ''
def _extract_phone(self, candidate: dict) -> str:
owner = candidate.get('wlasciciel') or candidate.get('owner') or {}
contact = candidate.get('kontakt') or candidate.get('contact') or {}
values = [
candidate.get('telefon'),
candidate.get('telefonKontaktowy'),
candidate.get('phone'),
candidate.get('phoneNumber'),
contact.get('telefon') if isinstance(contact, dict) else '',
contact.get('phone') if isinstance(contact, dict) else '',
owner.get('telefon') if isinstance(owner, dict) else '',
owner.get('phone') if isinstance(owner, dict) else '',
]
for value in values:
cleaned = self._clean_text(value)
if cleaned:
return cleaned
return ''
def _extract_email(self, candidate: dict) -> str:
owner = candidate.get('wlasciciel') or candidate.get('owner') or {}
contact = candidate.get('kontakt') or candidate.get('contact') or {}
values = [
candidate.get('email'),
candidate.get('adresEmail'),
candidate.get('emailAddress'),
contact.get('email') if isinstance(contact, dict) else '',
owner.get('email') if isinstance(owner, dict) else '',
]
for value in values:
cleaned = self._clean_text(value)
if cleaned:
return cleaned
return ''
def _compose_address(self, candidate: dict) -> str:
address = (
candidate.get('adresDzialalnosci')
or candidate.get('adresGlownegoMiejscaWykonywaniaDzialalnosci')
or candidate.get('adres')
or candidate.get('address')
or {}
)
if isinstance(address, str):
return self._clean_text(address)
if isinstance(address, dict):
street = self._clean_text(address.get('ulica') or address.get('street'))
building = self._clean_text(address.get('budynek') or address.get('numerNieruchomosci') or address.get('buildingNumber'))
apartment = self._clean_text(address.get('lokal') or address.get('numerLokalu') or address.get('apartmentNumber'))
postal_code = self._clean_text(address.get('kod') or address.get('kodPocztowy') or address.get('postalCode'))
city = self._clean_text(address.get('miasto') or address.get('miejscowosc') or address.get('city'))
parts = []
street_part = ' '.join([part for part in [street, building] if part]).strip()
if apartment:
street_part = f'{street_part}/{apartment}' if street_part else apartment
if street_part:
parts.append(street_part)
city_part = ' '.join([part for part in [postal_code, city] if part]).strip()
if city_part:
parts.append(city_part)
return ', '.join(parts)
values = [
candidate.get('ulica'),
candidate.get('numerNieruchomosci'),
candidate.get('numerLokalu'),
candidate.get('kodPocztowy'),
candidate.get('miejscowosc'),
]
return ', '.join([self._clean_text(v) for v in values if self._clean_text(v)])

View File

@@ -0,0 +1,56 @@
from flask import session
from flask_login import current_user
from app.extensions import db
from app.models.company import Company, UserCompanyAccess
class CompanyService:
@staticmethod
def available_for_user(user=None):
user = user or current_user
if not getattr(user, 'is_authenticated', False):
return []
return user.companies()
@staticmethod
def get_current_company(user=None):
user = user or current_user
if not getattr(user, 'is_authenticated', False):
return None
company_id = session.get('current_company_id')
if company_id and user.can_access_company(company_id):
return db.session.get(Company, company_id)
companies = user.companies()
if companies:
session['current_company_id'] = companies[0].id
return companies[0]
return None
@staticmethod
def switch_company(company_id, user=None):
user = user or current_user
if getattr(user, 'role', None) == 'admin' or user.can_access_company(company_id):
session['current_company_id'] = company_id
return True
return False
@staticmethod
def create_company(name, tax_id='', regon='', address='', sync_enabled=False, sync_interval_minutes=60):
company = Company(name=name, tax_id=tax_id, regon=regon, address=address, sync_enabled=sync_enabled, sync_interval_minutes=sync_interval_minutes)
db.session.add(company)
db.session.commit()
return company
@staticmethod
def assign_user(user, company, access_level='full', switch_after=False):
existing = UserCompanyAccess.query.filter_by(user_id=user.id, company_id=company.id).first()
if existing:
existing.access_level = access_level
else:
db.session.add(UserCompanyAccess(user_id=user.id, company_id=company.id, access_level=access_level))
db.session.commit()
if switch_after:
try:
session['current_company_id'] = company.id
except RuntimeError:
pass

View File

@@ -0,0 +1,71 @@
from datetime import datetime
from sqlalchemy import text
from app.extensions import db
from app.services.company_service import CompanyService
from app.services.ksef_service import KSeFService
from app.services.settings_service import SettingsService
from app.services.ceidg_service import CeidgService
from app.services.redis_service import RedisService
class HealthService:
CACHE_TTL = 300
def cache_key(self, company_id):
return f'health.status.company.{company_id or "global"}'
def get_cached_status(self, company_id=None):
return RedisService.get_json(self.cache_key(company_id))
def warm_cache(self, company_id=None):
status = self.get_status(force_refresh=True, company_id=company_id)
RedisService.set_json(self.cache_key(company_id), status, ttl=self.CACHE_TTL)
return status
def get_status(self, force_refresh: bool = False, company_id=None):
if not force_refresh:
cached = self.get_cached_status(company_id)
if cached:
return cached
company = CompanyService.get_current_company()
if company_id is None and company:
company_id = company.id
redis_status, redis_details = RedisService.ping()
status = {
'timestamp': datetime.utcnow().isoformat(),
'db': 'ok',
'smtp': 'configured' if SettingsService.get_effective('mail.server', company_id=company_id) else 'not_configured',
'redis': redis_status,
'redis_details': redis_details,
'ksef': 'unknown',
'ceidg': 'unknown',
'ksef_message': '',
'ceidg_message': '',
}
try:
db.session.execute(text('SELECT 1'))
except Exception:
status['db'] = 'error'
try:
ping = KSeFService(company_id=company_id).ping()
status['ksef'] = ping.get('status', 'unknown')
status['ksef_message'] = ping.get('message', '')
except Exception as exc:
status['ksef'] = 'error'
status['ksef_message'] = str(exc)
try:
ping = CeidgService().diagnostics()
status['ceidg'] = ping.get('status', 'unknown')
status['ceidg_message'] = ping.get('message', '')
except Exception as exc:
status['ceidg'] = 'error'
status['ceidg_message'] = str(exc)
status['critical'] = status['db'] != 'ok' or status['ksef'] not in ['ok', 'mock'] or status['ceidg'] != 'ok'
RedisService.set_json(self.cache_key(company_id), status, ttl=self.CACHE_TTL)
return status

View File

@@ -0,0 +1,73 @@
import re
import xml.etree.ElementTree as ET
class InvoicePartyService:
@staticmethod
def extract_address_from_ksef_xml(xml_content: str):
result = {
'street': None,
'city': None,
'postal_code': None,
'country': None,
'address': None,
}
if not xml_content:
return result
try:
root = ET.fromstring(xml_content)
except Exception:
return result
def local_name(tag: str) -> str:
return tag.split('}', 1)[1] if '}' in tag else tag
def clean(value):
return (value or '').strip()
def find_text(names: list[str]):
wanted = {name.lower() for name in names}
for node in root.iter():
if local_name(node.tag).lower() in wanted:
text = clean(node.text)
if text:
return text
return None
street = find_text(['Ulica', 'AdresL1'])
house_no = find_text(['NrDomu'])
apartment_no = find_text(['NrLokalu'])
city = find_text(['Miejscowosc'])
postal_code = find_text(['KodPocztowy'])
country = find_text(['Kraj', 'KodKraju'])
adres_l2 = find_text(['AdresL2'])
if adres_l2 and (not postal_code or not city):
match = re.match(r'^(\d{2}-\d{3})\s+(.+)$', clean(adres_l2))
if match:
postal_code = postal_code or match.group(1)
city = city or clean(match.group(2))
elif not city:
city = clean(adres_l2)
street_line = clean(street)
if not street_line:
street_parts = [part for part in [street, house_no] if part]
street_line = ' '.join([p for p in street_parts if p]).strip()
if apartment_no:
street_line = f'{street_line}/{apartment_no}' if street_line else apartment_no
address_parts = [part for part in [street_line, postal_code, city, country] if part]
result.update({
'street': street_line or None,
'city': city,
'postal_code': postal_code,
'country': country,
'address': ', '.join(address_parts) if address_parts else None,
})
return result

View File

@@ -0,0 +1,949 @@
from __future__ import annotations
from collections import defaultdict
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from xml.sax.saxutils import escape
import xml.etree.ElementTree as ET
from flask import current_app
from sqlalchemy import or_
from app.extensions import db
from app.models.catalog import InvoiceLine
from app.models.invoice import Invoice, InvoiceStatus, InvoiceType, Tag, SyncEvent
from app.repositories.invoice_repository import InvoiceRepository
from app.services.company_service import CompanyService
from app.services.ksef_service import KSeFService
from app.services.settings_service import SettingsService
class InvoiceService:
PERIOD_LABELS = {
"month": "miesięczne",
"quarter": "kwartalne",
"year": "roczne",
}
def __init__(self):
self.repo = InvoiceRepository()
def upsert_from_ksef(self, document, company):
invoice = self.repo.get_by_ksef_number(document.ksef_number, company_id=company.id)
created = False
if not invoice:
invoice = Invoice(ksef_number=document.ksef_number, company_id=company.id)
db.session.add(invoice)
created = True
invoice.invoice_number = document.invoice_number
invoice.contractor_name = document.contractor_name
invoice.contractor_nip = document.contractor_nip
invoice.issue_date = document.issue_date
invoice.received_date = document.received_date
invoice.fetched_at = document.fetched_at
invoice.net_amount = document.net_amount
invoice.vat_amount = document.vat_amount
invoice.gross_amount = document.gross_amount
invoice.invoice_type = InvoiceType(document.invoice_type)
invoice.last_synced_at = datetime.utcnow()
invoice.external_metadata = document.metadata
invoice.source_hash = KSeFService.calc_hash(document.xml_content)
invoice.xml_path = self._save_xml(company.id, invoice.ksef_number, document.xml_content)
metadata = dict(document.metadata or {})
payment_details = self.extract_payment_details_from_xml(document.xml_content)
if payment_details.get('payment_form_code') and not metadata.get('payment_form_code'):
metadata['payment_form_code'] = payment_details['payment_form_code']
if payment_details.get('payment_form_label') and not metadata.get('payment_form_label'):
metadata['payment_form_label'] = payment_details['payment_form_label']
if payment_details.get('bank_account') and not metadata.get('seller_bank_account'):
metadata['seller_bank_account'] = payment_details['bank_account']
if payment_details.get('bank_name') and not metadata.get('seller_bank_name'):
metadata['seller_bank_name'] = payment_details['bank_name']
if payment_details.get('payment_due_date') and not metadata.get('payment_due_date'):
metadata['payment_due_date'] = payment_details['payment_due_date']
invoice.external_metadata = metadata
invoice.seller_bank_account = payment_details.get('bank_account') or metadata.get('seller_bank_account') or invoice.seller_bank_account
invoice.contractor_address = (
metadata.get("contractor_address")
or ", ".join([
part for part in [
metadata.get("contractor_street"),
metadata.get("contractor_postal_code"),
metadata.get("contractor_city"),
metadata.get("contractor_country"),
] if part
])
or ""
)
db.session.flush()
existing_lines = invoice.lines.count() if hasattr(invoice.lines, "count") else len(list(invoice.lines))
if existing_lines == 0:
for line in self.extract_lines_from_xml(document.xml_content):
db.session.add(
InvoiceLine(
invoice_id=invoice.id,
description=line["description"],
quantity=line["quantity"],
unit=line["unit"],
unit_net=line["unit_net"],
vat_rate=line["vat_rate"],
net_amount=line["net_amount"],
vat_amount=line["vat_amount"],
gross_amount=line["gross_amount"],
)
)
if not invoice.html_preview:
invoice.html_preview = self.render_invoice_html(invoice)
db.session.flush()
db.session.add(
SyncEvent(
invoice_id=invoice.id,
status="created" if created else "updated",
message="Pobrano z KSeF",
)
)
SettingsService.set_many({"ksef.last_sync_at": datetime.utcnow().isoformat()}, company_id=company.id)
return invoice, created
def _save_xml(self, company_id, ksef_number, xml_content):
base_path = SettingsService.storage_path("app.archive_path", current_app.config["ARCHIVE_PATH"]) / f"company_{company_id}"
base_path.mkdir(parents=True, exist_ok=True)
safe_name = ksef_number.replace("/", "_") + ".xml"
path = Path(base_path) / safe_name
path.write_text(xml_content, encoding="utf-8")
return str(path)
def extract_lines_from_xml(self, xml_content):
def to_decimal(value, default='0'):
raw = str(value or '').strip()
if not raw:
return Decimal(default)
raw = raw.replace(' ', '').replace(',', '.')
try:
return Decimal(raw)
except Exception:
return Decimal(default)
def to_vat_rate(value):
raw = str(value or '').strip().lower()
if not raw:
return Decimal('0')
raw = raw.replace('%', '').replace(' ', '').replace(',', '.')
try:
return Decimal(raw)
except Exception:
return Decimal('0')
def looks_numeric(value):
raw = str(value or '').strip().replace(' ', '').replace(',', '.')
if not raw:
return False
try:
Decimal(raw)
return True
except Exception:
return False
lines = []
try:
root = ET.fromstring(xml_content)
namespace_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else ''
ns = {'fa': namespace_uri} if namespace_uri else {}
row_path = './/fa:FaWiersz' if ns else './/FaWiersz'
text_path = lambda name: f'fa:{name}' if ns else name
for row in root.findall(row_path, ns):
description = (row.findtext(text_path('P_7'), default='', namespaces=ns) or '').strip()
p_8a = row.findtext(text_path('P_8A'), default='', namespaces=ns)
p_8b = row.findtext(text_path('P_8B'), default='', namespaces=ns)
if looks_numeric(p_8a):
qty_raw = p_8a
unit_raw = p_8b or 'szt.'
else:
unit_raw = p_8a or 'szt.'
qty_raw = p_8b or '1'
unit_net = (
row.findtext(text_path('P_9A'), default='', namespaces=ns)
or row.findtext(text_path('P_9B'), default='', namespaces=ns)
or '0'
)
net = (
row.findtext(text_path('P_11'), default='', namespaces=ns)
or row.findtext(text_path('P_11A'), default='', namespaces=ns)
or '0'
)
vat_rate = row.findtext(text_path('P_12'), default='0', namespaces=ns)
vat = (
row.findtext(text_path('P_12Z'), default='', namespaces=ns)
or row.findtext(text_path('P_11Vat'), default='', namespaces=ns)
or '0'
)
net_dec = to_decimal(net)
vat_dec = to_decimal(vat)
lines.append({
'description': description,
'quantity': to_decimal(qty_raw, '1'),
'unit': (unit_raw or 'szt.').strip(),
'unit_net': to_decimal(unit_net),
'vat_rate': to_vat_rate(vat_rate),
'net_amount': net_dec,
'vat_amount': vat_dec,
'gross_amount': net_dec + vat_dec,
})
except Exception as exc:
current_app.logger.warning(f'KSeF XML line parse error: {exc}')
return lines
def extract_payment_details_from_xml(self, xml_content):
details = {
"payment_form_code": "",
"payment_form_label": "",
"bank_account": "",
"bank_name": "",
"payment_due_date": "",
}
if not xml_content:
return details
try:
root = ET.fromstring(xml_content)
namespace_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else ''
ns = {'fa': namespace_uri} if namespace_uri else {}
def find_text(path):
return (root.findtext(path, default='', namespaces=ns) or '').strip()
form_code = find_text('.//fa:Platnosc/fa:FormaPlatnosci' if ns else './/Platnosc/FormaPlatnosci')
bank_account = find_text('.//fa:Platnosc/fa:RachunekBankowy/fa:NrRB' if ns else './/Platnosc/RachunekBankowy/NrRB')
bank_name = find_text('.//fa:Platnosc/fa:RachunekBankowy/fa:NazwaBanku' if ns else './/Platnosc/RachunekBankowy/NazwaBanku')
payment_due_date = find_text('.//fa:Platnosc/fa:TerminPlatnosci/fa:Termin' if ns else './/Platnosc/TerminPlatnosci/Termin')
form_labels = {
'1': 'gotówka',
'2': 'karta',
'3': 'bon',
'4': 'czek',
'5': 'weksel',
'6': 'przelew',
'7': 'kompensata',
'8': 'pobranie',
'9': 'akredytywa',
'10': 'polecenie zapłaty',
'11': 'inny',
}
details.update({
'payment_form_code': form_code,
'payment_form_label': form_labels.get(form_code, form_code),
'bank_account': self._normalize_bank_account(bank_account),
'bank_name': bank_name,
'payment_due_date': payment_due_date,
})
except Exception as exc:
current_app.logger.warning(f'KSeF XML payment parse error: {exc}')
return details
@staticmethod
def _first_non_empty(*values, default=""):
for value in values:
if value is None:
continue
if isinstance(value, str):
if value.strip():
return value.strip()
continue
text = str(value).strip()
if text:
return text
return default
@staticmethod
def _normalize_metadata_container(value):
return value if isinstance(value, dict) else {}
def _get_external_metadata(self, invoice):
return self._normalize_metadata_container(getattr(invoice, "external_metadata", {}) or {})
def _get_ksef_metadata(self, invoice):
external_metadata = self._get_external_metadata(invoice)
return self._normalize_metadata_container(external_metadata.get("ksef", {}) or {})
def _resolve_purchase_seller_data(self, invoice):
external_metadata = self._get_external_metadata(invoice)
ksef_meta = self._get_ksef_metadata(invoice)
seller_name = self._first_non_empty(
getattr(invoice, "contractor_name", ""),
external_metadata.get("contractor_name"),
ksef_meta.get("contractor_name"),
)
seller_tax_id = self._first_non_empty(
getattr(invoice, "contractor_nip", ""),
external_metadata.get("contractor_nip"),
ksef_meta.get("contractor_nip"),
)
seller_street = self._first_non_empty(
external_metadata.get("contractor_street"),
ksef_meta.get("contractor_street"),
external_metadata.get("seller_street"),
ksef_meta.get("seller_street"),
)
seller_city = self._first_non_empty(
external_metadata.get("contractor_city"),
ksef_meta.get("contractor_city"),
external_metadata.get("seller_city"),
ksef_meta.get("seller_city"),
)
seller_postal_code = self._first_non_empty(
external_metadata.get("contractor_postal_code"),
ksef_meta.get("contractor_postal_code"),
external_metadata.get("seller_postal_code"),
ksef_meta.get("seller_postal_code"),
)
seller_country = self._first_non_empty(
external_metadata.get("contractor_country"),
ksef_meta.get("contractor_country"),
external_metadata.get("seller_country"),
ksef_meta.get("seller_country"),
)
seller_address = self._first_non_empty(
external_metadata.get("contractor_address"),
ksef_meta.get("contractor_address"),
external_metadata.get("seller_address"),
ksef_meta.get("seller_address"),
getattr(invoice, "contractor_address", ""),
)
if not seller_address:
address_parts = [part for part in [seller_street, seller_postal_code, seller_city, seller_country] if part]
seller_address = ", ".join(address_parts)
return {
"name": seller_name,
"tax_id": seller_tax_id,
"address": seller_address,
}
def update_metadata(self, invoice, form):
invoice.status = InvoiceStatus(form.status.data)
invoice.internal_note = form.internal_note.data
invoice.pinned = bool(form.pinned.data)
invoice.queue_accounting = bool(form.queue_accounting.data)
invoice.is_unread = invoice.status == InvoiceStatus.NEW
requested_tags = [t.strip() for t in (form.tags.data or "").split(",") if t.strip()]
invoice.tags.clear()
for name in requested_tags:
tag = Tag.query.filter_by(name=name).first()
if not tag:
tag = Tag(name=name, color="primary")
db.session.add(tag)
invoice.tags.append(tag)
db.session.commit()
return invoice
def mark_read(self, invoice):
if invoice.is_unread:
invoice.is_unread = False
invoice.status = InvoiceStatus.READ if invoice.status == InvoiceStatus.NEW else invoice.status
invoice.read_at = datetime.utcnow()
db.session.commit()
def render_invoice_html(self, invoice):
def esc(value):
return str(value or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def money(value):
return f"{Decimal(value):,.2f} {invoice.currency or 'PLN'}".replace(",", " ").replace(".", ",")
company_name_raw = invoice.company.name if invoice.company else "Twoja firma"
company_name = esc(company_name_raw)
company_tax_id = esc(getattr(invoice.company, "tax_id", "") if invoice.company else "")
company_address = esc(getattr(invoice.company, "address", "") if invoice.company else "")
customer = getattr(invoice, "customer", None)
customer_name = esc(getattr(customer, "name", invoice.contractor_name))
customer_tax_id = esc(getattr(customer, "tax_id", invoice.contractor_nip))
customer_address = esc(getattr(customer, "address", ""))
customer_email = esc(getattr(customer, "email", ""))
if invoice.invoice_type == InvoiceType.PURCHASE:
purchase_seller = self._resolve_purchase_seller_data(invoice)
seller_name = esc(purchase_seller["name"])
seller_tax_id = esc(purchase_seller["tax_id"])
seller_address = esc(purchase_seller["address"])
buyer_name = company_name
buyer_tax_id = company_tax_id
buyer_address = company_address
buyer_email = ""
else:
seller_name = company_name
seller_tax_id = company_tax_id
seller_address = company_address
buyer_name = customer_name
buyer_tax_id = customer_tax_id
buyer_address = customer_address
buyer_email = customer_email
nfz_meta = (invoice.external_metadata or {}).get("nfz", {})
invoice_kind = "FAKTURA NFZ" if invoice.source == "nfz" else "FAKTURA VAT"
page_title = self._first_non_empty(
getattr(invoice, "invoice_number", ""),
getattr(invoice, "ksef_number", ""),
company_name_raw,
"Faktura",
)
lines = invoice.lines.order_by("id").all() if hasattr(invoice.lines, "order_by") else list(invoice.lines)
if not lines and invoice.xml_path:
try:
xml_content = Path(invoice.xml_path).read_text(encoding="utf-8")
extracted = self.extract_lines_from_xml(xml_content)
lines = [type("TmpLine", (), line) for line in extracted]
except Exception:
lines = []
lines_html = "".join(
[
(
f"<tr>"
f"<td style='padding:8px;border:1px solid #000'>{esc(line.description)}</td>"
f"<td style='padding:8px;border:1px solid #000;text-align:right'>{esc(line.quantity)}</td>"
f"<td style='padding:8px;border:1px solid #000;text-align:right'>{esc(line.unit)}</td>"
f"<td style='padding:8px;border:1px solid #000;text-align:right'>{esc(line.vat_rate)}%</td>"
f"<td style='padding:8px;border:1px solid #000;text-align:right'>{money(line.net_amount)}</td>"
f"<td style='padding:8px;border:1px solid #000;text-align:right'>{money(line.gross_amount)}</td>"
f"</tr>"
)
for line in lines
]
) or (
"<tr>"
"<td colspan='6' style='padding:12px;border:1px solid #000;text-align:center'>Brak pozycji na fakturze.</td>"
"</tr>"
)
nfz_rows = ""
if nfz_meta:
nfz_pairs = [
("Oddział NFZ (IDWew)", nfz_meta.get("recipient_branch_id")),
("Nazwa oddziału", nfz_meta.get("recipient_branch_name")),
("Okres rozliczeniowy od", nfz_meta.get("settlement_from")),
("Okres rozliczeniowy do", nfz_meta.get("settlement_to")),
("Identyfikator świadczeniodawcy", nfz_meta.get("provider_identifier")),
("Kod zakresu / świadczenia", nfz_meta.get("service_code")),
("Numer umowy / aneksu", nfz_meta.get("contract_number")),
("Identyfikator szablonu", nfz_meta.get("template_identifier")),
("Schemat", nfz_meta.get("nfz_schema", "FA(3)")),
]
nfz_rows = "".join(
[
f"<tr><td style='padding:8px;border:1px solid #000;width:38%'>{esc(label)}</td><td style='padding:8px;border:1px solid #000'>{esc(value)}</td></tr>"
for label, value in nfz_pairs
]
)
nfz_rows = (
"<div style='margin:18px 0 10px;font-weight:700'>Dane NFZ</div>"
"<table style='width:100%;border-collapse:collapse;margin-bottom:18px'>"
"<thead><tr><th style='padding:8px;border:1px solid #000;text-align:left'>Pole NFZ</th><th style='padding:8px;border:1px solid #000;text-align:left'>Wartość</th></tr></thead>"
f"<tbody>{nfz_rows}</tbody></table>"
)
buyer_email_html = f"<br>E-mail: {buyer_email}" if buyer_email not in {"", ""} else ""
split_payment_html = "<div style='margin:12px 0;padding:10px;border:1px solid #000;font-weight:700'>Mechanizm podzielonej płatności</div>" if getattr(invoice, 'split_payment', False) else ""
payment_details = self.resolve_payment_details(invoice)
seller_bank_account = esc(self._resolve_seller_bank_account(invoice))
payment_form_html = f"<br>Forma płatności: {esc(payment_details.get('payment_form_label'))}" if payment_details.get('payment_form_label') else ''
seller_bank_account_html = f"<br>Rachunek bankowy: {seller_bank_account}" if seller_bank_account not in {'', ''} else ''
seller_bank_name_html = f"<br>Bank: {esc(payment_details.get('bank_name'))}" if payment_details.get('bank_name') else ''
return (
f"<div style='font-family:Helvetica,Arial,sans-serif;color:#000;background:#fff;padding:8px'>"
f"<div style='font-size:24px;font-weight:700;margin-bottom:10px'>{invoice_kind}</div>"
f"<table style='width:100%;border-collapse:collapse;margin-bottom:16px'><tr>"
f"<td style='width:50%;padding:8px;border:1px solid #000;vertical-align:top'><strong>Numer faktury:</strong> {esc(invoice.invoice_number)}<br><strong>Data wystawienia:</strong> {esc(invoice.issue_date)}<br><strong>Waluta:</strong> {esc(invoice.currency or 'PLN')}</td>"
f"<td style='width:50%;padding:8px;border:1px solid #000;vertical-align:top'><strong>Numer KSeF:</strong> {esc(invoice.ksef_number)}<br><strong>Status:</strong> {esc(invoice.issued_status)}<br><strong>Typ źródła:</strong> {esc(invoice.source)}</td>"
f"</tr></table>"
f"<table style='width:100%;border-collapse:collapse;margin-bottom:16px'><tr>"
f"<td style='width:50%;padding:8px;border:1px solid #000;vertical-align:top'><strong>Sprzedawca</strong><br>{seller_name}<br>NIP: {seller_tax_id}<br>Adres: {seller_address}{payment_form_html}{seller_bank_account_html}{seller_bank_name_html}</td>"
f"<td style='width:50%;padding:8px;border:1px solid #000;vertical-align:top'><strong>Nabywca</strong><br>{buyer_name}<br>NIP: {buyer_tax_id}<br>Adres: {buyer_address}{buyer_email_html}</td>"
f"</tr></table>"
f"{nfz_rows}"
f"{split_payment_html}"
"<div style='margin:18px 0 10px;font-weight:700'>Pozycje faktury</div>"
"<table style='width:100%;border-collapse:collapse'>"
"<thead>"
"<tr>"
"<th style='padding:8px;border:1px solid #000;text-align:left'>Pozycja</th>"
"<th style='padding:8px;border:1px solid #000;text-align:right'>Ilość</th>"
"<th style='padding:8px;border:1px solid #000;text-align:right'>JM</th>"
"<th style='padding:8px;border:1px solid #000;text-align:right'>VAT</th>"
"<th style='padding:8px;border:1px solid #000;text-align:right'>Netto</th>"
"<th style='padding:8px;border:1px solid #000;text-align:right'>Brutto</th>"
"</tr>"
"</thead>"
f"<tbody>{lines_html}</tbody></table>"
"<table style='width:52%;border-collapse:collapse;margin-left:auto;margin-top:16px'>"
f"<tr><td style='padding:8px;border:1px solid #000'>Netto</td><td style='padding:8px;border:1px solid #000;text-align:right'>{money(invoice.net_amount)}</td></tr>"
f"<tr><td style='padding:8px;border:1px solid #000'>VAT</td><td style='padding:8px;border:1px solid #000;text-align:right'>{money(invoice.vat_amount)}</td></tr>"
f"<tr><td style='padding:8px;border:1px solid #000'><strong>Razem brutto</strong></td><td style='padding:8px;border:1px solid #000;text-align:right'><strong>{money(invoice.gross_amount)}</strong></td></tr>"
"</table>"
f"<div style='margin-top:16px;font-size:11px'>{'Dokument zawiera pola wymagane dla rozliczeń NFZ i został przygotowany do wysyłki w schemacie FA(3).' if nfz_meta else 'Dokument wygenerowany przez KSeF Manager.'}</div>"
"</div>"
)
def monthly_groups(self, company_id=None):
groups = []
for row in self.repo.monthly_summary(company_id):
year = int(row.year)
month = int(row.month)
entries = self.repo.base_query(company_id).filter(Invoice.issue_date >= datetime(year, month, 1).date())
if month == 12:
entries = entries.filter(Invoice.issue_date < datetime(year + 1, 1, 1).date())
else:
entries = entries.filter(Invoice.issue_date < datetime(year, month + 1, 1).date())
groups.append(
{
"key": f"{year}-{month:02d}",
"year": year,
"month": month,
"count": int(row.count or 0),
"net": row.net or Decimal("0"),
"vat": row.vat or Decimal("0"),
"gross": row.gross or Decimal("0"),
"entries": entries.order_by(Invoice.issue_date.desc(), Invoice.id.desc()).all(),
}
)
return groups
def grouped_summary(self, company_id=None, *, period="month", search=None):
rows = self.repo.summary_query(company_id, period=period, search=search)
grouped_entries = defaultdict(list)
entry_query = self.repo.base_query(company_id)
if search:
like = f"%{search}%"
entry_query = entry_query.filter(
or_(
Invoice.invoice_number.ilike(like),
Invoice.ksef_number.ilike(like),
Invoice.contractor_name.ilike(like),
Invoice.contractor_nip.ilike(like),
)
)
for invoice in entry_query.order_by(Invoice.issue_date.desc(), Invoice.id.desc()).all():
if period == "year":
key = f"{invoice.issue_date.year}"
elif period == "quarter":
quarter = ((invoice.issue_date.month - 1) // 3) + 1
key = f"{invoice.issue_date.year}-Q{quarter}"
else:
key = f"{invoice.issue_date.year}-{invoice.issue_date.month:02d}"
grouped_entries[key].append(invoice)
groups = []
for row in rows:
year = int(row.year)
month = int(getattr(row, "month", 0) or 0)
quarter = int(getattr(row, "quarter", 0) or 0)
if period == "year":
key = f"{year}"
label = f"Rok {year}"
elif period == "quarter":
key = f"{year}-Q{quarter}"
label = f"Q{quarter} {year}"
else:
key = f"{year}-{month:02d}"
label = f"{year}-{month:02d}"
groups.append(
{
"key": key,
"label": label,
"year": year,
"month": month,
"quarter": quarter,
"count": int(row.count or 0),
"net": row.net or Decimal("0"),
"vat": row.vat or Decimal("0"),
"gross": row.gross or Decimal("0"),
"entries": grouped_entries.get(key, []),
}
)
return groups
def comparative_stats(self, company_id=None, *, search=None):
rows = self.repo.summary_query(company_id, period="year", search=search)
stats = []
previous = None
for row in rows:
year = int(row.year)
gross = row.gross or Decimal("0")
net = row.net or Decimal("0")
count = int(row.count or 0)
delta = None
if previous is not None:
delta = gross - previous["gross"]
stats.append({"year": year, "gross": gross, "net": net, "count": count, "delta": delta})
previous = {"gross": gross}
return stats
def next_sale_number(self, company_id, template="monthly"):
today = self.today_date()
query = Invoice.query.filter(Invoice.company_id == company_id, Invoice.invoice_type == InvoiceType.SALE)
if template == "yearly":
query = query.filter(Invoice.issue_date >= date(today.year, 1, 1))
prefix = f"FV/{today.year}/"
elif template == "custom":
prefix = f"FV/{today.year}/{today.month:02d}/"
query = query.filter(Invoice.issue_date >= date(today.year, today.month, 1))
if today.month == 12:
query = query.filter(Invoice.issue_date < date(today.year + 1, 1, 1))
else:
query = query.filter(Invoice.issue_date < date(today.year, today.month + 1, 1))
else:
prefix = f"FV/{today.year}/{today.month:02d}/"
query = query.filter(Invoice.issue_date >= date(today.year, today.month, 1))
if today.month == 12:
query = query.filter(Invoice.issue_date < date(today.year + 1, 1, 1))
else:
query = query.filter(Invoice.issue_date < date(today.year, today.month + 1, 1))
next_no = query.count() + 1
return f"{prefix}{next_no:04d}"
def build_ksef_payload(self, invoice):
lines = invoice.lines.order_by("id").all() if hasattr(invoice.lines, "order_by") else list(invoice.lines)
nfz_meta = (invoice.external_metadata or {}).get("nfz", {})
xml_content = self.render_structured_xml(invoice, lines=lines, nfz_meta=nfz_meta)
payload = {
"invoiceNumber": invoice.invoice_number,
"invoiceType": invoice.invoice_type.value if hasattr(invoice.invoice_type, "value") else str(invoice.invoice_type),
"schemaVersion": nfz_meta.get("nfz_schema", "FA(3)"),
"customer": {
"name": invoice.contractor_name,
"taxId": invoice.contractor_nip,
},
"lines": [
{
"name": line.description,
"qty": float(line.quantity),
"unit": line.unit,
"unitNet": float(line.unit_net),
"vatRate": float(line.vat_rate),
"netAmount": float(line.net_amount),
"grossAmount": float(line.gross_amount),
}
for line in lines
],
"metadata": {
"source": invoice.source,
"companyId": invoice.company_id,
"issueDate": invoice.issue_date.isoformat() if invoice.issue_date else "",
"currency": invoice.currency,
"nfz": nfz_meta,
"split_payment": bool(getattr(invoice, 'split_payment', False)),
},
"xml_content": xml_content,
}
if nfz_meta:
payload["customer"]["internalBranchId"] = nfz_meta.get("recipient_branch_id", "")
return payload
@staticmethod
def _xml_decimal(value, places=2):
quant = Decimal('1').scaleb(-places)
return format(Decimal(value or 0).quantize(quant), 'f')
@staticmethod
def _split_address_lines(address):
raw = (address or '').strip()
if not raw:
return '', ''
parts = [part.strip() for part in raw.replace('\n', ',').split(',') if part.strip()]
if len(parts) <= 1:
return raw[:512], ''
line1 = ', '.join(parts[:2])[:512]
line2 = ', '.join(parts[2:])[:512]
return line1, line2
@staticmethod
def _normalize_bank_account(value):
raw = ''.join(str(value or '').split())
return raw
def resolve_payment_details(self, invoice):
external_metadata = self._normalize_metadata_container(getattr(invoice, 'external_metadata', {}) or {})
details = {
'payment_form_code': self._first_non_empty(external_metadata.get('payment_form_code')),
'payment_form_label': self._first_non_empty(external_metadata.get('payment_form_label')),
'bank_account': self._normalize_bank_account(
self._first_non_empty(
getattr(invoice, 'seller_bank_account', ''),
external_metadata.get('seller_bank_account'),
)
),
'bank_name': self._first_non_empty(external_metadata.get('seller_bank_name')),
'payment_due_date': self._first_non_empty(external_metadata.get('payment_due_date')),
}
if getattr(invoice, 'xml_path', None) and (not details['bank_account'] or not details['payment_form_code'] or not details['bank_name'] or not details['payment_due_date']):
try:
xml_content = Path(invoice.xml_path).read_text(encoding='utf-8')
parsed = self.extract_payment_details_from_xml(xml_content)
for key, value in parsed.items():
if value and not details.get(key):
details[key] = value
except Exception:
pass
return details
def _resolve_seller_bank_account(self, invoice):
details = self.resolve_payment_details(invoice)
account = self._normalize_bank_account(details.get('bank_account', ''))
if account:
return account
if getattr(invoice, 'invoice_type', None) == InvoiceType.PURCHASE:
return ''
company = getattr(invoice, 'company', None)
return self._normalize_bank_account(getattr(company, 'bank_account', '') if company else '')
@staticmethod
def _safe_tax_id(value):
digits = ''.join(ch for ch in str(value or '') if ch.isdigit())
return digits
def _append_xml_text(self, parent, tag, value):
if value is None:
return None
text = str(value).strip()
if not text:
return None
node = ET.SubElement(parent, tag)
node.text = text
return node
def _append_address_node(self, parent, address, *, country_code='PL'):
line1, line2 = self._split_address_lines(address)
if not line1 and not line2:
return None
node = ET.SubElement(parent, 'Adres')
ET.SubElement(node, 'KodKraju').text = country_code or 'PL'
if line1:
ET.SubElement(node, 'AdresL1').text = line1
if line2:
ET.SubElement(node, 'AdresL2').text = line2
return node
def _append_party_section(self, root, tag_name, *, name, tax_id, address, country_code='PL'):
party = ET.SubElement(root, tag_name)
ident = ET.SubElement(party, 'DaneIdentyfikacyjne')
tax_digits = self._safe_tax_id(tax_id)
if tax_digits:
ET.SubElement(ident, 'NIP').text = tax_digits
else:
ET.SubElement(ident, 'BrakID').text = '1'
ET.SubElement(ident, 'Nazwa').text = (name or 'Brak nazwy').strip()
self._append_address_node(party, address, country_code=country_code)
return party
def _append_tax_summary(self, fa_node, lines):
vat_groups = {
'23': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_1', 'p14': 'P_14_1'},
'22': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_1', 'p14': 'P_14_1'},
'8': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_2', 'p14': 'P_14_2'},
'7': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_2', 'p14': 'P_14_2'},
'5': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_3', 'p14': 'P_14_3'},
}
for line in lines:
rate = Decimal(line.vat_rate or 0).normalize()
rate_key = format(rate, 'f').rstrip('0').rstrip('.') if '.' in format(rate, 'f') else format(rate, 'f')
group = vat_groups.get(rate_key)
if not group:
group = vat_groups.get('23') if Decimal(line.vat_amount or 0) > 0 else None
if not group:
continue
group['net'] += Decimal(line.net_amount or 0)
group['vat'] += Decimal(line.vat_amount or 0)
for cfg in vat_groups.values():
if cfg['net']:
ET.SubElement(fa_node, cfg['p13']).text = self._xml_decimal(cfg['net'])
ET.SubElement(fa_node, cfg['p14']).text = self._xml_decimal(cfg['vat'])
def _append_adnotations(self, fa_node, *, split_payment=False):
adnotacje = ET.SubElement(fa_node, 'Adnotacje')
ET.SubElement(adnotacje, 'P_16').text = '2'
ET.SubElement(adnotacje, 'P_17').text = '2'
ET.SubElement(adnotacje, 'P_18').text = '2'
ET.SubElement(adnotacje, 'P_18A').text = '1' if split_payment else '2'
zw = ET.SubElement(adnotacje, 'Zwolnienie')
ET.SubElement(zw, 'P_19N').text = '1'
ET.SubElement(adnotacje, 'P_23').text = '2'
return adnotacje
def _append_payment_details(self, fa_node, invoice):
bank_account = self._resolve_seller_bank_account(invoice)
if not bank_account:
return None
external_metadata = self._normalize_metadata_container(getattr(invoice, 'external_metadata', {}) or {})
bank_name = self._first_non_empty(external_metadata.get('seller_bank_name'))
platnosc = ET.SubElement(fa_node, 'Platnosc')
ET.SubElement(platnosc, 'FormaPlatnosci').text = '6'
rachunek = ET.SubElement(platnosc, 'RachunekBankowy')
ET.SubElement(rachunek, 'NrRB').text = bank_account
if bank_name:
ET.SubElement(rachunek, 'NazwaBanku').text = bank_name
return platnosc
def _append_nfz_extra_description(self, fa_node, nfz_meta):
mapping = [
('IDWew', nfz_meta.get('recipient_branch_id', '')),
('P_6_Od', nfz_meta.get('settlement_from', '')),
('P_6_Do', nfz_meta.get('settlement_to', '')),
('identyfikator-swiadczeniodawcy', nfz_meta.get('provider_identifier', '')),
('Indeks', nfz_meta.get('service_code', '')),
('NrUmowy', nfz_meta.get('contract_number', '')),
('identyfikator-szablonu', nfz_meta.get('template_identifier', '')),
]
for key, value in mapping:
if not str(value or '').strip():
continue
node = ET.SubElement(fa_node, 'DodatkowyOpis')
ET.SubElement(node, 'Klucz').text = str(key)
ET.SubElement(node, 'Wartosc').text = str(value)
def render_structured_xml(self, invoice, *, lines=None, nfz_meta=None):
lines = lines if lines is not None else (invoice.lines.order_by('id').all() if hasattr(invoice.lines, 'order_by') else list(invoice.lines))
nfz_meta = nfz_meta or (invoice.external_metadata or {}).get('nfz', {})
seller = invoice.company or CompanyService.get_current_company()
issue_date = invoice.issue_date.isoformat() if invoice.issue_date else self.today_date().isoformat()
created_at = self.utcnow().replace(microsecond=0).isoformat() + 'Z'
currency = (invoice.currency or 'PLN').strip() or 'PLN'
schema_code = str(nfz_meta.get('nfz_schema', 'FA(3)') or 'FA(3)')
schema_ns = 'http://crd.gov.pl/wzor/2025/06/25/13775/' if schema_code == 'FA(3)' else f'https://ksef.mf.gov.pl/schemat/faktura/{schema_code}'
ET.register_namespace('', schema_ns)
root = ET.Element('Faktura', {'xmlns': schema_ns})
naglowek = ET.SubElement(root, 'Naglowek')
kod_formularza = ET.SubElement(naglowek, 'KodFormularza', {'kodSystemowy': 'FA (3)', 'wersjaSchemy': '1-0E'})
kod_formularza.text = 'FA'
ET.SubElement(naglowek, 'WariantFormularza').text = '3'
ET.SubElement(naglowek, 'DataWytworzeniaFa').text = created_at
ET.SubElement(naglowek, 'SystemInfo').text = 'KSeF Manager'
self._append_party_section(
root,
'Podmiot1',
name=getattr(seller, 'name', '') or '',
tax_id=getattr(seller, 'tax_id', '') or '',
address=getattr(seller, 'address', '') or '',
)
self._append_party_section(
root,
'Podmiot2',
name=invoice.contractor_name or '',
tax_id=invoice.contractor_nip or '',
address=getattr(invoice, 'contractor_address', '') or '',
)
if nfz_meta.get('recipient_branch_id'):
podmiot3 = ET.SubElement(root, 'Podmiot3')
ident = ET.SubElement(podmiot3, 'DaneIdentyfikacyjne')
ET.SubElement(ident, 'IDWew').text = str(nfz_meta.get('recipient_branch_id'))
ET.SubElement(podmiot3, 'Rola').text = '7'
fa = ET.SubElement(root, 'Fa')
ET.SubElement(fa, 'KodWaluty').text = currency
ET.SubElement(fa, 'P_1').text = issue_date
self._append_xml_text(fa, 'P_1M', nfz_meta.get('issue_place'))
ET.SubElement(fa, 'P_2').text = (invoice.invoice_number or '').strip()
ET.SubElement(fa, 'P_6').text = issue_date
self._append_xml_text(fa, 'P_6_Od', nfz_meta.get('settlement_from'))
self._append_xml_text(fa, 'P_6_Do', nfz_meta.get('settlement_to'))
self._append_tax_summary(fa, lines)
ET.SubElement(fa, 'P_15').text = self._xml_decimal(invoice.gross_amount)
self._append_adnotations(fa, split_payment=bool(getattr(invoice, 'split_payment', False)))
self._append_payment_details(fa, invoice)
self._append_nfz_extra_description(fa, nfz_meta)
for idx, line in enumerate(lines, start=1):
row = ET.SubElement(fa, 'FaWiersz')
ET.SubElement(row, 'NrWierszaFa').text = str(idx)
if nfz_meta.get('service_date'):
ET.SubElement(row, 'P_6A').text = str(nfz_meta.get('service_date'))
ET.SubElement(row, 'P_7').text = str(line.description or '')
ET.SubElement(row, 'P_8A').text = str(line.unit or 'szt.')
ET.SubElement(row, 'P_8B').text = self._xml_decimal(line.quantity, places=6).rstrip('0').rstrip('.') or '0'
ET.SubElement(row, 'P_9A').text = self._xml_decimal(line.unit_net, places=8).rstrip('0').rstrip('.') or '0'
ET.SubElement(row, 'P_11').text = self._xml_decimal(line.net_amount)
ET.SubElement(row, 'P_12').text = self._xml_decimal(line.vat_rate, places=2).rstrip('0').rstrip('.') or '0'
if Decimal(line.vat_amount or 0):
ET.SubElement(row, 'P_12Z').text = self._xml_decimal(line.vat_amount)
stopka_parts = []
if getattr(invoice, 'split_payment', False):
stopka_parts.append('Mechanizm podzielonej płatności.')
seller_bank_account = self._resolve_seller_bank_account(invoice)
if seller_bank_account:
stopka_parts.append(f'Rachunek bankowy: {seller_bank_account}')
if nfz_meta.get('contract_number'):
stopka_parts.append(f"NFZ umowa: {nfz_meta.get('contract_number')}")
if stopka_parts:
stopka = ET.SubElement(root, 'Stopka')
info = ET.SubElement(stopka, 'Informacje')
ET.SubElement(info, 'StopkaFaktury').text = ' '.join(stopka_parts)
return ET.tostring(root, encoding='utf-8', xml_declaration=True).decode('utf-8')
def persist_issued_assets(self, invoice, xml_content=None):
db.session.flush()
invoice.html_preview = self.render_invoice_html(invoice)
xml_content = xml_content or self.build_ksef_payload(invoice).get("xml_content")
if xml_content:
invoice.source_hash = KSeFService.calc_hash(xml_content)
invoice.xml_path = self._save_xml(invoice.company_id, invoice.ksef_number or invoice.invoice_number, xml_content)
PdfService = __import__("app.services.pdf_service", fromlist=["PdfService"]).PdfService
PdfService().render_invoice_pdf(invoice)
return xml_content
@staticmethod
def invoice_locked(invoice):
return bool(
getattr(invoice, "issued_to_ksef_at", None)
or getattr(invoice, "issued_status", "") in {"issued", "issued_mock"}
)
@staticmethod
def today_date():
return date.today()
@staticmethod
def utcnow():
return datetime.utcnow()
@staticmethod
def period_title(period):
return InvoiceService.PERIOD_LABELS.get(period, InvoiceService.PERIOD_LABELS["month"])

1279
app/services/ksef_service.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
from __future__ import annotations
from datetime import datetime
from email.message import EmailMessage
import smtplib
from app.extensions import db
from app.models.invoice import Invoice, MailDelivery
from app.services.pdf_service import PdfService
from app.services.settings_service import SettingsService
class MailService:
def __init__(self, company_id=None):
self.company_id = company_id
def _smtp_config(self):
security_mode = (SettingsService.get_effective('mail.security_mode', '', company_id=self.company_id) or '').strip().lower()
if security_mode not in {'tls', 'ssl', 'none'}:
security_mode = 'tls' if str(SettingsService.get_effective('mail.tls', 'true', company_id=self.company_id)).lower() == 'true' else 'none'
return {
'server': (SettingsService.get_effective('mail.server', '', company_id=self.company_id) or '').strip(),
'port': int(SettingsService.get_effective('mail.port', '587', company_id=self.company_id) or 587),
'username': (SettingsService.get_effective('mail.username', '', company_id=self.company_id) or '').strip(),
'password': (SettingsService.get_effective_secret('mail.password', '', company_id=self.company_id) or '').strip(),
'sender': (SettingsService.get_effective('mail.sender', '', company_id=self.company_id) or '').strip(),
'security_mode': security_mode,
}
def send_invoice(self, invoice: Invoice, recipient: str, subject: str | None = None, body: str | None = None):
subject = subject or f'Faktura {invoice.invoice_number}'
body = body or f'W załączeniu faktura {invoice.invoice_number}.'
delivery = MailDelivery(invoice_id=invoice.id, recipient=recipient, subject=subject, status='queued')
db.session.add(delivery)
db.session.flush()
pdf_bytes, path = PdfService().render_invoice_pdf(invoice)
try:
self.send_mail(
recipient,
subject,
body,
[(path.name, 'application/pdf', pdf_bytes)]
)
delivery.status = 'sent'
delivery.sent_at = datetime.utcnow()
except Exception as exc:
delivery.status = 'error'
delivery.error_message = str(exc)
db.session.commit()
return delivery
def send_mail(self, recipient: str, subject: str, body: str, attachments=None):
cfg = self._smtp_config()
attachments = attachments or []
if not cfg['server']:
raise RuntimeError('SMTP server not configured')
sender = cfg['sender'] or cfg['username']
if not sender:
raise RuntimeError('SMTP sender not configured')
message = EmailMessage()
message['Subject'] = subject
message['From'] = sender
message['To'] = recipient
message.set_content(body)
for filename, mime, data in attachments:
maintype, subtype = mime.split('/', 1)
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
if cfg['security_mode'] == 'ssl':
smtp = smtplib.SMTP_SSL(cfg['server'], cfg['port'])
else:
smtp = smtplib.SMTP(cfg['server'], cfg['port'])
with smtp:
smtp.ehlo()
if cfg['security_mode'] == 'tls':
smtp.starttls()
smtp.ehlo()
if cfg['username']:
smtp.login(cfg['username'], cfg['password'])
smtp.send_message(message)
return {'status': 'sent'}
def send_test_mail(self, recipient: str):
return self.send_mail(
recipient,
'KSeF Manager - test SMTP',
'To jest testowa wiadomość z aplikacji KSeF Manager.'
)
def retry_delivery(self, delivery_id):
delivery = db.session.get(MailDelivery, delivery_id)
return self.send_invoice(delivery.invoice, delivery.recipient, delivery.subject)

View File

@@ -0,0 +1,104 @@
from datetime import datetime
from decimal import Decimal
import requests
from app.extensions import db
from app.models.company import UserCompanyAccess
from app.models.invoice import NotificationLog
from app.models.user import User
from app.services.mail_service import MailService
from app.services.settings_service import SettingsService
class NotificationService:
def __init__(self, company_id=None):
self.company_id = company_id
def should_notify(self, invoice):
if SettingsService.get_effective('notify.enabled', 'false', company_id=self.company_id) != 'true':
return False
min_amount = Decimal(SettingsService.get_effective('notify.min_amount', '0', company_id=self.company_id) or '0')
return Decimal(invoice.gross_amount) >= min_amount
def _pushover_credentials(self):
return {
'token': SettingsService.get_effective_secret('notify.pushover_api_token', '', company_id=self.company_id),
'user': SettingsService.get_effective('notify.pushover_user_key', '', company_id=self.company_id),
}
def _email_recipients(self):
if not self.company_id:
return []
rows = (
db.session.query(User.email)
.join(UserCompanyAccess, UserCompanyAccess.user_id == User.id)
.filter(UserCompanyAccess.company_id == self.company_id, User.is_blocked.is_(False))
.order_by(User.email.asc())
.all()
)
recipients = []
seen = set()
for (email,) in rows:
email = (email or '').strip().lower()
if not email or email in seen:
continue
seen.add(email)
recipients.append(email)
return recipients
def notify_new_invoice(self, invoice):
if not self.should_notify(invoice):
return []
message = f'Nowa faktura {invoice.invoice_number} / {invoice.contractor_name} / {invoice.gross_amount} PLN'
logs = [self._send_email_notification(invoice, message)]
logs.append(self._send_pushover(invoice.id, message))
return logs
def log_channel(self, invoice_id, channel, status, message):
log = NotificationLog(invoice_id=invoice_id, channel=channel, status=status, message=message, sent_at=datetime.utcnow())
db.session.add(log)
db.session.commit()
return log
def _send_email_notification(self, invoice, message):
recipients = self._email_recipients()
if not recipients:
return self.log_channel(invoice.id, 'email', 'skipped', 'Brak odbiorców e-mail przypisanych do aktywnej firmy')
mailer = MailService(company_id=self.company_id)
subject = f'Nowa faktura: {invoice.invoice_number}'
body = (
'W systemie KSeF Manager pojawiła się nowa faktura.\n\n'
f'Numer: {invoice.invoice_number}\n'
f'Kontrahent: {invoice.contractor_name}\n'
f'Kwota brutto: {invoice.gross_amount} PLN\n'
)
sent = 0
errors = []
for recipient in recipients:
try:
mailer.send_mail(recipient, subject, body)
sent += 1
except Exception as exc:
errors.append(f'{recipient}: {exc}')
if sent and not errors:
return self.log_channel(invoice.id, 'email', 'sent', f'{message} · odbiorców: {sent}')
if sent and errors:
return self.log_channel(invoice.id, 'email', 'error', f"Wysłano do {sent}/{len(recipients)} odbiorców. Błędy: {'; '.join(errors[:3])}")
return self.log_channel(invoice.id, 'email', 'error', 'Nie udało się wysłać powiadomień e-mail: ' + '; '.join(errors[:3]))
def _send_pushover(self, invoice_id, message):
creds = self._pushover_credentials()
if not creds['token'] or not creds['user']:
return self.log_channel(invoice_id, 'pushover', 'skipped', 'Brak konfiguracji Pushover')
try:
response = requests.post('https://api.pushover.net/1/messages.json', data={'token': creds['token'], 'user': creds['user'], 'message': message}, timeout=10)
response.raise_for_status()
return self.log_channel(invoice_id, 'pushover', 'sent', message)
except Exception as exc:
return self.log_channel(invoice_id, 'pushover', 'error', str(exc))
def send_test_pushover(self):
return self._send_pushover(None, 'KSeF Manager - test Pushover')

609
app/services/pdf_service.py Normal file
View File

@@ -0,0 +1,609 @@
from __future__ import annotations
from decimal import Decimal
from io import BytesIO
from pathlib import Path
import xml.etree.ElementTree as ET
from flask import current_app
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from app.models.invoice import InvoiceType
class PdfService:
def __init__(self):
self.font_name = self._register_font()
def _register_font(self):
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
]
for path in candidates:
if Path(path).exists():
pdfmetrics.registerFont(TTFont("AppUnicode", path))
return "AppUnicode"
return "Helvetica"
def _styles(self):
styles = getSampleStyleSheet()
base = self.font_name
styles["Normal"].fontName = base
styles["Normal"].fontSize = 9.5
styles["Normal"].leading = 12
styles.add(ParagraphStyle(name="DocTitle", fontName=base, fontSize=18, leading=22, spaceAfter=4))
styles.add(ParagraphStyle(name="SectionTitle", fontName=base, fontSize=10, leading=12, spaceAfter=4))
styles.add(ParagraphStyle(name="Small", fontName=base, fontSize=8, leading=10))
styles.add(ParagraphStyle(name="Right", fontName=base, fontSize=9.5, leading=12, alignment=2))
return styles
@staticmethod
def _money(value, currency="PLN") -> str:
return f"{Decimal(value):,.2f} {currency or 'PLN'}".replace(",", " ").replace(".", ",")
def _safe(self, value) -> str:
return str(value or "-").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _table_base_style(self, extra=None):
styles = [
("FONTNAME", (0, 0), (-1, -1), self.font_name),
("BOX", (0, 0), (-1, -1), 0.7, colors.black),
("INNERGRID", (0, 0), (-1, -1), 0.5, colors.black),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]
if extra:
styles.extend(extra)
return TableStyle(styles)
def _extract_lines_from_xml(self, xml_path):
def to_decimal(value, default='0'):
raw = str(value or '').strip()
if not raw:
return Decimal(default)
raw = raw.replace(' ', '').replace(',', '.')
try:
return Decimal(raw)
except Exception:
return Decimal(default)
def to_vat_rate(value):
raw = str(value or '').strip().lower()
if not raw:
return Decimal('0')
raw = raw.replace('%', '').replace(' ', '').replace(',', '.')
try:
return Decimal(raw)
except Exception:
return Decimal('0')
def looks_numeric(value):
raw = str(value or '').strip().replace(' ', '').replace(',', '.')
if not raw:
return False
try:
Decimal(raw)
return True
except Exception:
return False
if not xml_path:
return []
try:
xml_content = Path(xml_path).read_text(encoding='utf-8')
root = ET.fromstring(xml_content)
namespace_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else ''
ns = {'fa': namespace_uri} if namespace_uri else {}
row_path = './/fa:FaWiersz' if ns else './/FaWiersz'
text_path = lambda name: f'fa:{name}' if ns else name
lines = []
for row in root.findall(row_path, ns):
p_8a = row.findtext(text_path('P_8A'), default='', namespaces=ns)
p_8b = row.findtext(text_path('P_8B'), default='', namespaces=ns)
if looks_numeric(p_8a):
qty_raw = p_8a
unit_raw = p_8b or 'szt.'
else:
unit_raw = p_8a or 'szt.'
qty_raw = p_8b or '1'
net = to_decimal(
row.findtext(text_path('P_11'), default='', namespaces=ns)
or row.findtext(text_path('P_11A'), default='', namespaces=ns)
or '0'
)
vat = to_decimal(
row.findtext(text_path('P_12Z'), default='', namespaces=ns)
or row.findtext(text_path('P_11Vat'), default='', namespaces=ns)
or '0'
)
lines.append({
'description': (row.findtext(text_path('P_7'), default='', namespaces=ns) or '').strip(),
'quantity': to_decimal(qty_raw, '1'),
'unit': (unit_raw or 'szt.').strip(),
'unit_net': to_decimal(
row.findtext(text_path('P_9A'), default='', namespaces=ns)
or row.findtext(text_path('P_9B'), default='', namespaces=ns)
or '0'
),
'vat_rate': to_vat_rate(row.findtext(text_path('P_12'), default='0', namespaces=ns)),
'net_amount': net,
'vat_amount': vat,
'gross_amount': net + vat,
})
return lines
except Exception as exc:
current_app.logger.warning(f'PDF XML line parse error: {exc}')
return []
@staticmethod
def _first_non_empty(*values, default=""):
for value in values:
if value is None:
continue
if isinstance(value, str):
if value.strip():
return value.strip()
continue
text = str(value).strip()
if text:
return text
return default
@staticmethod
def _normalize_metadata_container(value):
return value if isinstance(value, dict) else {}
def _get_external_metadata(self, invoice):
return self._normalize_metadata_container(getattr(invoice, "external_metadata", {}) or {})
def _get_ksef_metadata(self, invoice):
external_metadata = self._get_external_metadata(invoice)
ksef_meta = self._normalize_metadata_container(external_metadata.get("ksef", {}) or {})
return ksef_meta
def _resolve_purchase_seller_data(self, invoice, fallback_name, fallback_tax):
external_metadata = self._get_external_metadata(invoice)
ksef_meta = self._get_ksef_metadata(invoice)
seller_name = self._first_non_empty(
getattr(invoice, "contractor_name", ""),
external_metadata.get("contractor_name"),
ksef_meta.get("contractor_name"),
fallback_name,
)
seller_tax = self._first_non_empty(
getattr(invoice, "contractor_nip", ""),
external_metadata.get("contractor_nip"),
ksef_meta.get("contractor_nip"),
fallback_tax,
)
seller_street = self._first_non_empty(
external_metadata.get("contractor_street"),
ksef_meta.get("contractor_street"),
external_metadata.get("seller_street"),
ksef_meta.get("seller_street"),
)
seller_city = self._first_non_empty(
external_metadata.get("contractor_city"),
ksef_meta.get("contractor_city"),
external_metadata.get("seller_city"),
ksef_meta.get("seller_city"),
)
seller_postal_code = self._first_non_empty(
external_metadata.get("contractor_postal_code"),
ksef_meta.get("contractor_postal_code"),
external_metadata.get("seller_postal_code"),
ksef_meta.get("seller_postal_code"),
)
seller_country = self._first_non_empty(
external_metadata.get("contractor_country"),
ksef_meta.get("contractor_country"),
external_metadata.get("seller_country"),
ksef_meta.get("seller_country"),
)
seller_address = self._first_non_empty(
external_metadata.get("contractor_address"),
ksef_meta.get("contractor_address"),
external_metadata.get("seller_address"),
ksef_meta.get("seller_address"),
getattr(invoice, "contractor_address", ""),
)
if not seller_address:
address_parts = [part for part in [seller_street, seller_postal_code, seller_city, seller_country] if part]
seller_address = ", ".join(address_parts)
return {
"name": seller_name,
"tax": seller_tax,
"address": seller_address,
}
@staticmethod
def _normalize_bank_account(value):
return ''.join(str(value or '').split())
def _extract_payment_details_from_xml(self, xml_content):
details = {'payment_form_code': '', 'payment_form_label': '', 'bank_account': '', 'bank_name': '', 'payment_due_date': ''}
if not xml_content:
return details
try:
root = ET.fromstring(xml_content)
namespace_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else ''
ns = {'fa': namespace_uri} if namespace_uri else {}
def find_text(path):
return (root.findtext(path, default='', namespaces=ns) or '').strip()
form_code = find_text('.//fa:Platnosc/fa:FormaPlatnosci' if ns else './/Platnosc/FormaPlatnosci')
bank_account = find_text('.//fa:Platnosc/fa:RachunekBankowy/fa:NrRB' if ns else './/Platnosc/RachunekBankowy/NrRB')
bank_name = find_text('.//fa:Platnosc/fa:RachunekBankowy/fa:NazwaBanku' if ns else './/Platnosc/RachunekBankowy/NazwaBanku')
payment_due_date = find_text('.//fa:Platnosc/fa:TerminPlatnosci/fa:Termin' if ns else './/Platnosc/TerminPlatnosci/Termin')
labels = {'1': 'gotówka', '2': 'karta', '3': 'bon', '4': 'czek', '5': 'weksel', '6': 'przelew', '7': 'kompensata', '8': 'pobranie', '9': 'akredytywa', '10': 'polecenie zapłaty', '11': 'inny'}
details.update({'payment_form_code': form_code, 'payment_form_label': labels.get(form_code, form_code), 'bank_account': self._normalize_bank_account(bank_account), 'bank_name': bank_name, 'payment_due_date': payment_due_date})
except Exception:
pass
return details
def _resolve_payment_details(self, invoice):
external_metadata = self._get_external_metadata(invoice)
details = {
'payment_form_code': self._first_non_empty(external_metadata.get('payment_form_code')),
'payment_form_label': self._first_non_empty(external_metadata.get('payment_form_label')),
'bank_account': self._normalize_bank_account(self._first_non_empty(getattr(invoice, 'seller_bank_account', ''), external_metadata.get('seller_bank_account'))),
'bank_name': self._first_non_empty(external_metadata.get('seller_bank_name')),
'payment_due_date': self._first_non_empty(external_metadata.get('payment_due_date')),
}
if getattr(invoice, 'xml_path', None) and (not details['bank_account'] or not details['payment_form_code']):
try:
xml_content = Path(invoice.xml_path).read_text(encoding='utf-8')
parsed = self._extract_payment_details_from_xml(xml_content)
for key, value in parsed.items():
if value and not details.get(key):
details[key] = value
except Exception:
pass
return details
def _resolve_seller_bank_account(self, invoice):
details = self._resolve_payment_details(invoice)
account = self._normalize_bank_account(details.get('bank_account', ''))
if account:
return account
if getattr(invoice, 'invoice_type', None) == InvoiceType.PURCHASE:
return ''
company = getattr(invoice, 'company', None)
return self._normalize_bank_account(getattr(company, 'bank_account', '') if company else '')
def _build_pdf_filename_stem(self, invoice):
raw = self._first_non_empty(
getattr(invoice, "invoice_number", ""),
getattr(invoice, "ksef_number", ""),
"Faktura",
)
return raw.replace("/", "_")
def _build_pdf_title(self, invoice, invoice_kind, company_name):
return self._first_non_empty(
getattr(invoice, "invoice_number", ""),
getattr(invoice, "ksef_number", ""),
company_name,
invoice_kind,
"Faktura",
)
@staticmethod
def _set_pdf_metadata(canvas, doc, title, author, subject, creator="KSeF Manager"):
try:
canvas.setTitle(title)
except Exception:
pass
try:
canvas.setAuthor(author)
except Exception:
pass
try:
canvas.setSubject(subject)
except Exception:
pass
try:
canvas.setCreator(creator)
except Exception:
pass
def render_invoice_pdf(self, invoice):
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
leftMargin=14 * mm,
rightMargin=14 * mm,
topMargin=14 * mm,
bottomMargin=14 * mm,
)
styles = self._styles()
story = []
company_name = self._safe(invoice.company.name if invoice.company else "Twoja firma")
company_tax = self._safe(getattr(invoice.company, "tax_id", "") if invoice.company else "")
company_address = self._safe(getattr(invoice.company, "address", "") if invoice.company else "")
customer = getattr(invoice, "customer", None)
customer_name = self._safe(getattr(customer, "name", invoice.contractor_name))
customer_tax = self._safe(getattr(customer, "tax_id", invoice.contractor_nip))
customer_address = self._safe(getattr(customer, "address", ""))
customer_email = self._safe(getattr(customer, "email", ""))
if invoice.invoice_type == InvoiceType.PURCHASE:
purchase_seller = self._resolve_purchase_seller_data(
invoice=invoice,
fallback_name=invoice.contractor_name,
fallback_tax=invoice.contractor_nip,
)
seller_name = self._safe(purchase_seller["name"])
seller_tax = self._safe(purchase_seller["tax"])
seller_address = self._safe(purchase_seller["address"])
buyer_name = company_name
buyer_tax = company_tax
buyer_address = company_address
buyer_email = ""
else:
seller_name = company_name
seller_tax = company_tax
seller_address = company_address
buyer_name = customer_name
buyer_tax = customer_tax
buyer_address = customer_address
buyer_email = customer_email
nfz_meta = (invoice.external_metadata or {}).get("nfz", {})
invoice_kind = "FAKTURA NFZ" if invoice.source == "nfz" else "FAKTURA VAT"
currency = invoice.currency or "PLN"
pdf_title = self._build_pdf_title(invoice, invoice_kind, company_name)
pdf_author = self._first_non_empty(
invoice.company.name if invoice.company else "",
"KSeF Manager",
)
pdf_subject = self._first_non_empty(
invoice_kind,
getattr(invoice, "source", ""),
"Faktura",
)
story.append(Paragraph(invoice_kind, styles["DocTitle"]))
header = Table(
[
[
Paragraph(
f"<b>Numer faktury:</b> {self._safe(invoice.invoice_number)}<br/>"
f"<b>Data wystawienia:</b> {self._safe(invoice.issue_date)}<br/>"
f"<b>Waluta:</b> {self._safe(currency)}",
styles["Normal"],
),
Paragraph(
f"<b>Numer KSeF:</b> {self._safe(invoice.ksef_number)}<br/>"
f"<b>Status:</b> {self._safe(invoice.issued_status)}<br/>"
f"<b>Typ źródła:</b> {self._safe(invoice.source)}",
styles["Normal"],
),
]
],
colWidths=[88 * mm, 88 * mm],
)
header.setStyle(self._table_base_style())
story.extend([header, Spacer(1, 5 * mm)])
buyer_email_html = f"<br/>E-mail: {buyer_email}" if buyer_email not in {"", "-"} else ""
payment_details = self._resolve_payment_details(invoice)
seller_bank_account = self._safe(self._resolve_seller_bank_account(invoice))
payment_form_html = (
f"<br/>Forma płatności: {self._safe(payment_details.get('payment_form_label'))}"
if payment_details.get('payment_form_label') else ''
)
seller_bank_account_html = (
f"<br/>Rachunek: {seller_bank_account}"
if seller_bank_account not in {"", "-"}
else ""
)
seller_bank_name_html = (
f"<br/>Bank: {self._safe(payment_details.get('bank_name'))}"
if payment_details.get('bank_name') else ''
)
parties = Table(
[
[
Paragraph(
f"<b>Sprzedawca</b><br/>{seller_name}<br/>"
f"NIP: {seller_tax}<br/>"
f"Adres: {seller_address}"
f"{payment_form_html}"
f"{seller_bank_account_html}"
f"{seller_bank_name_html}",
styles["Normal"],
),
Paragraph(
f"<b>Nabywca</b><br/>{buyer_name}<br/>"
f"NIP: {buyer_tax}<br/>"
f"Adres: {buyer_address}"
f"{buyer_email_html}",
styles["Normal"],
),
]
],
colWidths=[88 * mm, 88 * mm],
)
parties.setStyle(self._table_base_style())
story.extend([parties, Spacer(1, 5 * mm)])
if getattr(invoice, 'split_payment', False):
story.extend([Paragraph('Mechanizm podzielonej płatności', styles['SectionTitle']), Spacer(1, 2 * mm)])
if nfz_meta:
nfz_rows = [
[Paragraph("<b>Pole NFZ</b>", styles["Normal"]), Paragraph("<b>Wartość</b>", styles["Normal"])],
[Paragraph("Oddział NFZ (IDWew)", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("recipient_branch_id")), styles["Normal"])],
[Paragraph("Nazwa oddziału", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("recipient_branch_name")), styles["Normal"])],
[Paragraph("Okres rozliczeniowy od", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("settlement_from")), styles["Normal"])],
[Paragraph("Okres rozliczeniowy do", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("settlement_to")), styles["Normal"])],
[Paragraph("Identyfikator świadczeniodawcy", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("provider_identifier")), styles["Normal"])],
[Paragraph("Kod zakresu / świadczenia", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("service_code")), styles["Normal"])],
[Paragraph("Numer umowy / aneksu", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("contract_number")), styles["Normal"])],
[Paragraph("Identyfikator szablonu", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("template_identifier")), styles["Normal"])],
[Paragraph("Schemat", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("nfz_schema", "FA(3)")), styles["Normal"])],
]
nfz_table = Table(nfz_rows, colWidths=[62 * mm, 114 * mm], repeatRows=1)
nfz_table.setStyle(self._table_base_style([("ALIGN", (0, 0), (-1, 0), "CENTER")]))
story.extend([Paragraph("Dane NFZ", styles["SectionTitle"]), nfz_table, Spacer(1, 5 * mm)])
invoice_lines = invoice.lines.order_by("id").all() if hasattr(invoice.lines, "order_by") else list(invoice.lines)
if not invoice_lines:
invoice_lines = [
type("TmpLine", (), line)
for line in self._extract_lines_from_xml(getattr(invoice, "xml_path", None))
]
lines = [[
Paragraph("<b>Pozycja</b>", styles["Normal"]),
Paragraph("<b>Ilość</b>", styles["Normal"]),
Paragraph("<b>JM</b>", styles["Normal"]),
Paragraph("<b>VAT</b>", styles["Normal"]),
Paragraph("<b>Netto</b>", styles["Normal"]),
Paragraph("<b>Brutto</b>", styles["Normal"]),
]]
if invoice_lines:
for line in invoice_lines:
lines.append([
Paragraph(self._safe(line.description), styles["Normal"]),
Paragraph(self._safe(line.quantity), styles["Right"]),
Paragraph(self._safe(line.unit), styles["Right"]),
Paragraph(f"{Decimal(line.vat_rate):.0f}%", styles["Right"]),
Paragraph(self._money(line.net_amount, currency), styles["Right"]),
Paragraph(self._money(line.gross_amount, currency), styles["Right"]),
])
else:
lines.append([
Paragraph("Brak pozycji na fakturze.", styles["Normal"]),
Paragraph("-", styles["Right"]),
Paragraph("-", styles["Right"]),
Paragraph("-", styles["Right"]),
Paragraph("-", styles["Right"]),
Paragraph("-", styles["Right"]),
])
items = Table(
lines,
colWidths=[82 * mm, 18 * mm, 16 * mm, 16 * mm, 28 * mm, 30 * mm],
repeatRows=1,
)
items.setStyle(self._table_base_style([
("ALIGN", (1, 0), (-1, -1), "RIGHT"),
("ALIGN", (0, 0), (0, -1), "LEFT"),
]))
story.extend([Paragraph("Pozycje faktury", styles["SectionTitle"]), items, Spacer(1, 5 * mm)])
summary = Table(
[
[Paragraph("Netto", styles["Normal"]), Paragraph(self._money(invoice.net_amount, currency), styles["Right"])],
[Paragraph("VAT", styles["Normal"]), Paragraph(self._money(invoice.vat_amount, currency), styles["Right"])],
[Paragraph("<b>Razem brutto</b>", styles["Normal"]), Paragraph(f"<b>{self._money(invoice.gross_amount, currency)}</b>", styles["Right"])],
],
colWidths=[48 * mm, 42 * mm],
)
summary.setStyle(self._table_base_style([
("ALIGN", (0, 0), (-1, -1), "RIGHT"),
("ALIGN", (0, 0), (0, -1), "LEFT"),
]))
summary_wrap = Table([["", summary]], colWidths=[86 * mm, 90 * mm])
summary_wrap.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP")]))
story.extend([summary_wrap, Spacer(1, 5 * mm)])
note = (
"Dokument zawiera pola wymagane dla rozliczeń NFZ i został przygotowany do wysyłki w schemacie FA(3)."
if nfz_meta
else "Dokument wygenerowany przez KSeF Manager."
)
story.append(Paragraph(note, styles["Small"]))
def _apply_metadata(canvas, pdf_doc):
self._set_pdf_metadata(
canvas=canvas,
doc=pdf_doc,
title=pdf_title,
author=pdf_author,
subject=pdf_subject,
)
doc.build(story, onFirstPage=_apply_metadata, onLaterPages=_apply_metadata)
pdf_bytes = buffer.getvalue()
path = Path(current_app.config["PDF_PATH"]) / f"{self._build_pdf_filename_stem(invoice)}.pdf"
path.write_bytes(pdf_bytes)
invoice.pdf_path = str(path)
return pdf_bytes, path
def month_pdf(self, entries, title):
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
leftMargin=16 * mm,
rightMargin=16 * mm,
topMargin=16 * mm,
bottomMargin=16 * mm,
)
styles = self._styles()
rows = [[Paragraph("<b>Numer</b>", styles["Normal"]), Paragraph("<b>Kontrahent</b>", styles["Normal"]), Paragraph("<b>Brutto</b>", styles["Normal"])]]
for invoice in entries:
rows.append([
Paragraph(str(invoice.invoice_number), styles["Normal"]),
Paragraph(str(invoice.contractor_name), styles["Normal"]),
Paragraph(self._money(invoice.gross_amount, getattr(invoice, "currency", "PLN")), styles["Right"]),
])
table = Table(rows, colWidths=[45 * mm, 95 * mm, 35 * mm], repeatRows=1)
table.setStyle(self._table_base_style([("ALIGN", (2, 0), (2, -1), "RIGHT")]))
pdf_title = self._first_non_empty(title, "Zestawienie faktur")
pdf_author = "KSeF Manager"
pdf_subject = "Miesięczne zestawienie faktur"
def _apply_metadata(canvas, pdf_doc):
self._set_pdf_metadata(
canvas=canvas,
doc=pdf_doc,
title=pdf_title,
author=pdf_author,
subject=pdf_subject,
)
doc.build([Paragraph(title, styles["DocTitle"]), Spacer(1, 4 * mm), table], onFirstPage=_apply_metadata, onLaterPages=_apply_metadata)
return buffer.getvalue()

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
import json
import time
from threading import Lock
from typing import Any
from urllib.parse import urlparse
from flask import current_app
from redis import Redis
from redis.exceptions import RedisError
class RedisService:
_memory_store: dict[str, tuple[float | None, str]] = {}
_lock = Lock()
_failure_logged_at = 0.0
_availability_cache: tuple[bool, float] = (False, 0.0)
@classmethod
def _config(cls, app=None):
if app is not None:
return app.config
return current_app.config
@classmethod
def enabled(cls, app=None) -> bool:
cfg = cls._config(app)
return str(cfg.get('REDIS_URL', 'memory://')).strip().lower().startswith('redis://')
@classmethod
def url(cls, app=None) -> str:
cfg = cls._config(app)
return str(cfg.get('REDIS_URL', 'memory://')).strip() or 'memory://'
@classmethod
def _logger(cls, app=None):
if app is not None:
return app.logger
return current_app.logger
@classmethod
def _log_failure_once(cls, action: str, exc: Exception, app=None) -> None:
now = time.time()
if now - cls._failure_logged_at < 60:
return
cls._failure_logged_at = now
cls._logger(app).warning(
'Redis %s niedostępny, przełączam na cache pamięciowy: %s', action, exc
)
@classmethod
def client(cls, app=None) -> Redis | None:
if not cls.enabled(app):
return None
try:
return Redis.from_url(
cls.url(app),
decode_responses=True,
socket_connect_timeout=1,
socket_timeout=1,
)
except Exception as exc:
cls._log_failure_once('client', exc, app)
return None
@classmethod
def available(cls, app=None) -> bool:
if not cls.enabled(app):
return False
cached_ok, checked_at = cls._availability_cache
if time.time() - checked_at < 15:
return cached_ok
client = cls.client(app)
if client is None:
cls._availability_cache = (False, time.time())
return False
try:
client.ping()
cls._availability_cache = (True, time.time())
return True
except RedisError as exc:
cls._log_failure_once('ping', exc, app)
cls._availability_cache = (False, time.time())
return False
except Exception as exc:
cls._log_failure_once('ping', exc, app)
cls._availability_cache = (False, time.time())
return False
@classmethod
def ping(cls, app=None) -> tuple[str, str]:
url = cls.url(app)
if not cls.enabled(app):
return 'disabled', 'Cache pamięciowy aktywny (Redis wyłączony).'
if cls.available(app):
return 'ok', f'{url} · połączenie aktywne'
parsed = urlparse(url)
hint = ''
if parsed.hostname in {'localhost', '127.0.0.1'}:
hint = ' · w Dockerze użyj nazwy usługi redis zamiast localhost'
return 'fallback', f'{url} · brak połączenia, aktywny fallback pamięciowy{hint}'
@classmethod
def _memory_get(cls, key: str) -> Any | None:
now = time.time()
with cls._lock:
payload = cls._memory_store.get(key)
if not payload:
return None
expires_at, raw = payload
if expires_at is not None and expires_at <= now:
cls._memory_store.pop(key, None)
return None
try:
return json.loads(raw)
except Exception:
return None
@classmethod
def _memory_set(cls, key: str, value: Any, ttl: int = 60) -> bool:
expires_at = None if ttl <= 0 else time.time() + ttl
with cls._lock:
cls._memory_store[key] = (expires_at, json.dumps(value, default=str))
return True
@classmethod
def get_json(cls, key: str, app=None) -> Any | None:
if not cls.enabled(app) or not cls.available(app):
return cls._memory_get(key)
client = cls.client(app)
if client is None:
return cls._memory_get(key)
try:
raw = client.get(key)
if raw:
return json.loads(raw)
return cls._memory_get(key)
except Exception as exc:
cls._log_failure_once(f'get_json({key})', exc, app)
return cls._memory_get(key)
@classmethod
def set_json(cls, key: str, value: Any, ttl: int = 60, app=None) -> bool:
cls._memory_set(key, value, ttl=ttl)
if not cls.enabled(app) or not cls.available(app):
return False
client = cls.client(app)
if client is None:
return False
try:
client.setex(key, ttl, json.dumps(value, default=str))
return True
except Exception as exc:
cls._log_failure_once(f'set_json({key})', exc, app)
return False
@classmethod
def delete(cls, key: str, app=None) -> None:
with cls._lock:
cls._memory_store.pop(key, None)
if not cls.enabled(app) or not cls.available(app):
return
client = cls.client(app)
if client is None:
return
try:
client.delete(key)
except Exception as exc:
cls._log_failure_once(f'delete({key})', exc, app)

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from pathlib import Path
from flask import current_app
from app.extensions import db
from app.models.setting import AppSetting
from app.services.company_service import CompanyService
class SettingsService:
@staticmethod
def _user_scope_key(key: str, user_id=None):
from flask_login import current_user
if user_id is None and getattr(current_user, 'is_authenticated', False):
user_id = current_user.id
return f'user.{user_id}.{key}' if user_id else key
@staticmethod
def _scope_key(key: str, company_id=None):
if company_id is None:
company = CompanyService.get_current_company()
company_id = company.id if company else None
return f'company.{company_id}.{key}' if company_id else key
@staticmethod
def get(key, default=None, company_id=None):
scoped = AppSetting.get(SettingsService._scope_key(key, company_id), default=None)
if scoped is not None:
return scoped
return AppSetting.get(key, default=default)
@staticmethod
def get_secret(key, default=None, company_id=None):
scoped = AppSetting.get(SettingsService._scope_key(key, company_id), default=None, decrypt=True)
if scoped is not None:
return scoped
return AppSetting.get(key, default=default, decrypt=True)
@staticmethod
def set_many(mapping: dict[str, tuple[object, bool] | object], company_id=None):
for key, value in mapping.items():
if isinstance(value, tuple):
raw, encrypt = value
else:
raw, encrypt = value, False
AppSetting.set(SettingsService._scope_key(key, company_id), raw, encrypt=encrypt)
db.session.commit()
@staticmethod
def storage_path(key: str, fallback: Path):
value = SettingsService.get(key)
if value:
path = Path(value)
if not path.is_absolute():
path = Path(current_app.root_path).parent / path
path.mkdir(parents=True, exist_ok=True)
return path
fallback.mkdir(parents=True, exist_ok=True)
return fallback
@staticmethod
def read_only_enabled(company_id=None) -> bool:
from flask_login import current_user
company = CompanyService.get_current_company()
cid = company_id or (company.id if company else None)
truly_global_ro = AppSetting.get('app.read_only_mode', 'false') == 'true'
company_ro = AppSetting.get(f'company.{cid}.app.read_only_mode', 'false') == 'true' if cid else False
user_ro = getattr(current_user, 'is_authenticated', False) and cid and current_user.is_company_readonly(cid)
return truly_global_ro or company_ro or bool(user_ro)
@staticmethod
def get_user(key, default=None, user_id=None):
return AppSetting.get(SettingsService._user_scope_key(key, user_id), default=default)
@staticmethod
def get_user_secret(key, default=None, user_id=None):
return AppSetting.get(SettingsService._user_scope_key(key, user_id), default=default, decrypt=True)
@staticmethod
def set_many_user(mapping: dict[str, tuple[object, bool] | object], user_id=None):
for key, value in mapping.items():
if isinstance(value, tuple):
raw, encrypt = value
else:
raw, encrypt = value, False
AppSetting.set(SettingsService._user_scope_key(key, user_id), raw, encrypt=encrypt)
db.session.commit()
@staticmethod
def get_preference(scope_name: str, default='global', user_id=None):
return AppSetting.get(SettingsService._user_scope_key(f'pref.{scope_name}.mode', user_id), default=default)
@staticmethod
def set_preference(scope_name: str, mode: str, user_id=None):
AppSetting.set(SettingsService._user_scope_key(f'pref.{scope_name}.mode', user_id), mode)
db.session.commit()
@staticmethod
def get_effective(key, default=None, company_id=None, user_id=None, scope_name=None, user_default='global'):
scope_name = scope_name or key.split('.', 1)[0]
mode = SettingsService.get_preference(scope_name, default=user_default, user_id=user_id)
if mode == 'user':
value = SettingsService.get_user(key, default=None, user_id=user_id)
if value not in [None, '']:
return value
return SettingsService.get(key, default=default, company_id=company_id)
@staticmethod
def get_effective_secret(key, default=None, company_id=None, user_id=None, scope_name=None, user_default='global'):
scope_name = scope_name or key.split('.', 1)[0]
mode = SettingsService.get_preference(scope_name, default=user_default, user_id=user_id)
if mode == 'user':
value = SettingsService.get_user_secret(key, default=None, user_id=user_id)
if value not in [None, '']:
return value
return SettingsService.get_secret(key, default=default, company_id=company_id)

View File

@@ -0,0 +1,129 @@
from datetime import datetime
from threading import Thread
from flask import current_app
from app.extensions import db
from app.models.company import Company
from app.models.setting import AppSetting
from app.models.sync_log import SyncLog
from app.services.company_service import CompanyService
from app.services.invoice_service import InvoiceService
from app.services.ksef_service import KSeFService
from app.services.notification_service import NotificationService
from app.services.settings_service import SettingsService
from app.services.redis_service import RedisService
class SyncService:
def __init__(self, company=None):
self.company = company or CompanyService.get_current_company()
self.ksef = KSeFService(company_id=self.company.id if self.company else None)
self.invoice_service = InvoiceService()
self.notification_service = NotificationService(company_id=self.company.id if self.company else None)
def _run(self, sync_type='manual', existing_log=None):
log = existing_log or SyncLog(
company_id=self.company.id if self.company else None,
sync_type=sync_type,
status='started',
started_at=datetime.utcnow(),
message='Rozpoczęto synchronizację',
)
db.session.add(log)
db.session.commit()
since_raw = SettingsService.get('ksef.last_sync_at', company_id=self.company.id if self.company else None)
since = datetime.fromisoformat(since_raw) if since_raw else None
created = updated = errors = 0
try:
documents = self.ksef.list_documents(since=since)
log.total = len(documents)
db.session.commit()
for idx, document in enumerate(documents, start=1):
invoice, was_created = self.invoice_service.upsert_from_ksef(document, self.company)
if was_created:
created += 1
self.notification_service.notify_new_invoice(invoice)
else:
updated += 1
log.processed = idx
log.created = created
log.updated = updated
log.message = f'Przetworzono {idx}/{len(documents)}'
db.session.commit()
log.status = 'finished'
log.message = 'Synchronizacja zakończona'
SettingsService.set_many(
{
'ksef.status': 'ready',
'ksef.last_sync_at': datetime.utcnow().isoformat(),
},
company_id=self.company.id if self.company else None,
)
except RuntimeError as exc:
message = str(exc)
if 'HTTP 429' in message or 'ogranicza liczbę zapytań' in message:
current_app.logger.warning('Synchronizacja KSeF wstrzymana przez limit API: %s', message)
else:
current_app.logger.error('Sync failed: %s', message)
log.status = 'error'
log.message = message
errors += 1
SettingsService.set_many(
{'ksef.status': 'error'},
company_id=self.company.id if self.company else None,
)
except Exception as exc:
current_app.logger.exception('Sync failed: %s', exc)
log.status = 'error'
log.message = str(exc)
errors += 1
SettingsService.set_many(
{'ksef.status': 'error'},
company_id=self.company.id if self.company else None,
)
RedisService.delete(f'dashboard.summary.company.{self.company.id if self.company else "global"}')
RedisService.delete(f'health.status.company.{self.company.id if self.company else "global"}')
log.errors = errors
log.finished_at = datetime.utcnow()
db.session.commit()
return log
def run_manual_sync(self):
return self._run('manual')
def run_scheduled_sync(self):
return self._run('scheduled')
@staticmethod
def start_manual_sync_async(app, company_id):
company = db.session.get(Company, company_id)
log = SyncLog(
company_id=company_id,
sync_type='manual',
status='queued',
started_at=datetime.utcnow(),
message='Zadanie zakolejkowane',
total=1,
)
db.session.add(log)
db.session.commit()
log_id = log.id
def worker():
with app.app_context():
company_local = db.session.get(Company, company_id)
log_local = db.session.get(SyncLog, log_id)
log_local.status = 'started'
log_local.message = 'Start pobierania'
db.session.commit()
SyncService(company_local)._run('manual', existing_log=log_local)
Thread(target=worker, daemon=True).start()
return log_id

View File

@@ -0,0 +1,277 @@
from __future__ import annotations
import json
import os
import platform
from pathlib import Path
import psutil
from flask import current_app
from sqlalchemy import inspect
from app.extensions import db
from app.models.audit_log import AuditLog
from app.models.catalog import Customer, InvoiceLine, Product
from app.models.company import Company, UserCompanyAccess
from app.models.invoice import Invoice, MailDelivery, NotificationLog, SyncEvent, Tag
from app.models.setting import AppSetting
from app.models.sync_log import SyncLog
from app.models.user import User
from app.services.ceidg_service import CeidgService
from app.services.company_service import CompanyService
from app.services.health_service import HealthService
from app.services.ksef_service import KSeFService
from app.services.settings_service import SettingsService
class SystemDataService:
APP_MODELS = [
('Użytkownicy', User),
('Firmy', Company),
('Dostępy do firm', UserCompanyAccess),
('Faktury', Invoice),
('Pozycje faktur', InvoiceLine),
('Klienci', Customer),
('Produkty', Product),
('Tagi', Tag),
('Logi synchronizacji', SyncLog),
('Zdarzenia sync', SyncEvent),
('Wysyłki maili', MailDelivery),
('Notyfikacje', NotificationLog),
('Logi audytu', AuditLog),
('Ustawienia', AppSetting),
]
def collect(self) -> dict:
company = CompanyService.get_current_company()
company_id = company.id if company else None
process = self._process_stats()
storage = self._storage_stats()
app = self._app_stats(company)
database = self._database_stats()
health = HealthService().get_status()
ksef = KSeFService(company_id=company_id).diagnostics()
ceidg = CeidgService().diagnostics()
return {
'overview': self._overview_cards(process, storage, app, database, health, ksef, ceidg),
'process': process,
'storage': storage,
'app': app,
'database': database,
'health': health,
'integrations': {
'ksef': ksef,
'ceidg': ceidg,
},
}
def _overview_cards(self, process: dict, storage: list[dict], app: dict, database: dict, health: dict, ksef: dict, ceidg: dict) -> list[dict]:
storage_total = sum(item['size_bytes'] for item in storage)
total_records = sum(item['rows'] for item in database['table_rows'])
return [
{
'label': 'CPU procesu',
'value': f"{process['cpu_percent']:.2f}%",
'subvalue': f"PID {process['pid']} · {process['threads']} wątków",
'icon': 'fa-microchip',
'tone': 'primary',
},
{
'label': 'RAM procesu',
'value': process['rss_human'],
'subvalue': f"System zajęty: {process['system_memory_percent']:.2f}% z {process['system_memory_total']}",
'icon': 'fa-memory',
'tone': 'info',
},
{
'label': 'Katalogi robocze',
'value': self._human_size(storage_total),
'subvalue': f'{len(storage)} lokalizacji monitorowanych',
'icon': 'fa-hard-drive',
'tone': 'warning',
},
{
'label': 'Użytkownicy / firmy',
'value': f"{app['users_count']} / {app['companies_count']}",
'subvalue': f"R/O: {'ON' if app['read_only_global'] else 'OFF'}",
'icon': 'fa-users',
'tone': 'secondary',
},
{
'label': 'Rekordy bazy',
'value': str(total_records),
'subvalue': f"{database['tables_count']} tabel · {database['engine']}",
'icon': 'fa-database',
'tone': 'secondary',
},
{
'label': 'Health',
'value': self._health_summary(health),
'subvalue': f"DB {health.get('db')} · SMTP {health.get('smtp')} · Redis {health.get('redis')}",
'icon': 'fa-heart-pulse',
'tone': 'success' if health.get('db') == 'ok' and health.get('ksef') in ['ok', 'mock'] else 'warning',
},
{
'label': 'KSeF',
'value': ksef.get('status', 'unknown').upper(),
'subvalue': ksef.get('message', 'Brak danych'),
'icon': 'fa-file-invoice',
'tone': 'success' if ksef.get('status') in ['ok', 'mock'] else 'danger',
},
{
'label': 'CEIDG',
'value': ceidg.get('status', 'unknown').upper(),
'subvalue': ceidg.get('message', 'Brak danych'),
'icon': 'fa-building-circle-check',
'tone': 'success' if ceidg.get('status') == 'ok' else 'danger',
},
]
def _process_stats(self) -> dict:
process = psutil.Process(os.getpid())
cpu_percent = process.cpu_percent(interval=0.1)
mem = process.memory_info()
system_mem = psutil.virtual_memory()
try:
open_files = len(process.open_files())
except Exception:
open_files = 0
return {
'pid': process.pid,
'cpu_percent': round(cpu_percent, 2),
'rss_bytes': int(mem.rss),
'rss_human': self._human_size(mem.rss),
'system_memory_total': self._human_size(system_mem.total),
'system_memory_percent': round(system_mem.percent, 2),
'threads': process.num_threads(),
'open_files': open_files,
'platform': platform.platform(),
'python': platform.python_version(),
}
def _storage_stats(self) -> list[dict]:
locations = [
('Instancja', Path(current_app.instance_path)),
('Archiwum XML', SettingsService.storage_path('app.archive_path', current_app.config['ARCHIVE_PATH'])),
('PDF', SettingsService.storage_path('app.pdf_path', current_app.config['PDF_PATH'])),
('Backupy', SettingsService.storage_path('app.backup_path', current_app.config['BACKUP_PATH'])),
('Certyfikaty', SettingsService.storage_path('app.certs_path', current_app.config['CERTS_PATH'])),
]
rows = []
for label, path in locations:
size_bytes = self._dir_size(path)
usage = psutil.disk_usage(str(path if path.exists() else path.parent))
rows.append({
'label': label,
'path': str(path),
'size_bytes': size_bytes,
'size_human': self._human_size(size_bytes),
'disk_total': self._human_size(usage.total),
'disk_free': self._human_size(usage.free),
'disk_percent': round(usage.percent, 2),
})
return rows
def _app_stats(self, company) -> dict:
users_count = User.query.count()
companies_count = Company.query.count()
counts = [{'label': label, 'count': model.query.count()} for label, model in self.APP_MODELS]
counts_sorted = sorted(counts, key=lambda item: item['count'], reverse=True)
return {
'current_company': company.name if company else 'Brak wybranej firmy',
'current_company_id': company.id if company else None,
'read_only_global': AppSetting.get('app.read_only_mode', 'false') == 'true',
'app_timezone': current_app.config.get('APP_TIMEZONE'),
'counts': counts_sorted,
'counts_top': counts_sorted[:6],
'users_count': int(users_count),
'companies_count': int(companies_count),
}
def _database_stats(self) -> dict:
engine = db.engine
inspector = inspect(engine)
table_names = inspector.get_table_names()
table_rows = []
for table_name in table_names:
table = db.metadata.tables.get(table_name)
if table is None:
continue
count = db.session.execute(db.select(db.func.count()).select_from(table)).scalar() or 0
table_rows.append({'table': table_name, 'rows': int(count)})
uri = current_app.config.get('SQLALCHEMY_DATABASE_URI', '')
sqlite_path = None
sqlite_size = None
if uri.startswith('sqlite:///') and not uri.endswith(':memory:'):
sqlite_path = uri.replace('sqlite:///', '', 1)
try:
sqlite_size = self._human_size(Path(sqlite_path).stat().st_size)
except FileNotFoundError:
sqlite_size = 'brak pliku'
table_rows_sorted = sorted(table_rows, key=lambda item: (-item['rows'], item['table']))
return {
'engine': engine.name,
'uri': self._mask_uri(uri),
'tables_count': len(table_rows),
'sqlite_path': sqlite_path,
'sqlite_size': sqlite_size,
'table_rows': table_rows_sorted,
'largest_tables': table_rows_sorted[:6],
}
@staticmethod
def json_preview(payload, max_len: int = 1200) -> str:
if payload is None:
return 'Brak danych.'
if isinstance(payload, str):
text = payload
else:
text = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
return text if len(text) <= max_len else text[:max_len] + '\n...'
@staticmethod
def _dir_size(path: Path) -> int:
total = 0
if not path.exists():
return total
if path.is_file():
return path.stat().st_size
for root, _, files in os.walk(path):
for filename in files:
try:
total += (Path(root) / filename).stat().st_size
except OSError:
continue
return total
@staticmethod
def _human_size(size: int | float) -> str:
value = float(size or 0)
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if value < 1024 or unit == 'TB':
return f'{value:.2f} {unit}'
value /= 1024
return f'{value:.2f} TB'
@staticmethod
def _mask_uri(uri: str) -> str:
if '@' in uri and '://' in uri:
prefix, suffix = uri.split('://', 1)
credentials, rest = suffix.split('@', 1)
if ':' in credentials:
user, _ = credentials.split(':', 1)
return f'{prefix}://{user}:***@{rest}'
return uri
@staticmethod
def _health_summary(health: dict) -> str:
tracked = {
'Baza': health.get('db'),
'SMTP': health.get('smtp'),
'Redis': health.get('redis'),
'KSeF': health.get('ksef'),
'CEIDG': health.get('ceidg'),
}
ok = sum(1 for value in tracked.values() if value in ['ok', 'mock', 'configured', 'fallback'])
total = len(tracked)
return f'{ok}/{total} OK'