push
This commit is contained in:
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
19
app/services/audit_service.py
Normal file
19
app/services/audit_service.py
Normal 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
|
||||
66
app/services/backup_service.py
Normal file
66
app/services/backup_service.py
Normal 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)
|
||||
334
app/services/ceidg_service.py
Normal file
334
app/services/ceidg_service.py
Normal 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)])
|
||||
56
app/services/company_service.py
Normal file
56
app/services/company_service.py
Normal 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
|
||||
71
app/services/health_service.py
Normal file
71
app/services/health_service.py
Normal 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
|
||||
73
app/services/invoice_party_service.py
Normal file
73
app/services/invoice_party_service.py
Normal 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
|
||||
949
app/services/invoice_service.py
Normal file
949
app/services/invoice_service.py
Normal 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
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
1279
app/services/ksef_service.py
Normal file
File diff suppressed because it is too large
Load Diff
104
app/services/mail_service.py
Normal file
104
app/services/mail_service.py
Normal 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)
|
||||
104
app/services/notification_service.py
Normal file
104
app/services/notification_service.py
Normal 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
609
app/services/pdf_service.py
Normal 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
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()
|
||||
170
app/services/redis_service.py
Normal file
170
app/services/redis_service.py
Normal 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)
|
||||
115
app/services/settings_service.py
Normal file
115
app/services/settings_service.py
Normal 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)
|
||||
129
app/services/sync_service.py
Normal file
129
app/services/sync_service.py
Normal 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
|
||||
277
app/services/system_data_service.py
Normal file
277
app/services/system_data_service.py
Normal 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'
|
||||
Reference in New Issue
Block a user