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

29
.dockerignore Normal file
View File

@@ -0,0 +1,29 @@
.venv/
venv
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
instance/*
*.db
.env
.env.*
dist/
build/
*.egg-info/
.pytest_cache
*.pdf
*.xml
storage/*
backups/*
certs/*
pdf/*

90
.env.example Normal file
View File

@@ -0,0 +1,90 @@
# ============================================
# KSeF Manager przykładowy plik .env.example
# ============================================
# ================================
# Klucze bezpieczeństwa aplikacji
# ================================
# Sekretny klucz Flask używany do:
# - podpisywania sesji
# - tokenów CSRF
# - zabezpieczeń aplikacji
SECRET_KEY=change-me-please
# Opcjonalny klucz nadrzędny aplikacji.
# Jeśli nie ustawiony, przyjmuje wartość SECRET_KEY.
# Używany do szyfrowania wrażliwych danych (np. certyfikatów).
APP_MASTER_KEY=
# ================================
# Konfiguracja domeny / reverse proxy
# ================================
# Domeną pod którą dostępna jest aplikacja
# (bez https:// i bez portu)
APP_DOMAIN=ksef.local
# Schemat zewnętrzny
APP_EXTERNAL_SCHEME=https
# Port wystawiony na zewnątrz przez Caddy / Docker
EXPOSE_PORT=8785
# ================================
# Baza danych
# ================================
# Adres SQLAlchemy
#
# Przykłady:
# sqlite:///instance/app.db
# postgresql://user:pass@localhost/dbname
DATABASE_URL=sqlite:///instance/app.db
# ================================
# Redis / Cache / Rate limit
# ================================
# Redis używany do:
# - cache dashboardu
# - rate-limit
# - kolejek
#
# Jeśli puste → fallback do pamięci aplikacji
REDIS_URL=redis://redis:6379/0
# ================================
# Ścieżki robocze aplikacji
# ================================
# Jeśli nie ustawione, aplikacja utworzy katalogi w storage/*
ARCHIVE_PATH=storage/archive
PDF_PATH=storage/pdf
BACKUP_PATH=storage/backups
CERTS_PATH=storage/certs
# ================================
# Konfiguracja aplikacji
# ================================
# Strefa czasowa aplikacji
APP_TIMEZONE=Europe/Warsaw
# Poziom logowania
# DEBUG / INFO / WARNING / ERROR / CRITICAL
LOG_LEVEL=INFO
# ================================
# Port aplikacji (wewnętrzny)
# ================================
# Port na którym Flask/Gunicorn działa w kontenerze
# Nie zmieniać przy Docker Compose
APP_PORT=5000

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
.venv/
venv
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
instance/*
*.db
.env
dist/
build/
*.egg-info/
.pytest_cache
*.pdf
*.xml
storage/*
backups/*
certs/*
pdf/*

20
DEPLOY_SSL_NOTES.txt Normal file
View File

@@ -0,0 +1,20 @@
Zmiany wdrożeniowe
====================
1. Docker uruchamia aplikację przez Gunicorn:
gunicorn -w 1 -k gthread --threads 8 -b 0.0.0.0:5000 run:app
2. Dodano Caddy jako reverse proxy HTTPS.
- konfiguracja: deploy/caddy/Caddyfile
- certyfikaty TLS: deploy/caddy/ssl/server.crt oraz deploy/caddy/ssl/server.key
3. Dodano skrypt wdrożeniowy: deploy_docer.sh
Skrypt:
- generuje self-signed cert, gdy brak plików SSL,
- wykonuje docker compose pull,
- buduje bez cache,
- zatrzymuje stary stack,
- czyści stare obrazy/builder cache,
- uruchamia stack ponownie.
4. Aplikacja ufa nagłówkom reverse proxy (ProxyFix) i ma secure cookies dla HTTPS.

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM python:3.14-alpine
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apk add --no-cache \
gcc musl-dev python3-dev \
\
libffi-dev \
jpeg-dev \
zlib-dev \
\
cairo-dev \
pango-dev \
gdk-pixbuf-dev \
glib-dev \
freetype-dev \
fontconfig-dev \
\
pkgconfig
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p instance storage/archive storage/pdf storage/backups
CMD ["gunicorn", "-w", "1", "-k", "gthread", "--threads", "8", "-b", "0.0.0.0:5000", "run:app"]

106
README.md Normal file
View File

@@ -0,0 +1,106 @@
# STATUS APLIKACJI WIP !
# KSeF Flask App
## Start lokalny
```bash
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
mkdir -p instance storage/archive storage/pdf storage/backups storage/certs
flask --app run.py init-db
flask --app run.py create-company
flask --app run.py create-user
python run.py
```
## CLI w Docker
```bash
docker compose run --rm web flask --app run.py flask --app run.py init-db # nie wymagane, baza się inicjuje automatycznie
docker compose run --rm web flask --app run.py flask --app run.py create-company # opcjonalne
docker compose run --rm web flask --app run.py flask --app run.py create-user # utworzenie pierwszego admina
```
## Jak działa `.env`
W `.env` trzymane są tylko ustawienia techniczne aplikacji. Dane biznesowe, takie jak KSeF, SMTP, Pushover, tokeny i certyfikaty, ustawia się z panelu WWW osobno dla każdej firmy.
### Pola w `.env.example`
- `SECRET_KEY` — klucz Flask do sesji i CSRF.
- `APP_MASTER_KEY` — klucz do szyfrowania danych w bazie, np. tokenów i certyfikatów.
- `DATABASE_URL` — połączenie do bazy. Dla SQLite może zostać `sqlite:///instance/app.db`.
- `APP_PORT` — port aplikacji. Pozstaw domyślny w docker.
- `LOG_LEVEL` — poziom logowania.
- `REDIS_URL` — opcjonalny Redis do rate-limitów, cache i zadań tła.
- `ARCHIVE_PATH`, `PDF_PATH`, `BACKUP_PATH`, `CERTS_PATH` — katalogi plików lokalnych.
- `APP_TIMEZONE` — strefa czasowa aplikacji.
## Redis — jak podać i po co
Poprawne przykłady:
```env
REDIS_URL=redis://127.0.0.1:6379/0
```
W Dockerze:
```env
REDIS_URL=redis://redis:6379/0
```
Jeśli `REDIS_URL` jest puste, aplikacja przechodzi na fallback `memory://`.
Dla konfiguracji docker ustaw:
```env
REDIS_URL=redis://redis:6379/0
```
## Ważne
- Dane biznesowe wprowadza się z panelu WWW.
- Każda firma ma własną konfigurację KSeF, SMTP, Pushover, harmonogram i certyfikat.
- Harmonogram działa w tle także w Dockerze, w procesie aplikacji Flask.
- Ręczne pobieranie tylko pobiera dokumenty i generuje powiadomienia. Nie księguje i nie akceptuje faktur.
- Tryb mock KSeF służy do testów lokalnych. Synchronizacja i wystawianie działają lokalnie i nie wysyłają danych do środowiska produkcyjnego KSeF.
## Docker
```bash
docker compose up --build
```
Start:
```bash
APP_DOMAIN=ksef.local:8785 ./deploy_docker.sh
```
## Konta
Nie ma już wymogu seedów do logowania. Użyj CLI:
```bash
flask --app run.py create-company
flask --app run.py create-user
```
## Role i dostęp do firm
- `admin` — pełny dostęp i zarządzanie użytkownikami/firmami
- `operator` — praca operacyjna
- `readonly` — tylko odczyt
- na poziomie firmy można przypisać `full` albo `readonly`
## CEIDG
Klucz api konfigurowalny jest w panelu admina,
Hurtownia https://dane.biznes.gov.pl/pl/portal/034872, tu można złożyć wniosek o darmowy klucz API.
## Migracja bazy w dockerze
```docker compose run --rm web flask --app run.py db upgrade
```

165
app/__init__.py Normal file
View File

@@ -0,0 +1,165 @@
from dotenv import load_dotenv
from pathlib import Path
from flask import Flask, render_template, request, url_for
from flask_login import current_user
from werkzeug.middleware.proxy_fix import ProxyFix
from dotenv import load_dotenv
from sqlalchemy import inspect, text
from sqlalchemy.exc import SQLAlchemyError
from config import Config
from redis.exceptions import RedisError
from app.cli import register_cli
from app.extensions import db, migrate, login_manager, csrf, limiter
from app.logging_config import configure_logging
from app.scheduler import init_scheduler
from app.utils.formatters import pln
import hashlib
def _ensure_storage(app):
Path(app.instance_path).mkdir(parents=True, exist_ok=True)
for key in ['ARCHIVE_PATH', 'PDF_PATH', 'BACKUP_PATH', 'CERTS_PATH']:
app.config[key].mkdir(parents=True, exist_ok=True)
def _ensure_column(table_name: str, column_name: str, ddl: str):
inspector = inspect(db.engine)
columns = {col['name'] for col in inspector.get_columns(table_name)} if inspector.has_table(table_name) else set()
if columns and column_name not in columns:
db.session.execute(text(f'ALTER TABLE {table_name} ADD COLUMN {ddl}'))
db.session.commit()
def _bootstrap_database(app):
try:
db.create_all()
patches = [
('user', 'theme_preference', "theme_preference VARCHAR(20) DEFAULT 'light' NOT NULL"),
('user', 'is_blocked', 'is_blocked BOOLEAN DEFAULT 0 NOT NULL'),
('user', 'force_password_change', 'force_password_change BOOLEAN DEFAULT 0 NOT NULL'),
('app_setting', 'is_encrypted', 'is_encrypted BOOLEAN DEFAULT 0 NOT NULL'),
('invoice', 'company_id', 'company_id INTEGER'),
('invoice', 'source', "source VARCHAR(32) DEFAULT 'ksef' NOT NULL"),
('invoice', 'customer_id', 'customer_id INTEGER'),
('invoice', 'issued_to_ksef_at', 'issued_to_ksef_at DATETIME'),
('invoice', 'issued_status', "issued_status VARCHAR(32) DEFAULT 'received' NOT NULL"),
('sync_log', 'company_id', 'company_id INTEGER'),
('sync_log', 'total', 'total INTEGER DEFAULT 0'),
('company', 'note', 'note TEXT'),
('company', 'regon', "regon VARCHAR(32) DEFAULT ''"),
('company', 'address', "address VARCHAR(255) DEFAULT ''"),
('customer', 'regon', "regon VARCHAR(32) DEFAULT ''"),
]
for table, col, ddl in patches:
_ensure_column(table, col, ddl)
app.logger.info('Database bootstrap checked.')
except SQLAlchemyError:
app.logger.exception('Automatic database bootstrap failed.')
def _asset_hash(app: Flask, filename: str) -> str:
static_file = Path(app.static_folder) / filename
if not static_file.exists() or not static_file.is_file():
return 'dev'
digest = hashlib.sha256(static_file.read_bytes()).hexdigest()
return digest[:12]
def create_app(config_class=Config):
load_dotenv()
app = Flask(__name__, instance_relative_config=True)
app.config.from_object(config_class)
app.config['RATELIMIT_STORAGE_URI'] = app.config.get('REDIS_URL', 'memory://')
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
_ensure_storage(app)
configure_logging(app)
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
csrf.init_app(app)
from app.services.redis_service import RedisService
if app.config.get('RATELIMIT_STORAGE_URI', '').startswith('redis://') and not RedisService.available(app):
app.logger.warning('Redis niedostępny przy starcie - limiter przełączony na memory://')
app.config['RATELIMIT_STORAGE_URI'] = 'memory://'
limiter.init_app(app)
app.jinja_env.filters['pln'] = pln
from app.models import user, invoice, sync_log, notification, setting, audit_log, company, catalog # noqa
from app.auth.routes import bp as auth_bp
from app.dashboard.routes import bp as dashboard_bp
from app.invoices.routes import bp as invoices_bp
from app.settings.routes import bp as settings_bp
from app.notifications.routes import bp as notifications_bp
from app.api.routes import bp as api_bp
from app.admin.routes import bp as admin_bp
from app.nfz.routes import bp as nfz_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(invoices_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(notifications_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(nfz_bp)
app.register_blueprint(api_bp, url_prefix='/api')
register_cli(app)
with app.app_context():
if not app.config.get('TESTING'):
_bootstrap_database(app)
init_scheduler(app)
@app.context_processor
def inject_globals():
from app.models.setting import AppSetting
from app.services.company_service import CompanyService
theme = request.cookies.get('theme', 'light')
if getattr(current_user, 'is_authenticated', False):
theme = getattr(current_user, 'theme_preference', theme) or 'light'
else:
theme = AppSetting.get('ui.theme', theme)
current_company = CompanyService.get_current_company() if getattr(current_user, 'is_authenticated', False) else None
available_companies = CompanyService.available_for_user() if getattr(current_user, 'is_authenticated', False) else []
nfz_enabled = False
if getattr(current_user, 'is_authenticated', False) and current_company:
from app.services.settings_service import SettingsService
nfz_enabled = SettingsService.get_effective('modules.nfz_enabled', 'false', company_id=current_company.id) == 'true'
status_map = {'sent': 'Wysłano', 'success': 'Sukces', 'error': 'Błąd', 'failed': 'Błąd', 'skipped': 'Pominięto', 'queued': 'Oczekuje'}
channel_map = {'email': 'E-mail', 'pushover': 'Pushover'}
return {
'app_name': 'KSeF Manager',
'theme': theme,
'read_only_mode': (__import__('app.services.settings_service', fromlist=['SettingsService']).SettingsService.read_only_enabled(company_id=current_company.id if current_company else None) if getattr(current_user, 'is_authenticated', False) else False),
'current_company': current_company,
'available_companies': available_companies,
'nfz_module_enabled': nfz_enabled,
'static_asset': lambda filename: url_for('static', filename=filename, v=_asset_hash(app, filename)),
'global_footer_text': app.config.get('APP_FOOTER_TEXT', ''),
'status_pl': lambda value: status_map.get((value or '').lower(), value or ''),
'channel_pl': lambda value: channel_map.get((value or '').lower(), (value or '').upper() if value else ''),
}
@app.after_request
def cleanup_static_headers(response):
if request.path.startswith('/static/'):
response.headers.pop('Content-Disposition', None)
return response
@app.errorhandler(403)
def error_403(err):
return render_template('errors/403.html'), 403
@app.errorhandler(404)
def error_404(err):
return render_template('errors/404.html'), 404
@app.errorhandler(RedisError)
@app.errorhandler(ConnectionError)
def error_redis(err):
db.session.rollback()
return render_template('errors/503.html', message='Usługa cache jest chwilowo niedostępna. Aplikacja korzysta z trybu awaryjnego.'), 503
@app.errorhandler(500)
def error_500(err):
db.session.rollback()
return render_template('errors/500.html'), 500
return app

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

498
app/admin/routes.py Normal file
View File

@@ -0,0 +1,498 @@
from __future__ import annotations
from datetime import datetime, timedelta
from decimal import Decimal
from pathlib import Path
from werkzeug.security import generate_password_hash
from flask import Blueprint, abort, flash, redirect, render_template, request, send_file, url_for
from flask_login import current_user, login_required
from sqlalchemy import String, cast, or_
from app.extensions import db
from app.forms.admin import AdminCompanyForm, AdminUserForm, AccessForm, PasswordResetForm, CeidgConfigForm, DatabaseBackupForm, GlobalKsefDefaultsForm, GlobalMailSettingsForm, GlobalNfzSettingsForm, GlobalNotificationSettingsForm, LogCleanupForm, SharedCompanyKsefForm
from app.models.audit_log import AuditLog
from app.models.catalog import Customer, Product, InvoiceLine
from app.models.company import Company, UserCompanyAccess
from app.models.invoice import Invoice, InvoiceStatus, InvoiceType, MailDelivery, NotificationLog, SyncEvent
from app.models.setting import AppSetting
from app.models.sync_log import SyncLog
from app.models.user import User
from app.services.audit_service import AuditService
from app.services.backup_service import BackupService
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 RequestsKSeFAdapter
from app.services.settings_service import SettingsService
from app.services.system_data_service import SystemDataService
from app.utils.decorators import roles_required
bp = Blueprint('admin', __name__, url_prefix='/admin')
def _mock_invoice_filter():
metadata_text = cast(Invoice.external_metadata, String)
return or_(
Invoice.source == 'mock',
Invoice.issued_status == 'issued_mock',
Invoice.ksef_number.ilike('%MOCK%'),
Invoice.invoice_number.ilike('%MOCK%'),
metadata_text.ilike('%"source": "mock"%'),
metadata_text.ilike('%"mock"%'),
)
def _cleanup_mock_catalog(company_ids: set[int]) -> dict:
deleted_customers = 0
deleted_products = 0
if not company_ids:
return {'customers': 0, 'products': 0}
customer_candidates = Customer.query.filter(Customer.company_id.in_(company_ids)).all()
for customer in customer_candidates:
looks_like_demo = (customer.name or '').lower().startswith('klient demo') or (customer.email or '').lower() == 'demo@example.com'
if looks_like_demo and customer.invoices.count() == 0:
db.session.delete(customer)
deleted_customers += 1
product_candidates = Product.query.filter(Product.company_id.in_(company_ids)).all()
for product in product_candidates:
looks_like_demo = (product.name or '').lower() == 'abonament miesięczny' or (product.sku or '').upper() == 'SUB-MONTH'
linked_lines = InvoiceLine.query.filter_by(product_id=product.id).count()
if looks_like_demo and linked_lines == 0:
db.session.delete(product)
deleted_products += 1
return {'customers': deleted_customers, 'products': deleted_products}
def _admin_dashboard_context() -> dict:
mock_enabled = AppSetting.query.filter(
AppSetting.key.like('company.%.ksef.mock_mode'),
AppSetting.value == 'true'
).count()
global_ro = AppSetting.get('app.read_only_mode', 'false') == 'true'
ceidg_environment = CeidgService.get_environment()
ceidg_form = CeidgConfigForm(environment=ceidg_environment)
ceidg_url = CeidgService.get_api_url(ceidg_environment)
cleanup_form = LogCleanupForm(days=90)
backup_form = DatabaseBackupForm()
return {
'users': User.query.count(),
'companies': Company.query.count(),
'audits': AuditLog.query.count(),
'mock_enabled': mock_enabled,
'global_ro': global_ro,
'ceidg_form': ceidg_form,
'ceidg_url': ceidg_url,
'ceidg_api_key_configured': CeidgService.has_api_key(),
'cleanup_form': cleanup_form,
'backup_form': backup_form,
'ceidg_environment': ceidg_environment,
'backup_meta': BackupService().get_database_backup_meta(),
}
@bp.route("/")
@login_required
@roles_required('admin')
def index():
return render_template('admin/index.html', **_admin_dashboard_context())
@bp.route('/users')
@login_required
@roles_required('admin')
def users():
return render_template('admin/users.html', users=User.query.order_by(User.name).all())
@bp.route('/users/new', methods=['GET', 'POST'])
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
@roles_required('admin')
def user_form(user_id=None):
user = db.session.get(User, user_id) if user_id else None
form = AdminUserForm(obj=user)
form.company_id.choices = [(0, '— bez przypisania —')] + [(c.id, c.name) for c in Company.query.order_by(Company.name).all()]
if request.method == 'GET' and user:
form.force_password_change.data = user.force_password_change
form.is_blocked.data = user.is_blocked
if form.validate_on_submit():
if not user:
user = User(email=form.email.data.lower(), name=form.name.data, role=form.role.data, password_hash=generate_password_hash(form.password.data or 'ChangeMe123!'))
db.session.add(user)
db.session.flush()
else:
user.email = form.email.data.lower()
user.name = form.name.data
user.role = form.role.data
if form.password.data:
user.password_hash = generate_password_hash(form.password.data)
user.force_password_change = bool(form.force_password_change.data)
user.is_blocked = bool(form.is_blocked.data)
db.session.commit()
if form.company_id.data:
CompanyService.assign_user(user, db.session.get(Company, form.company_id.data), form.access_level.data)
AuditService().log('save_user', 'user', user.id, f'role={user.role}, blocked={user.is_blocked}')
flash('Zapisano użytkownika.', 'success')
return redirect(url_for('admin.user_access', user_id=user.id))
accesses = UserCompanyAccess.query.filter_by(user_id=user.id).all() if user else []
return render_template('admin/user_form.html', form=form, user=user, accesses=accesses)
@bp.route('/users/<int:user_id>/access', methods=['GET', 'POST'])
@login_required
@roles_required('admin')
def user_access(user_id):
user = db.session.get(User, user_id)
form = AccessForm()
form.company_id.choices = [(c.id, c.name) for c in Company.query.order_by(Company.name).all()]
if form.validate_on_submit():
access = UserCompanyAccess.query.filter_by(user_id=user.id, company_id=form.company_id.data).first()
if not access:
access = UserCompanyAccess(user_id=user.id, company_id=form.company_id.data)
db.session.add(access)
access.access_level = form.access_level.data
db.session.commit()
AuditService().log('save_access', 'user', user.id, f'company={form.company_id.data}, level={form.access_level.data}')
flash('Zapisano uprawnienia do firmy.', 'success')
return redirect(url_for('admin.user_access', user_id=user.id))
accesses = UserCompanyAccess.query.filter_by(user_id=user.id).all()
return render_template('admin/user_access.html', user=user, form=form, accesses=accesses)
@bp.post('/users/<int:user_id>/access/<int:access_id>/delete')
@login_required
@roles_required('admin')
def delete_access(user_id, access_id):
access = db.session.get(UserCompanyAccess, access_id)
if access and access.user_id == user_id:
db.session.delete(access)
db.session.commit()
AuditService().log('delete_access', 'user', user_id, f'access={access_id}')
flash('Usunięto dostęp.', 'info')
return redirect(url_for('admin.user_access', user_id=user_id))
@bp.route('/users/<int:user_id>/reset-password', methods=['GET', 'POST'])
@login_required
@roles_required('admin')
def reset_password(user_id):
user = db.session.get(User, user_id)
form = PasswordResetForm()
if form.validate_on_submit():
user.password_hash = generate_password_hash(form.password.data)
user.force_password_change = bool(form.force_password_change.data)
db.session.commit()
AuditService().log('reset_password', 'user', user.id, 'reset by admin')
flash('Hasło zostało zresetowane.', 'success')
return redirect(url_for('admin.users'))
return render_template('admin/reset_password.html', form=form, user=user)
@bp.post('/users/<int:user_id>/toggle-block')
@login_required
@roles_required('admin')
def toggle_block(user_id):
user = db.session.get(User, user_id)
user.is_blocked = not user.is_blocked
db.session.commit()
AuditService().log('toggle_block', 'user', user.id, f'blocked={user.is_blocked}')
flash('Zmieniono status blokady użytkownika.', 'warning')
return redirect(url_for('admin.users'))
@bp.route('/companies')
@login_required
@roles_required('admin')
def companies():
return render_template('admin/companies.html', companies=Company.query.order_by(Company.name).all())
@bp.route('/companies/new', methods=['GET', 'POST'])
@bp.route('/companies/<int:company_id>/edit', methods=['GET', 'POST'])
@login_required
@roles_required('admin')
def company_form(company_id=None):
company = db.session.get(Company, company_id) if company_id else None
form = AdminCompanyForm(obj=company)
if request.method == 'GET':
if company:
form.sync_interval_minutes.data = str(company.sync_interval_minutes)
form.mock_mode.data = AppSetting.get(f'company.{company.id}.ksef.mock_mode', 'false') == 'true'
else:
form.mock_mode.data = False
if form.fetch_submit.data and form.validate_on_submit():
lookup = CeidgService().fetch_company(form.tax_id.data)
if lookup.get('ok'):
form.name.data = lookup.get('name') or form.name.data
form.regon.data = lookup.get('regon') or form.regon.data
form.address.data = lookup.get('address') or form.address.data
form.tax_id.data = lookup.get('tax_id') or form.tax_id.data
flash('Pobrano dane firmy z CEIDG.', 'success')
else:
flash(lookup.get('message', 'Nie udało się pobrać danych z CEIDG.'), 'warning')
elif form.submit.data and form.validate_on_submit():
created = company is None
if not company:
company = Company()
db.session.add(company)
company.name = form.name.data
company.tax_id = form.tax_id.data or ''
company.regon = form.regon.data or ''
company.address = form.address.data or ''
company.bank_account = (form.bank_account.data or '').strip()
company.is_active = bool(form.is_active.data)
company.sync_enabled = bool(form.sync_enabled.data)
company.sync_interval_minutes = int(form.sync_interval_minutes.data or 60)
company.note = form.note.data or ''
db.session.commit()
AppSetting.set(f'company.{company.id}.ksef.mock_mode', str(bool(form.mock_mode.data)).lower())
db.session.commit()
if created:
CompanyService.assign_user(user=current_user, company=company, access_level='full', switch_after=True)
AuditService().log('save_company', 'company', company.id, company.name)
flash('Zapisano firmę.', 'success')
return redirect(url_for('admin.companies'))
return render_template('admin/company_form.html', form=form, company=company)
@bp.post('/mock-data/generate')
@login_required
@roles_required('admin')
def generate_mock_data():
companies = Company.query.order_by(Company.id).all()
for company in companies:
AppSetting.set(f'company.{company.id}.ksef.mock_mode', 'true')
if not Product.query.filter_by(company_id=company.id).first():
db.session.add(Product(company_id=company.id, name='Abonament miesięczny', sku='SUB-MONTH', unit='usł.', net_price=Decimal('199.00'), vat_rate=Decimal('23')))
if not Customer.query.filter_by(company_id=company.id).first():
db.session.add(Customer(company_id=company.id, name=f'Klient demo {company.id}', tax_id=f'5250000{company.id:03d}', email='demo@example.com', address='Warszawa, Polska'))
db.session.flush()
if Invoice.query.filter_by(company_id=company.id).count() == 0:
customer = Customer.query.filter_by(company_id=company.id).first()
for idx in range(1, 4):
invoice = Invoice(
company_id=company.id,
customer_id=customer.id if customer else None,
ksef_number=f'MOCK/{company.id}/{idx}',
invoice_number=f'FV/{company.id}/{idx:03d}/2026',
contractor_name=customer.name if customer else f'Klient demo {company.id}',
contractor_nip=customer.tax_id if customer else f'5250000{company.id:03d}',
issue_date=datetime.utcnow().date() - timedelta(days=idx),
received_date=datetime.utcnow().date() - timedelta(days=idx),
fetched_at=datetime.utcnow(),
net_amount=Decimal('199.00'),
vat_amount=Decimal('45.77'),
gross_amount=Decimal('244.77'),
invoice_type=InvoiceType.SALE,
status=InvoiceStatus.SENT,
source='mock',
issued_status='sent',
issued_to_ksef_at=datetime.utcnow(),
)
db.session.add(invoice)
db.session.flush()
db.session.commit()
AuditService().log('generate_mock_data', 'system', 0, f'companies={len(companies)}')
flash('Wygenerowano dane mock.', 'success')
return redirect(url_for('admin.index'))
@bp.post('/mock-data/clear')
@login_required
@roles_required('admin')
def clear_mock_data():
invoices = Invoice.query.filter(_mock_invoice_filter()).all()
company_ids = {invoice.company_id for invoice in invoices if invoice.company_id}
deleted_invoices = 0
for invoice in invoices:
db.session.delete(invoice)
deleted_invoices += 1
catalog_deleted = _cleanup_mock_catalog(company_ids)
for company_id in company_ids:
AppSetting.set(f'company.{company_id}.ksef.mock_mode', 'false')
db.session.commit()
AuditService().log(
'clear_mock_data',
'system',
0,
f'invoices={deleted_invoices}, customers={catalog_deleted["customers"]}, products={catalog_deleted["products"]}, companies={len(company_ids)}',
)
flash(
f'Usunięto dane mock: faktury {deleted_invoices}, klienci {catalog_deleted["customers"]}, produkty {catalog_deleted["products"]}.',
'info',
)
return redirect(url_for('admin.index'))
@bp.post('/ceidg/save')
@login_required
@roles_required('admin')
def save_ceidg_settings():
form = CeidgConfigForm()
if form.validate_on_submit():
environment = (form.environment.data or 'production').strip().lower()
if environment not in {'production', 'test'}:
environment = 'production'
api_key = (form.api_key.data or '').strip()
AppSetting.set('ceidg.environment', environment)
api_key_updated = False
if api_key:
AppSetting.set('ceidg.api_key', api_key, encrypt=True)
api_key_updated = True
db.session.commit()
AuditService().log(
'save_ceidg_settings',
'system',
0,
f'environment={environment}, api_key_updated={api_key_updated}',
)
flash('Zapisano konfigurację CEIDG.', 'success')
else:
flash('Nie udało się zapisać konfiguracji CEIDG.', 'danger')
return redirect(url_for('admin.index'))
@bp.post('/read-only/toggle')
@login_required
@roles_required('admin')
def toggle_global_read_only():
enabled = request.form.get('enabled') == '1'
AppSetting.set('app.read_only_mode', 'true' if enabled else 'false')
db.session.commit()
AuditService().log('toggle_global_read_only', 'system', 0, f'enabled={enabled}')
flash('Zmieniono globalny tryb tylko do odczytu.', 'warning' if enabled else 'success')
return redirect(url_for('admin.index'))
@bp.post('/logs/cleanup')
@login_required
@roles_required('admin')
def cleanup_logs():
form = LogCleanupForm()
if not form.validate_on_submit():
flash('Podaj poprawną liczbę dni.', 'danger')
return redirect(url_for('admin.index'))
cutoff = datetime.utcnow() - timedelta(days=form.days.data)
deleted = {}
targets = [
('audit', AuditLog, AuditLog.created_at),
('sync', SyncLog, SyncLog.created_at),
('notifications', NotificationLog, NotificationLog.created_at),
('mail_delivery', MailDelivery, MailDelivery.created_at),
('sync_events', SyncEvent, SyncEvent.created_at),
]
for label, model, column in targets:
deleted[label] = model.query.filter(column < cutoff).delete(synchronize_session=False)
removed_files = 0
log_dir = Path('instance')
for pattern in ['app.log.*', '*.log.*']:
for file_path in log_dir.glob(pattern):
try:
if datetime.utcfromtimestamp(file_path.stat().st_mtime) < cutoff:
file_path.unlink()
removed_files += 1
except OSError:
continue
db.session.commit()
AuditService().log('cleanup_logs', 'system', 0, f'days={form.days.data}, deleted={deleted}, files={removed_files}')
flash(f'Usunięto stare logi starsze niż {form.days.data} dni. DB: {sum(deleted.values())}, pliki: {removed_files}.', 'success')
return redirect(url_for('admin.index'))
@bp.post('/database/backup')
@login_required
@roles_required('admin')
def database_backup():
form = DatabaseBackupForm()
if not form.validate_on_submit():
flash('Nie udało się uruchomić backupu bazy.', 'danger')
return redirect(url_for('admin.index'))
backup_path = BackupService().create_database_backup()
AuditService().log('database_backup', 'system', 0, backup_path)
return send_file(backup_path, as_attachment=True, download_name=Path(backup_path).name)
@bp.route('/global-settings', methods=['GET', 'POST'])
@login_required
@roles_required('admin')
def global_settings():
current_company = CompanyService.get_current_company()
company_id = current_company.id if current_company else None
mail_form = GlobalMailSettingsForm(prefix='mail', server=SettingsService.get('mail.server', ''), port=SettingsService.get('mail.port', '587'), username=SettingsService.get('mail.username', ''), sender=SettingsService.get('mail.sender', ''), security_mode=(SettingsService.get('mail.security_mode', '') or ('tls' if SettingsService.get('mail.tls', 'true') == 'true' else 'none')))
notify_form = GlobalNotificationSettingsForm(prefix='notify', pushover_user_key=SettingsService.get('notify.pushover_user_key', ''), min_amount=SettingsService.get('notify.min_amount', '0'), quiet_hours=SettingsService.get('notify.quiet_hours', ''), enabled=SettingsService.get('notify.enabled', 'false') == 'true')
nfz_form = GlobalNfzSettingsForm(prefix='nfz', enabled=SettingsService.get('modules.nfz_enabled', 'false') == 'true')
ksef_defaults_form = GlobalKsefDefaultsForm(prefix='kdef', environment=SettingsService.get('ksef.default_environment', 'prod'), auth_mode=SettingsService.get('ksef.default_auth_mode', 'token'), client_id=SettingsService.get('ksef.default_client_id', ''))
shared_ksef_form = SharedCompanyKsefForm(prefix='shared', environment=SettingsService.get('ksef.environment', 'prod', company_id=company_id), auth_mode=SettingsService.get('ksef.auth_mode', 'token', company_id=company_id), client_id=SettingsService.get('ksef.client_id', '', company_id=company_id), certificate_name=SettingsService.get('ksef.certificate_name', '', company_id=company_id))
if mail_form.submit.data and mail_form.validate_on_submit():
SettingsService.set_many({'mail.server': mail_form.server.data or '', 'mail.port': mail_form.port.data or '587', 'mail.username': mail_form.username.data or '', 'mail.password': (mail_form.password.data or SettingsService.get_secret('mail.password', ''), True), 'mail.sender': mail_form.sender.data or '', 'mail.security_mode': mail_form.security_mode.data or 'tls', 'mail.tls': str((mail_form.security_mode.data or 'tls') == 'tls').lower()})
flash('Zapisano globalne ustawienia SMTP.', 'success')
return redirect(url_for('admin.global_settings'))
if notify_form.submit.data and notify_form.validate_on_submit():
SettingsService.set_many({'notify.pushover_user_key': notify_form.pushover_user_key.data or '', 'notify.pushover_api_token': (notify_form.pushover_api_token.data or SettingsService.get_secret('notify.pushover_api_token', ''), True), 'notify.min_amount': notify_form.min_amount.data or '0', 'notify.quiet_hours': notify_form.quiet_hours.data or '', 'notify.enabled': str(bool(notify_form.enabled.data)).lower()})
flash('Zapisano globalne ustawienia Pushover.', 'success')
return redirect(url_for('admin.global_settings'))
if nfz_form.submit.data and nfz_form.validate_on_submit():
SettingsService.set_many({'modules.nfz_enabled': str(bool(nfz_form.enabled.data)).lower()})
flash('Zapisano globalne ustawienia modułu NFZ.', 'success')
return redirect(url_for('admin.global_settings'))
if ksef_defaults_form.submit.data and ksef_defaults_form.validate_on_submit():
SettingsService.set_many({'ksef.default_environment': ksef_defaults_form.environment.data or 'prod', 'ksef.default_auth_mode': ksef_defaults_form.auth_mode.data or 'token', 'ksef.default_client_id': ksef_defaults_form.client_id.data or ''})
flash('Zapisano domyślne parametry KSeF.', 'success')
return redirect(url_for('admin.global_settings'))
if shared_ksef_form.submit.data and shared_ksef_form.validate_on_submit() and company_id:
SettingsService.set_many({'ksef.environment': shared_ksef_form.environment.data or 'prod', 'ksef.base_url': RequestsKSeFAdapter.ENVIRONMENT_URLS.get(shared_ksef_form.environment.data or 'prod', RequestsKSeFAdapter.ENVIRONMENT_URLS['prod']), 'ksef.auth_mode': shared_ksef_form.auth_mode.data or 'token', 'ksef.client_id': shared_ksef_form.client_id.data or '', 'ksef.certificate_name': shared_ksef_form.certificate_name.data or '', 'ksef.token': (shared_ksef_form.token.data or SettingsService.get_secret('ksef.token', '', company_id=company_id), True), 'ksef.certificate_data': (shared_ksef_form.certificate_data.data or SettingsService.get_secret('ksef.certificate_data', '', company_id=company_id), True)}, company_id=company_id)
flash('Zapisano współdzielony profil KSeF dla aktywnej firmy.', 'success')
return redirect(url_for('admin.global_settings'))
return render_template('admin/global_settings.html', mail_form=mail_form, notify_form=notify_form, nfz_form=nfz_form, ksef_defaults_form=ksef_defaults_form, shared_ksef_form=shared_ksef_form, current_company=current_company, shared_token_configured=bool(SettingsService.get_secret('ksef.token', '', company_id=company_id)) if company_id else False, shared_cert_configured=bool(SettingsService.get_secret('ksef.certificate_data', '', company_id=company_id)) if company_id else False)
@bp.route('/maintenance')
@login_required
@roles_required('admin')
def maintenance():
return render_template('admin/maintenance.html', **_admin_dashboard_context())
@bp.route('/audit')
@login_required
@roles_required('admin')
def audit():
logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(200).all()
return render_template('admin/audit.html', logs=logs)
@bp.route('/health')
@login_required
@roles_required('admin')
def health():
return redirect(url_for('admin.system_data'))
@bp.route('/system-data')
@login_required
@roles_required('admin')
def system_data():
data = SystemDataService().collect()
return render_template('admin/system_data.html', data=data, json_preview=SystemDataService.json_preview)

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

24
app/api/routes.py Normal file
View File

@@ -0,0 +1,24 @@
from flask import Blueprint, jsonify, request
from flask_login import login_required
from app.models.invoice import Invoice
from app.services.company_service import CompanyService
from app.services.health_service import HealthService
bp = Blueprint('api', __name__)
@bp.route('/health')
def health():
return jsonify(HealthService().get_status())
@bp.route('/invoices')
@login_required
def invoices():
company = CompanyService.get_current_company()
page = request.args.get('page', 1, type=int)
query = Invoice.query.order_by(Invoice.issue_date.desc())
if company:
query = query.filter_by(company_id=company.id)
items = query.paginate(page=page, per_page=20, error_out=False)
return jsonify({'items': [{'id': i.id, 'invoice_number': i.invoice_number, 'ksef_number': i.ksef_number, 'contractor_name': i.contractor_name, 'gross_amount': float(i.gross_amount), 'status': i.status.value} for i in items.items], 'page': items.page, 'pages': items.pages, 'total': items.total})

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

48
app/auth/routes.py Normal file
View File

@@ -0,0 +1,48 @@
from datetime import datetime
from flask import Blueprint, flash, make_response, redirect, render_template, request, url_for, session
from flask_login import current_user, login_required, login_user, logout_user
from app.forms.auth import LoginForm
from app.extensions import db
from app.models.user import User
bp = Blueprint('auth', __name__, url_prefix='/auth')
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('dashboard.index'))
form = LoginForm()
response = None
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data.lower()).first()
if user and user.check_password(form.password.data):
if user.is_blocked:
flash('Konto użytkownika jest zablokowane.', 'danger')
else:
login_user(user)
user.last_login_at = datetime.utcnow()
first_company = user.companies()[0] if user.companies() else None
if first_company:
session['current_company_id'] = first_company.id
db.session.commit()
flash('Zalogowano pomyślnie.', 'success')
response = make_response(redirect(request.args.get('next') or url_for('dashboard.index')))
response.set_cookie('theme', user.theme_preference or 'light', max_age=31536000, samesite='Lax')
return response
else:
flash('Błędny login lub hasło.', 'danger')
theme = request.cookies.get('theme', 'light')
return render_template('auth/login.html', form=form, theme=theme)
@bp.route('/logout')
@login_required
def logout():
theme = current_user.theme_preference or request.cookies.get('theme', 'light')
logout_user()
session.pop('current_company_id', None)
flash('Wylogowano.', 'info')
response = make_response(redirect(url_for('auth.login')))
response.set_cookie('theme', theme, max_age=31536000, samesite='Lax')
return response

72
app/cli.py Normal file
View File

@@ -0,0 +1,72 @@
import click
from werkzeug.security import generate_password_hash
from app.extensions import db
from app.models.user import User
from app.models.company import Company, UserCompanyAccess
from app.seed import seed_data
from app.services.backup_service import BackupService
from app.services.mail_service import MailService
from app.services.pdf_service import PdfService
from app.services.sync_service import SyncService
def register_cli(app):
@app.cli.command('init-db')
def init_db():
db.create_all()
print('Database initialized')
@app.cli.command('create-company')
@click.option('--name', prompt=True)
@click.option('--tax-id', default='')
def create_company(name, tax_id):
company = Company(name=name, tax_id=tax_id)
db.session.add(company)
db.session.commit()
print(f'Company created: {company.id}')
@app.cli.command('create-user')
@click.option('--email', prompt=True)
@click.option('--name', prompt=True)
@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True)
@click.option('--role', default='admin', type=click.Choice(['admin', 'operator', 'readonly']))
@click.option('--company-id', type=int, default=None)
@click.option('--access-level', default='full', type=click.Choice(['full', 'readonly']))
def create_user(email, name, password, role, company_id, access_level):
if User.query.filter_by(email=email.lower()).first():
raise click.ClickException('User already exists')
user = User(email=email.lower(), name=name, password_hash=generate_password_hash(password), role=role)
db.session.add(user)
db.session.flush()
if company_id:
db.session.add(UserCompanyAccess(user_id=user.id, company_id=company_id, access_level=access_level))
db.session.commit()
print('User created')
@app.cli.command('seed-data')
def seed_command():
seed_data()
print('Seed data inserted')
@app.cli.command('sync-ksef')
@click.option('--company-id', type=int, default=None)
def sync_ksef(company_id):
company = db.session.get(Company, company_id) if company_id else Company.query.first()
SyncService(company).run_manual_sync()
print('KSeF sync complete')
@app.cli.command('rebuild-pdf')
def rebuild_pdf():
PdfService().rebuild_all()
print('PDF rebuild complete')
@app.cli.command('resend-mail')
@click.option('--delivery-id', prompt=True, type=int)
def resend_mail(delivery_id):
MailService().retry_delivery(delivery_id)
print('Mail resent')
@app.cli.command('backup-data')
def backup_data():
path = BackupService().create_backup()
print(f'Backup created: {path}')

View File

204
app/dashboard/routes.py Normal file
View File

@@ -0,0 +1,204 @@
from datetime import date, datetime
from decimal import Decimal
from flask import Blueprint, current_app, jsonify, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from sqlalchemy import extract
from app.extensions import csrf
from app.models.invoice import Invoice
from app.models.sync_log import SyncLog
from app.services.company_service import CompanyService
from app.services.health_service import HealthService
from app.services.redis_service import RedisService
from app.services.settings_service import SettingsService
from app.services.sync_service import SyncService
bp = Blueprint('dashboard', __name__)
def _load_dashboard_summary(company_id: int):
cache_key = f'dashboard.summary.company.{company_id}'
cached = RedisService.get_json(cache_key) or {}
base = Invoice.query.filter_by(company_id=company_id)
today = date.today()
if not cached:
month_invoices = base.filter(
extract('month', Invoice.issue_date) == today.month,
extract('year', Invoice.issue_date) == today.year,
).order_by(Invoice.issue_date.desc(), Invoice.id.desc()).all()
cached = {
'month_invoice_ids': [invoice.id for invoice in month_invoices],
'unread': base.filter_by(is_unread=True).count(),
'totals': {
'net': str(sum(Decimal(invoice.net_amount) for invoice in month_invoices)),
'vat': str(sum(Decimal(invoice.vat_amount) for invoice in month_invoices)),
'gross': str(sum(Decimal(invoice.gross_amount) for invoice in month_invoices)),
},
'recent_invoice_ids': [invoice.id for invoice in base.order_by(Invoice.created_at.desc(), Invoice.id.desc()).limit(200).all()],
}
RedisService.set_json(cache_key, cached, ttl=300)
month_ids = cached.get('month_invoice_ids', [])
month_invoices = Invoice.query.filter(Invoice.id.in_(month_ids)).all() if month_ids else []
month_invoices.sort(key=lambda item: month_ids.index(item.id) if item.id in month_ids else 9999)
totals = {
'net': Decimal(str(cached.get('totals', {}).get('net', '0'))),
'vat': Decimal(str(cached.get('totals', {}).get('vat', '0'))),
'gross': Decimal(str(cached.get('totals', {}).get('gross', '0'))),
}
return cached, month_invoices, totals
@bp.route('/')
@login_required
def index():
company = CompanyService.get_current_company()
health_service = HealthService()
health = health_service.get_cached_status(company.id if company else None) or health_service.get_status(company_id=company.id if company else None)
if not company:
return render_template(
'dashboard/index.html',
company=None,
month_invoices=[],
unread=0,
totals={'net': Decimal('0'), 'vat': Decimal('0'), 'gross': Decimal('0')},
recent_invoices=[],
last_sync_display='brak',
sync_status='inactive',
health=health,
current_user=current_user,
recent_pagination={'page': 1, 'pages': 1, 'has_prev': False, 'has_next': False, 'prev_num': 1, 'next_num': 1},
payment_details_map={},
redis_fallback=(health.get('redis') == 'fallback'),
)
read_only = SettingsService.read_only_enabled(company_id=company.id)
base = Invoice.query.filter_by(company_id=company.id)
last_sync_raw = SettingsService.get('ksef.last_sync_at', None, company_id=company.id)
last_sync = None
if isinstance(last_sync_raw, str) and last_sync_raw.strip():
try:
last_sync = datetime.fromisoformat(last_sync_raw.replace('Z', '+00:00'))
except Exception:
last_sync = last_sync_raw
elif last_sync_raw:
last_sync = last_sync_raw
if not last_sync:
latest_log = SyncLog.query.filter_by(company_id=company.id, status='finished').order_by(SyncLog.finished_at.desc()).first()
last_sync = latest_log.finished_at if latest_log and latest_log.finished_at else None
cached, month_invoices, totals = _load_dashboard_summary(company.id)
unread = cached.get('unread', 0)
recent_ids = cached.get('recent_invoice_ids', [])
per_page = 10
total_recent = len(recent_ids)
total_pages = max((total_recent + per_page - 1) // per_page, 1)
dashboard_page = min(max(request.args.get('dashboard_page', 1, type=int), 1), total_pages)
start = (dashboard_page - 1) * per_page
end = start + per_page
current_ids = recent_ids[start:end]
recent_invoices = Invoice.query.filter(Invoice.id.in_(current_ids)).all() if current_ids else []
recent_invoices.sort(key=lambda item: current_ids.index(item.id) if item.id in current_ids else 9999)
recent_pagination = {
'page': dashboard_page,
'pages': total_pages,
'has_prev': dashboard_page > 1,
'has_next': end < total_recent,
'prev_num': dashboard_page - 1,
'next_num': dashboard_page + 1,
}
from app.services.invoice_service import InvoiceService
payment_details_map = {invoice.id: InvoiceService().resolve_payment_details(invoice) for invoice in recent_invoices}
last_sync_display = last_sync.strftime('%Y-%m-%d %H:%M:%S') if hasattr(last_sync, 'strftime') else (last_sync or 'brak')
return render_template(
'dashboard/index.html',
company=company,
month_invoices=month_invoices,
unread=unread,
totals=totals,
recent_invoices=recent_invoices,
recent_pagination=recent_pagination,
payment_details_map=payment_details_map,
last_sync_display=last_sync_display,
last_sync_raw=last_sync,
sync_status=SettingsService.get('ksef.status', 'inactive', company_id=company.id),
health=health,
read_only=read_only,
redis_fallback=(health.get('redis') == 'fallback'),
)
@bp.route('/switch-company/<int:company_id>')
@login_required
def switch_company(company_id):
CompanyService.set_active_company(company_id)
return redirect(url_for('dashboard.index'))
@bp.post('/sync/manual')
@login_required
def manual_sync():
company = CompanyService.get_current_company()
if not company:
return redirect(url_for('dashboard.index'))
app = current_app._get_current_object()
log_id = SyncService.start_manual_sync_async(app, company.id)
return redirect(url_for('dashboard.index', started=log_id))
@bp.route('/sync/status')
@login_required
@csrf.exempt
def sync_status():
company = CompanyService.get_current_company()
if not company:
return jsonify({'status': 'no_company'})
log = SyncLog.query.filter_by(company_id=company.id).order_by(SyncLog.started_at.desc()).first()
if not log:
return jsonify({'status': 'idle'})
return jsonify(
{
'status': log.status,
'message': log.message,
'processed': log.processed,
'created': log.created,
'updated': log.updated,
'errors': log.errors,
'total': log.total,
}
)
@bp.post('/sync/start')
@login_required
def sync_start():
company = CompanyService.get_current_company()
if not company:
return jsonify({'error': 'no_company'}), 400
app = current_app._get_current_object()
log_id = SyncService.start_manual_sync_async(app, company.id)
return jsonify({'log_id': log_id})
@bp.get('/sync/status/<int:log_id>')
@login_required
def sync_status_by_id(log_id):
log = SyncLog.query.get_or_404(log_id)
total = log.total or 0
progress = int((log.processed / total) * 100) if total else (100 if log.status == 'finished' else 0)
return jsonify({
'status': log.status,
'message': log.message,
'processed': log.processed,
'created': log.created,
'updated': log.updated,
'errors': log.errors,
'total': total,
'progress': progress,
})

17
app/extensions.py Normal file
View File

@@ -0,0 +1,17 @@
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_mail import Mail
from flask_wtf.csrf import CSRFProtect
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
mail = Mail()
csrf = CSRFProtect()
limiter = Limiter(key_func=get_remote_address, default_limits=['300/day', '100/hour'])
login_manager.login_message = None

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

120
app/forms/admin.py Normal file
View File

@@ -0,0 +1,120 @@
from flask_wtf import FlaskForm
from wtforms import (
BooleanField,
HiddenField,
IntegerField,
PasswordField,
SelectField,
StringField,
SubmitField,
TextAreaField,
)
from wtforms.validators import DataRequired, Email, NumberRange, Optional
class AdminUserForm(FlaskForm):
email = StringField('E-mail', validators=[DataRequired(), Email()])
name = StringField('Imię i nazwisko', validators=[DataRequired()])
role = SelectField('Rola globalna', choices=[('admin', 'Admin'), ('operator', 'Operator'), ('readonly', 'Readonly')], validators=[DataRequired()])
password = PasswordField('Hasło', validators=[Optional()])
company_id = SelectField('Dodaj dostęp do firmy', coerce=int, validators=[Optional()])
access_level = SelectField('Poziom dostępu do firmy', choices=[('full', 'Pełny'), ('readonly', 'Tylko odczyt')], validators=[Optional()])
force_password_change = BooleanField('Wymuś zmianę hasła')
is_blocked = BooleanField('Zablokowany')
submit = SubmitField('Zapisz użytkownika')
class AccessForm(FlaskForm):
company_id = SelectField('Firma', coerce=int, validators=[DataRequired()])
access_level = SelectField('Poziom dostępu', choices=[('full', 'Pełny'), ('readonly', 'Tylko odczyt')], validators=[DataRequired()])
submit = SubmitField('Zapisz dostęp')
class PasswordResetForm(FlaskForm):
password = PasswordField('Nowe hasło', validators=[DataRequired()])
force_password_change = BooleanField('Wymuś zmianę po logowaniu')
submit = SubmitField('Resetuj hasło')
class AdminCompanyForm(FlaskForm):
name = StringField('Nazwa firmy', validators=[Optional()])
tax_id = StringField('NIP', validators=[Optional()])
regon = StringField('REGON', validators=[Optional()])
address = StringField('Adres', validators=[Optional()])
bank_account = StringField('Numer rachunku bankowego', validators=[Optional()])
is_active = BooleanField('Aktywna')
sync_enabled = BooleanField('Automatyczne pobieranie')
sync_interval_minutes = StringField('Interwał sync (min)', validators=[Optional()])
note = TextAreaField('Opis / notatka', validators=[Optional()])
mock_mode = BooleanField('Włącz tryb mock dla tej firmy')
submit = SubmitField('Zapisz firmę')
fetch_submit = SubmitField('Pobierz dane z CEIDG')
def validate(self, extra_validators=None):
if not super().validate(extra_validators=extra_validators):
return False
is_fetch = bool(self.fetch_submit.data and not self.submit.data)
if is_fetch:
if not (self.tax_id.data or '').strip():
self.tax_id.errors.append('Podaj NIP, aby pobrać dane z CEIDG.')
return False
return True
if not (self.name.data or '').strip():
self.name.errors.append('To pole jest wymagane.')
return False
return True
class CeidgConfigForm(FlaskForm):
environment = HiddenField(default='production')
api_key = PasswordField('API KEY CEIDG', validators=[Optional()])
submit = SubmitField('Zapisz konfigurację CEIDG')
class LogCleanupForm(FlaskForm):
days = IntegerField('Usuń logi starsze niż (dni)', validators=[DataRequired(), NumberRange(min=1, max=3650)])
submit = SubmitField('Wyczyść logi')
class DatabaseBackupForm(FlaskForm):
submit = SubmitField('Wykonaj kopię bazy')
class GlobalMailSettingsForm(FlaskForm):
server = StringField('SMTP host', validators=[Optional()])
port = StringField('SMTP port', validators=[Optional()])
username = StringField('SMTP login', validators=[Optional()])
password = PasswordField('SMTP hasło', validators=[Optional()])
sender = StringField('Nadawca', validators=[Optional(), Email()])
security_mode = SelectField('Zabezpieczenie połączenia', choices=[('tls', 'TLS / STARTTLS'), ('ssl', 'SSL'), ('none', 'Brak')], validators=[Optional()], default='tls')
submit = SubmitField('Zapisz SMTP globalne')
class GlobalNotificationSettingsForm(FlaskForm):
pushover_user_key = StringField('Pushover user key', validators=[Optional()])
pushover_api_token = PasswordField('Pushover API token', validators=[Optional()])
min_amount = StringField('Powiadom od kwoty', validators=[Optional()])
quiet_hours = StringField('Cichy harmonogram', validators=[Optional()])
enabled = BooleanField('Włącz globalne powiadomienia')
submit = SubmitField('Zapisz Pushover globalnie')
class GlobalNfzSettingsForm(FlaskForm):
enabled = BooleanField('Włącz moduł NFZ globalnie')
submit = SubmitField('Zapisz NFZ globalnie')
class GlobalKsefDefaultsForm(FlaskForm):
environment = SelectField('Domyślne środowisko KSeF', choices=[('prod', 'PROD'), ('test', 'TEST')], validators=[Optional()])
auth_mode = SelectField('Domyślny tryb autoryzacji', choices=[('token', 'Token'), ('certificate', 'Certyfikat')])
client_id = StringField('Domyślny Client ID', validators=[Optional()])
submit = SubmitField('Zapisz domyślne parametry KSeF')
class SharedCompanyKsefForm(FlaskForm):
environment = SelectField('Środowisko współdzielonego profilu', choices=[('prod', 'PROD'), ('test', 'TEST')], validators=[Optional()])
auth_mode = SelectField('Tryb autoryzacji', choices=[('token', 'Token'), ('certificate', 'Certyfikat')])
token = PasswordField('Token', validators=[Optional()])
client_id = StringField('Client ID', validators=[Optional()])
certificate_name = StringField('Nazwa certyfikatu', validators=[Optional()])
certificate_data = PasswordField('Treść certyfikatu / base64', validators=[Optional()])
submit = SubmitField('Zapisz współdzielony profil KSeF')

7
app/forms/auth.py Normal file
View File

@@ -0,0 +1,7 @@
from flask_wtf import FlaskForm
from wtforms import PasswordField, StringField, SubmitField
from wtforms.validators import DataRequired, Email, Length
class LoginForm(FlaskForm):
email = StringField('E-mail', validators=[DataRequired(), Email()])
password = PasswordField('Hasło', validators=[DataRequired(), Length(min=6)])
submit = SubmitField('Zaloguj')

26
app/forms/invoices.py Normal file
View File

@@ -0,0 +1,26 @@
from flask_wtf import FlaskForm
from wtforms import BooleanField, SelectField, StringField, SubmitField, TextAreaField
from wtforms.validators import Optional
class InvoiceFilterForm(FlaskForm):
month = SelectField('Miesiąc', choices=[('', 'Wszystkie')] + [(str(i), str(i)) for i in range(1, 13)], validators=[Optional()])
year = StringField('Rok', validators=[Optional()])
contractor = StringField('Kontrahent', validators=[Optional()])
nip = StringField('NIP', validators=[Optional()])
invoice_type = SelectField('Typ', choices=[('', 'Wszystkie'), ('purchase', 'Zakupowa'), ('sale', 'Sprzedażowa'), ('correction', 'Korekta')], validators=[Optional()])
status = SelectField('Status', choices=[('', 'Wszystkie'), ('new', 'Nowa'), ('read', 'Przeczytana'), ('accounted', 'Zaksięgowana'), ('sent', 'Wysłana'), ('archived', 'Archiwalna'), ('needs_attention', 'Wymaga uwagi'), ('error', 'Błąd')], validators=[Optional()])
quick_filter = SelectField('Szybki filtr', choices=[('', 'Brak'), ('this_month', 'Ten miesiąc'), ('previous_month', 'Poprzedni miesiąc'), ('unread', 'Nieprzeczytane'), ('error', 'Z błędem'), ('to_send', 'Do wysyłki')], validators=[Optional()])
min_amount = StringField('Min brutto', validators=[Optional()])
max_amount = StringField('Max brutto', validators=[Optional()])
search = StringField('Szukaj', validators=[Optional()])
submit = SubmitField('Filtruj')
class InvoiceMetaForm(FlaskForm):
status = SelectField('Status', choices=[('new', 'Nowa'), ('read', 'Przeczytana'), ('accounted', 'Zaksięgowana'), ('sent', 'Wysłana'), ('archived', 'Archiwalna'), ('needs_attention', 'Wymaga uwagi'), ('error', 'Błąd')])
tags = StringField('Tagi', validators=[Optional()])
internal_note = TextAreaField('Notatka', validators=[Optional()])
queue_accounting = BooleanField('Do księgowości')
pinned = BooleanField('Przypnij')
submit = SubmitField('Zapisz')

15
app/forms/issued.py Normal file
View File

@@ -0,0 +1,15 @@
from flask_wtf import FlaskForm
from wtforms import BooleanField, DecimalField, SelectField, StringField, SubmitField
from wtforms.validators import DataRequired, Optional
class IssuedInvoiceForm(FlaskForm):
customer_id = SelectField('Klient', coerce=int, validators=[DataRequired()])
numbering_template = SelectField('Format numeracji', choices=[('monthly', 'Miesięczny'), ('yearly', 'Roczny'), ('custom', 'Własny')], validators=[DataRequired()])
invoice_number = StringField('Numer faktury', validators=[Optional()])
product_id = SelectField('Towar / usługa', coerce=int, validators=[DataRequired()])
quantity = DecimalField('Ilość', validators=[DataRequired()], default=1)
unit_net = DecimalField('Cena netto', validators=[Optional()])
split_payment = BooleanField('Split payment')
save_submit = SubmitField('Generuj fakturę')
submit = SubmitField('Wyślij do KSeF')

41
app/forms/nfz.py Normal file
View File

@@ -0,0 +1,41 @@
from flask_wtf import FlaskForm
from wtforms import DateField, DecimalField, SelectField, StringField, SubmitField
from wtforms.validators import DataRequired, Optional
NFZ_BRANCH_CHOICES = [
('1070001057-00018', 'Dolnośląski OW NFZ'),
('1070001057-00021', 'Kujawsko-Pomorski OW NFZ'),
('1070001057-00034', 'Lubelski OW NFZ'),
('1070001057-00047', 'Lubuski OW NFZ'),
('1070001057-00050', 'Łódzki OW NFZ'),
('1070001057-00063', 'Małopolski OW NFZ'),
('1070001057-00076', 'Mazowiecki OW NFZ'),
('1070001057-00089', 'Opolski OW NFZ'),
('1070001057-00092', 'Podkarpacki OW NFZ'),
('1070001057-00106', 'Podlaski OW NFZ'),
('1070001057-00119', 'Pomorski OW NFZ'),
('1070001057-00122', 'Śląski OW NFZ'),
('1070001057-00135', 'Świętokrzyski OW NFZ'),
('1070001057-00148', 'Warmińsko-Mazurski OW NFZ'),
('1070001057-00151', 'Wielkopolski OW NFZ'),
('1070001057-00164', 'Zachodniopomorski OW NFZ'),
('1070001057-00177', 'Centrala NFZ'),
]
class NfzInvoiceForm(FlaskForm):
customer_id = SelectField('Odbiorca techniczny', coerce=int, validators=[DataRequired()])
product_id = SelectField('Towar / usługa', coerce=int, validators=[DataRequired()])
invoice_number = StringField('Numer faktury', validators=[Optional()])
nfz_branch_id = SelectField('Oddział NFZ (IDWew)', choices=NFZ_BRANCH_CHOICES, validators=[DataRequired()])
settlement_from = DateField('Okres rozliczeniowy od', validators=[DataRequired()], format='%Y-%m-%d')
settlement_to = DateField('Okres rozliczeniowy do', validators=[DataRequired()], format='%Y-%m-%d')
template_identifier = StringField('Identyfikator szablonu', validators=[Optional()])
provider_identifier = StringField('Identyfikator świadczeniodawcy', validators=[DataRequired()])
service_code = StringField('Kod zakresu / wyróżnik / kod świadczenia', validators=[DataRequired()])
contract_number = StringField('Numer umowy / aneksu', validators=[DataRequired()])
quantity = DecimalField('Ilość', validators=[DataRequired()], default=1)
unit_net = DecimalField('Cena netto', validators=[DataRequired()])
save_submit = SubmitField('Zapisz roboczo')
submit = SubmitField('Zapisz i wyślij do KSeF')

73
app/forms/settings.py Normal file
View File

@@ -0,0 +1,73 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed, FileField
from wtforms import BooleanField, IntegerField, PasswordField, RadioField, SelectField, StringField, SubmitField
from wtforms.validators import DataRequired, Email, Optional
class KSeFSettingsForm(FlaskForm):
source_mode = RadioField('Źródło ustawień', choices=[('user', 'Moje ustawienia'), ('global', 'Profil współdzielony firmy')], default='user')
environment = SelectField(
'Środowisko KSeF',
choices=[('prod', 'PROD'), ('test', 'TEST')],
validators=[Optional()],
)
auth_mode = SelectField('Tryb autoryzacji', choices=[('token', 'Token'), ('certificate', 'Certyfikat')])
token = PasswordField('Token', validators=[Optional()])
client_id = StringField('Client ID', validators=[Optional()])
certificate_file = FileField('Certyfikat', validators=[Optional(), FileAllowed(['pem', 'crt', 'cer', 'p12', 'pfx'], 'Dozwolone: pem, crt, cer, p12, pfx')])
submit = SubmitField('Zapisz KSeF')
class MailSettingsForm(FlaskForm):
source_mode = RadioField('Źródło SMTP', choices=[('global', 'Użyj ustawień globalnych'), ('user', 'Podaj indywidualne ustawienia')], default='global')
server = StringField('SMTP host', validators=[Optional()])
port = StringField('SMTP port', validators=[Optional()])
username = StringField('SMTP login', validators=[Optional()])
password = PasswordField('SMTP hasło', validators=[Optional()])
sender = StringField('Nadawca', validators=[Optional(), Email()])
security_mode = SelectField('Zabezpieczenie połączenia', choices=[('tls', 'TLS / STARTTLS'), ('ssl', 'SSL'), ('none', 'Brak')], validators=[Optional()], default='tls')
test_recipient = StringField('Adres testowy', validators=[Optional(), Email()])
submit = SubmitField('Zapisz SMTP')
test_submit = SubmitField('Wyślij test maila')
class NotificationSettingsForm(FlaskForm):
source_mode = RadioField('Źródło Pushover', choices=[('global', 'Użyj ustawień globalnych'), ('user', 'Podaj indywidualne ustawienia')], default='global')
pushover_user_key = StringField('Pushover user key', validators=[Optional()])
pushover_api_token = PasswordField('Pushover API token', validators=[Optional()])
min_amount = StringField('Powiadom od kwoty', validators=[Optional()])
quiet_hours = StringField('Cichy harmonogram, np. 22:00-07:00', validators=[Optional()])
enabled = BooleanField('Włącz powiadomienia')
submit = SubmitField('Zapisz powiadomienia')
test_submit = SubmitField('Wyślij test Pushover')
class AppearanceSettingsForm(FlaskForm):
theme_preference = SelectField('Motyw interfejsu', choices=[('light', 'Jasny'), ('dark', 'Ciemny')], validators=[DataRequired()])
submit = SubmitField('Zapisz wygląd')
class CompanyForm(FlaskForm):
name = StringField('Nazwa firmy', validators=[DataRequired()])
tax_id = StringField('NIP', validators=[Optional()])
sync_enabled = BooleanField('Włącz harmonogram pobierania')
sync_interval_minutes = IntegerField('Interwał sync (min)', validators=[Optional()])
bank_account = StringField('Numer rachunku bankowego', validators=[Optional()])
read_only_mode = BooleanField('Tryb tylko odczyt (R/O)')
submit = SubmitField('Zapisz firmę')
class UserForm(FlaskForm):
email = StringField('E-mail', validators=[DataRequired(), Email()])
name = StringField('Imię i nazwisko', validators=[DataRequired()])
password = PasswordField('Hasło', validators=[Optional()])
role = SelectField('Rola globalna', choices=[('admin', 'Admin'), ('operator', 'Operator'), ('readonly', 'Readonly')])
company_id = SelectField('Firma', coerce=int, validators=[Optional()])
access_level = SelectField('Dostęp do firmy', choices=[('full', 'Pełny'), ('readonly', 'Tylko odczyt')])
submit = SubmitField('Dodaj / przypisz użytkownika')
class NfzModuleSettingsForm(FlaskForm):
source_mode = RadioField('Źródło konfiguracji NFZ', choices=[('global', 'Użyj ustawień globalnych'), ('user', 'Ustaw indywidualnie')], default='global')
enabled = BooleanField('Włącz moduł faktur NFZ')
submit = SubmitField('Zapisz moduł NFZ')

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

725
app/invoices/routes.py Normal file
View File

@@ -0,0 +1,725 @@
from __future__ import annotations
from io import BytesIO
import csv
import zipfile
from decimal import Decimal
from sqlalchemy import or_
from flask import Blueprint, Response, abort, flash, redirect, render_template, request, send_file, url_for
from flask_login import login_required
from app.extensions import db
from app.forms.invoices import InvoiceFilterForm, InvoiceMetaForm
from app.forms.issued import IssuedInvoiceForm
from app.models.catalog import Customer, InvoiceLine, Product
from app.models.invoice import Invoice, InvoiceStatus, InvoiceType
from app.repositories.invoice_repository import InvoiceRepository
from app.services.audit_service import AuditService
from app.services.ceidg_service import CeidgService
from app.services.company_service import CompanyService
from app.services.invoice_service import InvoiceService
from app.services.ksef_service import KSeFService
from app.services.mail_service import MailService
from app.services.pdf_service import PdfService
from app.services.settings_service import SettingsService
bp = Blueprint('invoices', __name__, url_prefix='/invoices')
def _company():
return CompanyService.get_current_company()
def _require_company(redirect_endpoint='dashboard.index'):
company = _company()
if company:
return company
flash('Najpierw wybierz lub utwórz firmę, aby korzystać z tej sekcji.', 'warning')
return redirect(url_for(redirect_endpoint))
def _invoice_or_404(invoice_id):
company = _company()
invoice = db.session.get(Invoice, invoice_id)
if not invoice or (company and invoice.company_id != company.id):
abort(404)
return invoice
def _ensure_full_access(company_id):
if SettingsService.read_only_enabled(company_id=company_id):
flash('Tryb tylko do odczytu jest aktywny dla tej firmy.', 'warning')
return False
return True
def _redirect_with_prefill(endpoint, *, customer_id=None, product_id=None, **kwargs):
if customer_id:
kwargs['created_customer_id'] = customer_id
if product_id:
kwargs['created_product_id'] = product_id
return redirect(url_for(endpoint, **kwargs))
def _customer_from_invoice(invoice, company_id):
existing = None
if invoice.customer_id:
existing = db.session.get(Customer, invoice.customer_id)
if existing and existing.company_id == company_id:
return existing, False
query = Customer.query.filter_by(company_id=company_id)
if invoice.contractor_nip:
existing = query.filter(Customer.tax_id == invoice.contractor_nip).first()
if not existing:
existing = query.filter(Customer.name == invoice.contractor_name).first()
created = existing is None
customer = existing or Customer(company_id=company_id)
customer.name = customer.name or (invoice.contractor_name or '').strip()
customer.tax_id = customer.tax_id or (invoice.contractor_nip or '').strip()
customer.address = customer.address or (invoice.contractor_address or '').strip()
if customer.tax_id and (not customer.address or not customer.regon):
lookup = CeidgService().fetch_company(customer.tax_id)
if lookup.get('ok'):
customer.name = customer.name or (lookup.get('name') or '').strip()
customer.tax_id = customer.tax_id or (lookup.get('tax_id') or '').strip()
customer.address = customer.address or (lookup.get('address') or '').strip()
customer.regon = customer.regon or (lookup.get('regon') or '').strip()
db.session.add(customer)
db.session.flush()
if invoice.customer_id != customer.id:
invoice.customer_id = customer.id
return customer, created
@bp.route('/')
@login_required
def index():
form = InvoiceFilterForm(request.args)
company = _company()
query = InvoiceRepository().query_filtered(request.args, company_id=company.id if company else None)
page = request.args.get('page', 1, type=int)
pagination = query.paginate(page=page, per_page=15, error_out=False)
payment_details_map = {invoice.id: InvoiceService().resolve_payment_details(invoice) for invoice in pagination.items}
return render_template('invoices/index.html', form=form, pagination=pagination, company=company, payment_details_map=payment_details_map)
@bp.route('/monthly')
@login_required
def monthly():
company = _company()
period = request.args.get('period', 'month')
if period not in {'year', 'quarter', 'month'}:
period = 'month'
search = (request.args.get('q') or '').strip()
service = InvoiceService()
groups = service.grouped_summary(company_id=company.id if company else None, period=period, search=search)
comparisons = service.comparative_stats(company_id=company.id if company else None, search=search)
return render_template(
'invoices/monthly.html',
groups=groups,
comparisons=comparisons,
company=company,
period=period,
period_title=service.period_title(period),
search=search
)
@bp.route('/issued')
@login_required
def issued_list():
company = _company()
search = (request.args.get('q') or '').strip()
page = request.args.get('page', 1, type=int)
query = Invoice.query.filter(Invoice.company_id == (company.id if company else None), Invoice.source.in_(['issued', 'nfz']))
if search:
like = f'%{search}%'
query = query.filter(or_(
Invoice.invoice_number.ilike(like),
Invoice.ksef_number.ilike(like),
Invoice.contractor_name.ilike(like),
Invoice.contractor_nip.ilike(like)
))
pagination = query.order_by(Invoice.created_at.desc()).paginate(page=page, per_page=15, error_out=False)
payment_details_map = {invoice.id: InvoiceService().resolve_payment_details(invoice) for invoice in pagination.items}
return render_template('invoices/issued_list.html', pagination=pagination, invoices=pagination.items, search=search, payment_details_map=payment_details_map)
@bp.route('/issued/new', methods=['GET', 'POST'])
@login_required
def issued_new():
company = _company()
if not company:
return _require_company()
form = IssuedInvoiceForm(numbering_template='monthly')
customers = Customer.query.filter_by(company_id=company.id, is_active=True).order_by(Customer.name).all()
products = Product.query.filter_by(company_id=company.id, is_active=True).order_by(Product.name).all()
form.customer_id.choices = [(c.id, f'{c.name} ({c.tax_id})' if c.tax_id else c.name) for c in customers]
form.product_id.choices = [(p.id, f'{p.name} - {p.net_price} PLN') for p in products]
if request.method == 'GET':
duplicate_id = request.args.get('duplicate_id', type=int)
if duplicate_id:
src = _invoice_or_404(duplicate_id)
form.invoice_number.data = f'{src.invoice_number}/COPY'
form.numbering_template.data = 'custom'
if src.customer_id:
form.customer_id.data = src.customer_id
first_line = src.lines.first()
if first_line and first_line.product_id:
form.product_id.data = first_line.product_id
form.quantity.data = first_line.quantity
form.unit_net.data = first_line.unit_net
form.split_payment.data = bool(getattr(src, 'split_payment', False))
else:
created_customer_id = request.args.get('created_customer_id', type=int)
created_product_id = request.args.get('created_product_id', type=int)
if created_customer_id and any(c.id == created_customer_id for c in customers):
form.customer_id.data = created_customer_id
elif customers:
form.customer_id.data = customers[0].id
if created_product_id and any(p.id == created_product_id for p in products):
form.product_id.data = created_product_id
elif products:
form.product_id.data = products[0].id
if products and form.product_id.data and not form.unit_net.data:
selected = next((p for p in products if p.id == form.product_id.data), None)
if selected:
form.unit_net.data = selected.net_price
form.split_payment.data = bool(selected.split_payment_default)
if customers and products and not form.invoice_number.data:
form.invoice_number.data = InvoiceService().next_sale_number(company.id, form.numbering_template.data or 'monthly')
if form.validate_on_submit():
if not _ensure_full_access(company.id):
return redirect(url_for('invoices.issued_list'))
customer = db.session.get(Customer, form.customer_id.data)
product = db.session.get(Product, form.product_id.data)
qty = Decimal(str(form.quantity.data or 1))
unit_net = Decimal(str(form.unit_net.data or product.net_price))
net = (qty * unit_net).quantize(Decimal('0.01'))
vat = (net * (Decimal(str(product.vat_rate)) / Decimal('100'))).quantize(Decimal('0.01'))
gross = net + vat
split_payment = bool(form.split_payment.data or product.split_payment_default or gross > Decimal('15000'))
number = form.invoice_number.data or InvoiceService().next_sale_number(company.id, form.numbering_template.data)
send_to_ksef_now = bool(form.submit.data)
invoice = Invoice(
company_id=company.id,
customer_id=customer.id,
ksef_number=f'PENDING/{number}',
invoice_number=number,
contractor_name=customer.name,
contractor_nip=customer.tax_id,
issue_date=InvoiceService().today_date(),
received_date=InvoiceService().today_date(),
net_amount=net,
vat_amount=vat,
gross_amount=gross,
seller_bank_account=(company.bank_account or '').strip(),
split_payment=split_payment,
invoice_type=InvoiceType.SALE,
status=InvoiceStatus.NEW,
source='issued',
issued_status='draft' if not send_to_ksef_now else 'pending',
html_preview='',
external_metadata={'split_payment': split_payment, 'seller_bank_account': (company.bank_account or '').strip()},
)
db.session.add(invoice)
db.session.flush()
db.session.add(InvoiceLine(
invoice_id=invoice.id,
product_id=product.id,
description=product.name,
quantity=qty,
unit=product.unit,
unit_net=unit_net,
vat_rate=product.vat_rate,
net_amount=net,
vat_amount=vat,
gross_amount=gross,
))
InvoiceService().persist_issued_assets(invoice)
if send_to_ksef_now:
payload = {
'invoiceNumber': number,
'customer': {'name': customer.name, 'taxId': customer.tax_id},
'lines': [{'name': product.name, 'qty': float(qty), 'unitNet': float(unit_net), 'vatRate': float(product.vat_rate)}],
'metadata': {'split_payment': bool(invoice.split_payment)},
}
result = KSeFService(company_id=company.id).issue_invoice(payload)
invoice.ksef_number = result.get('ksef_number', invoice.ksef_number)
invoice.issued_status = result.get('status', 'issued')
invoice.issued_to_ksef_at = InvoiceService().utcnow()
invoice.external_metadata = dict(invoice.external_metadata or {}, ksef_send=result)
flash(result.get('message', 'Wysłano fakturę do KSeF.'), 'success')
AuditService().log('send_invoice_to_ksef', 'invoice', invoice.id, invoice.ksef_number)
else:
flash('Wygenerowano fakturę roboczą. Możesz ją jeszcze poprawić przed wysyłką do KSeF.', 'success')
AuditService().log('draft_invoice', 'invoice', invoice.id, invoice.invoice_number)
db.session.commit()
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
preview_number = form.invoice_number.data or InvoiceService().next_sale_number(company.id, form.numbering_template.data or 'monthly') if customers and products else ''
return render_template('invoices/issued_form.html', form=form, customers=customers, products=products, preview_number=preview_number)
@bp.post('/issued/<int:invoice_id>/send-to-ksef')
@login_required
def send_to_ksef(invoice_id):
invoice = _invoice_or_404(invoice_id)
if invoice.source != 'issued':
abort(400)
if not _ensure_full_access(invoice.company_id):
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
if InvoiceService.invoice_locked(invoice):
flash('Ta faktura została już wysłana do KSeF i jest zablokowana do edycji.', 'warning')
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
if invoice.gross_amount > Decimal('15000'):
invoice.split_payment = True
invoice.external_metadata = dict(invoice.external_metadata or {}, split_payment=True)
first_line = invoice.lines.first()
payload = {
'invoiceNumber': invoice.invoice_number,
'customer': {'name': invoice.contractor_name, 'taxId': invoice.contractor_nip},
'metadata': {'split_payment': bool(invoice.split_payment)},
'lines': [{
'name': first_line.description if first_line else invoice.invoice_number,
'qty': float(first_line.quantity if first_line else 1),
'unitNet': float(first_line.unit_net if first_line else invoice.net_amount),
'vatRate': float(first_line.vat_rate if first_line else 23),
}],
}
result = KSeFService(company_id=invoice.company_id).issue_invoice(payload)
invoice.ksef_number = result.get('ksef_number', invoice.ksef_number)
invoice.issued_status = result.get('status', 'issued')
invoice.issued_to_ksef_at = InvoiceService().utcnow()
invoice.external_metadata = dict(invoice.external_metadata or {}, ksef_send=result)
InvoiceService().persist_issued_assets(invoice)
db.session.commit()
flash(result.get('message', 'Wysłano fakturę do KSeF.'), 'success')
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
@bp.route('/customers', methods=['GET', 'POST'])
@login_required
def customers():
company = _company()
if not company:
return _require_company()
customer_id = request.args.get('customer_id', type=int)
editing = db.session.get(Customer, customer_id) if customer_id else None
if editing and editing.company_id != company.id:
editing = None
if request.method == 'POST' and request.form.get('fetch_ceidg') and _ensure_full_access(company.id):
lookup = CeidgService().fetch_company(request.form.get('tax_id'))
if lookup.get('ok'):
editing = editing or Customer(company_id=company.id)
editing.name = lookup.get('name') or editing.name
editing.tax_id = lookup.get('tax_id') or editing.tax_id
editing.address = lookup.get('address') or editing.address
editing.regon = lookup.get('regon') or editing.regon
flash('Pobrano dane klienta z rejestru przedsiębiorców.', 'success')
else:
flash(lookup.get('message', 'Nie udało się pobrać danych z CEIDG.'), 'warning')
elif request.method == 'POST' and _ensure_full_access(company.id):
target = editing or Customer(company_id=company.id)
target.name = request.form.get('name', '').strip()
target.tax_id = request.form.get('tax_id', '').strip()
target.email = request.form.get('email', '').strip()
target.address = request.form.get('address', '').strip()
target.regon = request.form.get('regon', '').strip()
db.session.add(target)
db.session.commit()
flash('Zapisano klienta.', 'success')
return redirect(url_for('invoices.customers'))
search = (request.args.get('q') or '').strip()
page = request.args.get('page', 1, type=int)
sort = (request.args.get('sort') or 'name_asc').strip()
query = Customer.query.filter_by(company_id=company.id)
if search:
like = f'%{search}%'
query = query.filter(or_(
Customer.name.ilike(like),
Customer.tax_id.ilike(like),
Customer.regon.ilike(like),
Customer.email.ilike(like),
Customer.address.ilike(like)
))
sort_map = {
'name_asc': Customer.name.asc(),
'name_desc': Customer.name.desc(),
'tax_id_asc': Customer.tax_id.asc(),
'tax_id_desc': Customer.tax_id.desc(),
}
pagination = query.order_by(sort_map.get(sort, Customer.name.asc()), Customer.id.asc()).paginate(page=page, per_page=15, error_out=False)
return render_template('invoices/customers.html', items=pagination.items, pagination=pagination, editing=editing, search=search, sort=sort)
@bp.route('/products', methods=['GET', 'POST'])
@login_required
def products():
company = _company()
if not company:
return _require_company()
product_id = request.args.get('product_id', type=int)
editing = db.session.get(Product, product_id) if product_id else None
if editing and editing.company_id != company.id:
editing = None
if request.method == 'POST' and _ensure_full_access(company.id):
target = editing or Product(company_id=company.id)
target.name = request.form.get('name', '').strip()
target.sku = request.form.get('sku', '').strip()
target.unit = request.form.get('unit', 'szt.').strip()
target.net_price = Decimal(request.form.get('net_price', '0') or '0')
target.vat_rate = Decimal(request.form.get('vat_rate', '23') or '23')
target.split_payment_default = bool(request.form.get('split_payment_default'))
db.session.add(target)
db.session.commit()
flash('Zapisano towar/usługę.', 'success')
return redirect(url_for('invoices.products'))
search = (request.args.get('q') or '').strip()
page = request.args.get('page', 1, type=int)
sort = (request.args.get('sort') or 'name_asc').strip()
query = Product.query.filter_by(company_id=company.id)
if search:
like = f'%{search}%'
query = query.filter(or_(Product.name.ilike(like), Product.sku.ilike(like), Product.unit.ilike(like)))
sort_map = {
'name_asc': Product.name.asc(),
'name_desc': Product.name.desc(),
'price_asc': Product.net_price.asc(),
'price_desc': Product.net_price.desc(),
'sku_asc': Product.sku.asc(),
'sku_desc': Product.sku.desc(),
}
pagination = query.order_by(sort_map.get(sort, Product.name.asc()), Product.id.asc()).paginate(page=page, per_page=15, error_out=False)
return render_template('invoices/products.html', items=pagination.items, pagination=pagination, editing=editing, search=search, sort=sort)
@bp.post('/customers/quick-create')
@login_required
def quick_create_customer():
company = _company()
if not company:
return _require_company()
if not _ensure_full_access(company.id):
return redirect(request.form.get('return_to') or url_for('invoices.issued_new'))
name = (request.form.get('name') or '').strip()
if not name:
flash('Podaj nazwę klienta.', 'warning')
return redirect(request.form.get('return_to') or url_for('invoices.issued_new'))
customer = Customer(
company_id=company.id,
name=name,
tax_id=(request.form.get('tax_id') or '').strip(),
email=(request.form.get('email') or '').strip(),
address=(request.form.get('address') or '').strip(),
regon=(request.form.get('regon') or '').strip()
)
db.session.add(customer)
db.session.commit()
flash('Dodano klienta i ustawiono go w formularzu.', 'success')
target = request.form.get('return_endpoint') or 'invoices.issued_new'
target_kwargs = {}
invoice_id = request.form.get('invoice_id', type=int)
if invoice_id and target in {'invoices.issued_edit', 'nfz.edit'}:
target_kwargs['invoice_id'] = invoice_id
return _redirect_with_prefill(target, customer_id=customer.id, **target_kwargs)
@bp.post('/products/quick-create')
@login_required
def quick_create_product():
company = _company()
if not company:
return _require_company()
if not _ensure_full_access(company.id):
return redirect(request.form.get('return_to') or url_for('invoices.issued_new'))
name = (request.form.get('name') or '').strip()
if not name:
flash('Podaj nazwę towaru lub usługi.', 'warning')
return redirect(request.form.get('return_to') or url_for('invoices.issued_new'))
product = Product(
company_id=company.id,
name=name,
sku=(request.form.get('sku') or '').strip(),
unit=(request.form.get('unit') or 'szt.').strip(),
net_price=Decimal(request.form.get('net_price', '0') or '0'),
vat_rate=Decimal(request.form.get('vat_rate', '23') or '23'),
split_payment_default=bool(request.form.get('split_payment_default'))
)
db.session.add(product)
db.session.commit()
flash('Dodano towar/usługę i ustawiono w formularzu.', 'success')
target = request.form.get('return_endpoint') or 'invoices.issued_new'
target_kwargs = {}
invoice_id = request.form.get('invoice_id', type=int)
if invoice_id and target in {'invoices.issued_edit', 'nfz.edit'}:
target_kwargs['invoice_id'] = invoice_id
return _redirect_with_prefill(target, product_id=product.id, **target_kwargs)
@bp.route('/issued/<int:invoice_id>/edit', methods=['GET', 'POST'])
@login_required
def issued_edit(invoice_id):
invoice = _invoice_or_404(invoice_id)
if invoice.source != 'issued':
abort(400)
if InvoiceService.invoice_locked(invoice):
flash('Ta faktura została już wysłana do KSeF i nie można jej edytować.', 'warning')
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
company = invoice.company
if not company or not _ensure_full_access(company.id):
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
form = IssuedInvoiceForm(numbering_template='custom')
customers = Customer.query.filter_by(company_id=company.id, is_active=True).order_by(Customer.name).all()
products = Product.query.filter_by(company_id=company.id, is_active=True).order_by(Product.name).all()
form.customer_id.choices = [(c.id, f'{c.name} ({c.tax_id})' if c.tax_id else c.name) for c in customers]
form.product_id.choices = [(p.id, f'{p.name} - {p.net_price} PLN') for p in products]
line = invoice.lines.first()
if request.method == 'GET':
created_customer_id = request.args.get('created_customer_id', type=int)
created_product_id = request.args.get('created_product_id', type=int)
form.customer_id.data = created_customer_id if created_customer_id and any(c.id == created_customer_id for c in customers) else (invoice.customer_id or (customers[0].id if customers else None))
form.invoice_number.data = invoice.invoice_number
form.product_id.data = created_product_id if created_product_id and any(p.id == created_product_id for p in products) else (line.product_id if line and line.product_id else (products[0].id if products else None))
form.quantity.data = line.quantity if line else 1
form.unit_net.data = line.unit_net if line else invoice.net_amount
form.split_payment.data = bool(invoice.split_payment or (form.product_id.data and next((p.split_payment_default for p in products if p.id == form.product_id.data), False)))
if created_product_id:
selected = next((p for p in products if p.id == created_product_id), None)
if selected:
form.unit_net.data = selected.net_price
if form.validate_on_submit():
customer = db.session.get(Customer, form.customer_id.data)
product = db.session.get(Product, form.product_id.data)
qty = Decimal(str(form.quantity.data or 1))
unit_net = Decimal(str(form.unit_net.data or product.net_price))
net = (qty * unit_net).quantize(Decimal('0.01'))
vat = (net * (Decimal(str(product.vat_rate)) / Decimal('100'))).quantize(Decimal('0.01'))
gross = net + vat
invoice.split_payment = bool(form.split_payment.data or product.split_payment_default or gross > Decimal('15000'))
invoice.customer_id = customer.id
invoice.invoice_number = form.invoice_number.data or invoice.invoice_number
invoice.contractor_name = customer.name
invoice.contractor_nip = customer.tax_id
invoice.net_amount = net
invoice.vat_amount = vat
invoice.gross_amount = gross
invoice.seller_bank_account = (company.bank_account or '').strip()
invoice.ksef_number = f'PENDING/{invoice.invoice_number}'
if not line:
line = InvoiceLine(invoice_id=invoice.id)
db.session.add(line)
line.product_id = product.id
line.description = product.name
line.quantity = qty
line.unit = product.unit
line.unit_net = unit_net
line.vat_rate = product.vat_rate
line.net_amount = net
line.vat_amount = vat
line.gross_amount = gross
InvoiceService().persist_issued_assets(invoice)
invoice.external_metadata = dict(invoice.external_metadata or {}, split_payment=invoice.split_payment, seller_bank_account=invoice.seller_bank_account)
invoice.issued_status = 'draft'
db.session.commit()
flash('Zapisano zmiany w fakturze roboczej.', 'success')
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
preview_number = form.invoice_number.data or invoice.invoice_number
return render_template(
'invoices/issued_form.html',
form=form,
customers=customers,
products=products,
preview_number=preview_number,
editing_invoice=invoice
)
@bp.route('/<int:invoice_id>', methods=['GET', 'POST'])
@login_required
def detail(invoice_id):
invoice = _invoice_or_404(invoice_id)
service = InvoiceService()
service.mark_read(invoice)
locked = read_only = SettingsService.read_only_enabled(company_id=invoice.company_id) or InvoiceService.invoice_locked(invoice)
form = InvoiceMetaForm(
status=invoice.status.value,
tags=', '.join([t.name for t in invoice.tags]),
internal_note=invoice.internal_note,
queue_accounting=invoice.queue_accounting,
pinned=invoice.pinned
)
if form.validate_on_submit() and not locked:
service.update_metadata(invoice, form)
AuditService().log('update', 'invoice', invoice.id, 'Updated metadata')
flash('Zapisano zmiany.', 'success')
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
xml_content = ''
if invoice.xml_path:
from pathlib import Path as _Path
path = _Path(invoice.xml_path)
if path.exists():
xml_content = path.read_text(encoding='utf-8')
invoice.html_preview = service.render_invoice_html(invoice)
linked_customer = db.session.get(Customer, invoice.customer_id) if invoice.customer_id else None
can_add_seller_customer = bool(invoice.contractor_name) and invoice.source not in ['issued', 'nfz']
if invoice.source in ['issued', 'nfz']:
if not xml_content or (invoice.source == 'issued' and not invoice.xml_path):
xml_content = service.persist_issued_assets(invoice)
else:
PdfService().render_invoice_pdf(invoice)
else:
if invoice.xml_path:
PdfService().render_invoice_pdf(invoice)
db.session.commit()
payment_details = service.resolve_payment_details(invoice)
return render_template(
'invoices/detail.html',
invoice=invoice,
form=form,
xml_content=xml_content,
edit_locked=locked,
linked_customer=linked_customer,
can_add_seller_customer=can_add_seller_customer,
payment_details=payment_details,
)
@bp.route('/<int:invoice_id>/duplicate')
@login_required
def duplicate(invoice_id):
invoice = _invoice_or_404(invoice_id)
if invoice.source == 'nfz':
return redirect(url_for('nfz.index', duplicate_id=invoice_id))
return redirect(url_for('invoices.issued_new', duplicate_id=invoice_id))
@bp.route('/<int:invoice_id>/pdf')
@login_required
def pdf(invoice_id):
invoice = _invoice_or_404(invoice_id)
pdf_bytes, _ = PdfService().render_invoice_pdf(invoice)
return send_file(BytesIO(pdf_bytes), download_name=f'{invoice.invoice_number}.pdf', mimetype='application/pdf')
@bp.route('/<int:invoice_id>/send', methods=['POST'])
@login_required
def send(invoice_id):
invoice = _invoice_or_404(invoice_id)
if not _ensure_full_access(invoice.company_id):
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
recipient = (request.form.get('recipient') or '').strip()
if not recipient:
flash('Brak adresu e-mail odbiorcy.', 'warning')
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
delivery = MailService(company_id=invoice.company_id).send_invoice(invoice, recipient)
if delivery.status == 'sent':
flash(f'Fakturę wysłano na adres: {delivery.recipient}.', 'success')
else:
error_message = (delivery.error_message or 'Nieznany błąd SMTP').strip()
flash(
f'Nie udało się wysłać faktury na adres: {delivery.recipient}. Błąd: {error_message}',
'danger'
)
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
@bp.route('/export/csv')
@login_required
def export_csv():
company = _company()
rows = InvoiceRepository().query_filtered(request.args, company_id=company.id if company else None).all()
import io
sio = io.StringIO()
writer = csv.writer(sio)
writer.writerow(['Firma', 'Numer', 'KSeF', 'Kontrahent', 'NIP', 'Data', 'Netto', 'VAT', 'Brutto', 'Typ', 'Status'])
for invoice in rows:
writer.writerow([
invoice.company.name if invoice.company else '',
invoice.invoice_number,
invoice.ksef_number,
invoice.contractor_name,
invoice.contractor_nip,
invoice.issue_date.isoformat(),
str(invoice.net_amount),
str(invoice.vat_amount),
str(invoice.gross_amount),
invoice.invoice_type.value,
invoice.status.value
])
return Response(sio.getvalue(), mimetype='text/csv', headers={'Content-Disposition': 'attachment; filename=invoices.csv'})
@bp.route('/export/zip')
@login_required
def export_zip():
company = _company()
rows = InvoiceRepository().query_filtered(request.args, company_id=company.id if company else None).all()
mem = BytesIO()
with zipfile.ZipFile(mem, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
for invoice in rows:
if invoice.xml_path:
from pathlib import Path
path = Path(invoice.xml_path)
if path.exists():
zf.write(path, arcname=path.name)
mem.seek(0)
return send_file(mem, download_name='invoices.zip', mimetype='application/zip')
@bp.route('/monthly/<period>/pdf')
@login_required
def month_pdf(period):
company = _company()
groups = [g for g in InvoiceService().grouped_summary(company_id=company.id if company else None, period='month') if g['key'] == period]
if not groups:
return redirect(url_for('invoices.monthly'))
pdf_bytes = PdfService().month_pdf(groups[0]['entries'], f'Miesiąc {period}')
return send_file(BytesIO(pdf_bytes), download_name=f'{period}.pdf', mimetype='application/pdf')
@bp.route('/bulk', methods=['POST'])
@login_required
def bulk_action():
company = _company()
if SettingsService.read_only_enabled(company_id=company.id if company else None):
flash('Tryb read only jest aktywny.', 'warning')
return redirect(url_for('invoices.index'))
ids = request.form.getlist('invoice_ids')
action = request.form.get('action')
invoices = Invoice.query.filter(Invoice.company_id == (company.id if company else None), Invoice.id.in_(ids)).all()
for invoice in invoices:
if action == 'mark_accounted':
invoice.status = invoice.status.__class__.ACCOUNTED
elif action == 'queue_accounting':
invoice.queue_accounting = True
db.session.commit()
flash('Wykonano akcję masową.', 'success')
return redirect(url_for('invoices.index'))
@bp.post('/<int:invoice_id>/add-seller-customer')
@login_required
def add_seller_customer(invoice_id):
invoice = _invoice_or_404(invoice_id)
if invoice.source in ['issued', 'nfz']:
flash('Dla faktur sprzedażowych kontrahent jest już obsługiwany w kartotece odbiorców.', 'info')
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
if not _ensure_full_access(invoice.company_id):
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
customer, created = _customer_from_invoice(invoice, invoice.company_id)
db.session.commit()
flash('Dodano kontrahenta do kartoteki klientów.' if created else 'Powiązano kontrahenta z istniejącą kartoteką klientów.', 'success')
return redirect(url_for('invoices.customers', customer_id=customer.id))

12
app/logging_config.py Normal file
View File

@@ -0,0 +1,12 @@
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
def configure_logging(app):
log_dir = Path('instance')
log_dir.mkdir(exist_ok=True)
handler = RotatingFileHandler(log_dir / 'app.log', maxBytes=1_000_000, backupCount=5)
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s [%(name)s] %(message)s'))
app.logger.setLevel(app.config['LOG_LEVEL'])
if not app.logger.handlers:
app.logger.addHandler(handler)

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

10
app/models/audit_log.py Normal file
View File

@@ -0,0 +1,10 @@
from app.extensions import db
from app.models.base import TimestampMixin
class AuditLog(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
action = db.Column(db.String(64), nullable=False)
target_type = db.Column(db.String(64), nullable=False)
target_id = db.Column(db.Integer)
remote_addr = db.Column(db.String(64))
details = db.Column(db.Text)

6
app/models/base.py Normal file
View File

@@ -0,0 +1,6 @@
from datetime import datetime
from app.extensions import db
class TimestampMixin:
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

50
app/models/catalog.py Normal file
View File

@@ -0,0 +1,50 @@
from app.extensions import db
from app.models.base import TimestampMixin
class Customer(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False)
tax_id = db.Column(db.String(32), index=True, default='')
email = db.Column(db.String(255), default='')
address = db.Column(db.String(255), default='')
regon = db.Column(db.String(32), default='')
is_active = db.Column(db.Boolean, default=True, nullable=False)
company = db.relationship('Company', backref=db.backref('customers', lazy='dynamic', cascade='all, delete-orphan'))
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_customer_company_name'),)
class Product(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False)
sku = db.Column(db.String(64), default='')
unit = db.Column(db.String(16), default='szt.')
net_price = db.Column(db.Numeric(12, 2), nullable=False, default=0)
vat_rate = db.Column(db.Numeric(5, 2), nullable=False, default=23)
split_payment_default = db.Column(db.Boolean, default=False, nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
company = db.relationship('Company', backref=db.backref('products', lazy='dynamic', cascade='all, delete-orphan'))
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_product_company_name'),)
class InvoiceLine(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
invoice_id = db.Column(db.Integer, db.ForeignKey('invoice.id'), nullable=False, index=True)
product_id = db.Column(db.Integer, db.ForeignKey('product.id'), index=True)
description = db.Column(db.String(255), nullable=False)
quantity = db.Column(db.Numeric(12, 2), nullable=False, default=1)
unit = db.Column(db.String(16), default='szt.')
unit_net = db.Column(db.Numeric(12, 2), nullable=False, default=0)
vat_rate = db.Column(db.Numeric(5, 2), nullable=False, default=23)
net_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
vat_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
gross_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
invoice = db.relationship('Invoice', backref=db.backref('lines', lazy='dynamic', cascade='all, delete-orphan'))
product = db.relationship('Product')

29
app/models/company.py Normal file
View File

@@ -0,0 +1,29 @@
from app.extensions import db
from app.models.base import TimestampMixin
class Company(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, unique=True)
tax_id = db.Column(db.String(32), index=True)
regon = db.Column(db.String(32), index=True, default='')
address = db.Column(db.String(255), default='')
bank_account = db.Column(db.String(64), default='')
is_active = db.Column(db.Boolean, default=True, nullable=False)
sync_enabled = db.Column(db.Boolean, default=False, nullable=False)
sync_interval_minutes = db.Column(db.Integer, default=60, nullable=False)
notification_enabled = db.Column(db.Boolean, default=True, nullable=False)
note = db.Column(db.Text)
class UserCompanyAccess(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False, index=True)
access_level = db.Column(db.String(20), default='full', nullable=False)
user = db.relationship('User', back_populates='company_access')
company = db.relationship('Company', back_populates='user_access')
Company.user_access = db.relationship('UserCompanyAccess', back_populates='company', cascade='all, delete-orphan')

185
app/models/invoice.py Normal file
View File

@@ -0,0 +1,185 @@
import enum
from app.extensions import db
from app.models.base import TimestampMixin
invoice_tags = db.Table(
'invoice_tags',
db.Column('invoice_id', db.Integer, db.ForeignKey('invoice.id'), primary_key=True),
db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'), primary_key=True),
)
class InvoiceType(enum.Enum):
PURCHASE = 'purchase'
SALE = 'sale'
CORRECTION = 'correction'
class InvoiceStatus(enum.Enum):
NEW = 'new'
READ = 'read'
ACCOUNTED = 'accounted'
SENT = 'sent'
ARCHIVED = 'archived'
NEEDS_ATTENTION = 'needs_attention'
ERROR = 'error'
INVOICE_TYPE_LABELS = {
InvoiceType.PURCHASE: 'Zakupowa',
InvoiceType.SALE: 'Sprzedażowa',
InvoiceType.CORRECTION: 'Korekta',
}
INVOICE_STATUS_LABELS = {
InvoiceStatus.NEW: 'Nowa',
InvoiceStatus.READ: 'Odczytana',
InvoiceStatus.ACCOUNTED: 'Zaksięgowana',
InvoiceStatus.SENT: 'Wysłana',
InvoiceStatus.ARCHIVED: 'Archiwalna',
InvoiceStatus.NEEDS_ATTENTION: 'Do księgowania',
InvoiceStatus.ERROR: 'Błąd',
}
ISSUED_STATUS_LABELS = {
'draft': 'Robocza',
'pending': 'Oczekuje wysyłki',
'issued': 'Wysłana do KSeF',
'received': 'Odebrana',
'read': 'Odczytana',
'accounted': 'Zaksięgowana',
'queued': 'W kolejce',
'error': 'Błąd',
'sent': 'Wysłana',
'needs_attention': 'Do księgowania',
'issued_mock': 'Wysłana testowo',
}
class Invoice(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), index=True)
ksef_number = db.Column(db.String(128), nullable=False, index=True)
invoice_number = db.Column(db.String(128), nullable=False, index=True)
contractor_name = db.Column(db.String(255), nullable=False, index=True)
contractor_nip = db.Column(db.String(32), index=True)
contractor_regon = db.Column(db.String(32), index=True)
contractor_address = db.Column(db.String(512))
issue_date = db.Column(db.Date, nullable=False, index=True)
received_date = db.Column(db.Date, index=True)
fetched_at = db.Column(db.DateTime, index=True)
net_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
vat_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
gross_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0)
split_payment = db.Column(db.Boolean, default=False, nullable=False)
currency = db.Column(db.String(8), default='PLN')
seller_bank_account = db.Column(db.String(64), default='')
invoice_type = db.Column(db.Enum(InvoiceType), nullable=False, default=InvoiceType.PURCHASE)
status = db.Column(db.Enum(InvoiceStatus), nullable=False, default=InvoiceStatus.NEW)
xml_path = db.Column(db.String(512))
pdf_path = db.Column(db.String(512))
html_preview = db.Column(db.Text)
internal_note = db.Column(db.Text)
source_hash = db.Column(db.String(128))
read_at = db.Column(db.DateTime)
last_synced_at = db.Column(db.DateTime)
external_metadata = db.Column(db.JSON, default=dict)
is_unread = db.Column(db.Boolean, default=True, nullable=False)
pinned = db.Column(db.Boolean, default=False, nullable=False)
queue_accounting = db.Column(db.Boolean, default=False, nullable=False)
source = db.Column(db.String(32), default='ksef', nullable=False)
customer_id = db.Column(db.Integer, db.ForeignKey('customer.id'), index=True)
issued_to_ksef_at = db.Column(db.DateTime)
issued_status = db.Column(db.String(32), default='received', nullable=False)
tags = db.relationship(
'Tag',
secondary=invoice_tags,
lazy='joined',
backref=db.backref('invoices', lazy='dynamic'),
)
sync_events = db.relationship(
'SyncEvent',
backref='invoice',
lazy='dynamic',
cascade='all, delete-orphan',
)
mail_deliveries = db.relationship(
'MailDelivery',
backref='invoice',
lazy='dynamic',
cascade='all, delete-orphan',
)
notifications = db.relationship(
'NotificationLog',
backref='invoice',
lazy='dynamic',
cascade='all, delete-orphan',
)
company = db.relationship('Company', backref=db.backref('invoices', lazy='dynamic'))
customer = db.relationship('Customer', backref=db.backref('invoices', lazy='dynamic'))
__table_args__ = (
db.UniqueConstraint('company_id', 'ksef_number', name='uq_invoice_company_ksef'),
)
@property
def month_key(self):
return f'{self.issue_date.year}-{self.issue_date.month:02d}'
@property
def invoice_type_label(self):
return INVOICE_TYPE_LABELS.get(self.invoice_type, getattr(self.invoice_type, 'value', self.invoice_type))
@property
def status_label(self):
return INVOICE_STATUS_LABELS.get(self.status, getattr(self.status, 'value', self.status))
@property
def issued_status_label(self):
return ISSUED_STATUS_LABELS.get(self.issued_status, self.issued_status)
class Tag(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True, nullable=False)
color = db.Column(db.String(32), default='secondary')
class SyncEvent(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
invoice_id = db.Column(db.Integer, db.ForeignKey('invoice.id'), nullable=False)
status = db.Column(db.String(32), nullable=False)
message = db.Column(db.Text)
source = db.Column(db.String(32), default='ksef')
class MailDelivery(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
invoice_id = db.Column(db.Integer, db.ForeignKey('invoice.id'), nullable=False)
recipient = db.Column(db.String(255), nullable=False)
status = db.Column(db.String(32), default='queued')
subject = db.Column(db.String(255))
error_message = db.Column(db.Text)
sent_at = db.Column(db.DateTime)
class NotificationLog(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
invoice_id = db.Column(db.Integer, db.ForeignKey('invoice.id'))
channel = db.Column(db.String(32), nullable=False)
status = db.Column(db.String(32), default='queued')
message = db.Column(db.Text)
sent_at = db.Column(db.DateTime)

View File

@@ -0,0 +1 @@
from app.models.invoice import NotificationLog

50
app/models/setting.py Normal file
View File

@@ -0,0 +1,50 @@
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from flask import current_app
from sqlalchemy.exc import OperationalError, ProgrammingError
from app.extensions import db
from app.models.base import TimestampMixin
class AppSetting(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(128), unique=True, nullable=False, index=True)
value = db.Column(db.Text)
is_encrypted = db.Column(db.Boolean, default=False, nullable=False)
@classmethod
def _cipher(cls):
secret = current_app.config.get('APP_MASTER_KEY', current_app.config.get('SECRET_KEY', 'dev')).encode('utf-8')
digest = hashlib.sha256(secret).digest()
return Fernet(base64.urlsafe_b64encode(digest))
@classmethod
def get(cls, key, default=None, decrypt=False):
try:
item = cls.query.filter_by(key=key).first()
if not item:
return default
if decrypt and item.is_encrypted and item.value:
try:
return cls._cipher().decrypt(item.value.encode('utf-8')).decode('utf-8')
except InvalidToken:
return default
return item.value if item.value is not None else default
except (OperationalError, ProgrammingError):
return default
@classmethod
def set(cls, key, value, encrypt=False):
item = cls.query.filter_by(key=key).first()
if not item:
item = cls(key=key)
db.session.add(item)
item.is_encrypted = encrypt
if value is None:
item.value = None
elif encrypt:
item.value = cls._cipher().encrypt(str(value).encode('utf-8')).decode('utf-8')
else:
item.value = str(value)
return item

18
app/models/sync_log.py Normal file
View File

@@ -0,0 +1,18 @@
from app.extensions import db
from app.models.base import TimestampMixin
class SyncLog(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), index=True)
sync_type = db.Column(db.String(32), default='started')
status = db.Column(db.String(32), default='started')
started_at = db.Column(db.DateTime, nullable=False)
finished_at = db.Column(db.DateTime)
processed = db.Column(db.Integer, default=0)
total = db.Column(db.Integer, default=0)
created = db.Column(db.Integer, default=0)
updated = db.Column(db.Integer, default=0)
errors = db.Column(db.Integer, default=0)
message = db.Column(db.Text)
company = db.relationship('Company', backref=db.backref('sync_logs', lazy='dynamic'))

40
app/models/user.py Normal file
View File

@@ -0,0 +1,40 @@
from flask_login import UserMixin
from werkzeug.security import check_password_hash
from app.extensions import db, login_manager
from app.models.base import TimestampMixin
class User(UserMixin, TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
name = db.Column(db.String(255), nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(50), default='operator', nullable=False)
theme_preference = db.Column(db.String(20), default='light', nullable=False)
is_blocked = db.Column(db.Boolean, default=False, nullable=False)
force_password_change = db.Column(db.Boolean, default=False, nullable=False)
last_login_at = db.Column(db.DateTime)
company_access = db.relationship('UserCompanyAccess', back_populates='user', cascade='all, delete-orphan')
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def companies(self):
return [item.company for item in self.company_access if item.company and item.company.is_active]
def can_access_company(self, company_id):
return any(item.company_id == company_id for item in self.company_access)
def company_access_level(self, company_id):
for item in self.company_access:
if item.company_id == company_id:
return item.access_level
return None
def is_company_readonly(self, company_id):
return self.company_access_level(company_id) == 'readonly' or self.role == 'readonly'
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))

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

281
app/nfz/routes.py Normal file
View File

@@ -0,0 +1,281 @@
from __future__ import annotations
from datetime import date
from decimal import Decimal
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from flask_login import login_required
from app.extensions import db
from app.forms.nfz import NFZ_BRANCH_CHOICES, NfzInvoiceForm
from app.models.catalog import Customer, InvoiceLine, Product
from app.models.invoice import Invoice, InvoiceStatus, InvoiceType
from app.services.audit_service import AuditService
from app.services.company_service import CompanyService
from app.services.invoice_service import InvoiceService
from app.services.ksef_service import KSeFService
from app.services.settings_service import SettingsService
bp = Blueprint('nfz', __name__, url_prefix='/nfz')
NFZ_NIP = '1070001057'
NFZ_BRANCH_MAP = dict(NFZ_BRANCH_CHOICES)
def _company():
return CompanyService.get_current_company()
def _module_enabled(company_id: int | None) -> bool:
return bool(company_id) and SettingsService.get_effective('modules.nfz_enabled', 'false', company_id=company_id) == 'true'
def _invoice_or_404(invoice_id: int):
company = _company()
invoice = db.session.get(Invoice, invoice_id)
if not invoice or not company or invoice.company_id != company.id or invoice.source != 'nfz':
abort(404)
return invoice
def _prepare_form(form: NfzInvoiceForm, company):
customers = Customer.query.filter_by(company_id=company.id, is_active=True).order_by(Customer.name).all()
products = Product.query.filter_by(company_id=company.id, is_active=True).order_by(Product.name).all()
form.customer_id.choices = [(c.id, f'{c.name} ({c.tax_id})' if c.tax_id else c.name) for c in customers]
form.product_id.choices = [(p.id, f'{p.name} - {p.net_price} PLN') for p in products]
return customers, products
def _hydrate_form_from_invoice(form: NfzInvoiceForm, invoice: Invoice, *, duplicate: bool = False):
meta = (invoice.external_metadata or {}).get('nfz', {})
line = invoice.lines.first()
form.customer_id.data = invoice.customer_id
form.product_id.data = line.product_id if line and line.product_id else None
form.quantity.data = line.quantity if line else Decimal('1')
form.unit_net.data = line.unit_net if line else invoice.net_amount
form.invoice_number.data = f'{invoice.invoice_number}/COPY' if duplicate else invoice.invoice_number
form.nfz_branch_id.data = meta.get('recipient_branch_id')
if meta.get('settlement_from'):
form.settlement_from.data = date.fromisoformat(meta['settlement_from'])
if meta.get('settlement_to'):
form.settlement_to.data = date.fromisoformat(meta['settlement_to'])
form.template_identifier.data = meta.get('template_identifier', '')
form.provider_identifier.data = meta.get('provider_identifier', '')
form.service_code.data = meta.get('service_code', '')
form.contract_number.data = meta.get('contract_number', '')
def _build_nfz_metadata(form: NfzInvoiceForm):
return {
'nfz': {
'recipient_nip': NFZ_NIP,
'recipient_branch_id': form.nfz_branch_id.data,
'recipient_branch_name': NFZ_BRANCH_MAP.get(form.nfz_branch_id.data, form.nfz_branch_id.data),
'settlement_from': form.settlement_from.data.isoformat(),
'settlement_to': form.settlement_to.data.isoformat(),
'template_identifier': (form.template_identifier.data or '').strip(),
'provider_identifier': form.provider_identifier.data.strip(),
'service_code': form.service_code.data.strip(),
'contract_number': form.contract_number.data.strip(),
'nfz_schema': 'FA(3)',
'required_fields': ['IDWew', 'P_6_Od', 'P_6_Do', 'identyfikator-swiadczeniodawcy', 'Indeks', 'NrUmowy'],
}
}
def _save_invoice_from_form(invoice: Invoice | None, form: NfzInvoiceForm, company, *, send_to_ksef: bool):
customer = db.session.get(Customer, form.customer_id.data)
product = db.session.get(Product, form.product_id.data)
qty = Decimal(str(form.quantity.data or 1))
unit_net = Decimal(str(form.unit_net.data or product.net_price))
net = (qty * unit_net).quantize(Decimal('0.01'))
vat = (net * (Decimal(str(product.vat_rate)) / Decimal('100'))).quantize(Decimal('0.01'))
gross = net + vat
number = form.invoice_number.data or (invoice.invoice_number if invoice else InvoiceService().next_sale_number(company.id, 'monthly'))
metadata = _build_nfz_metadata(form)
if invoice is None:
invoice = Invoice(
company_id=company.id,
customer_id=customer.id,
ksef_number=f'NFZ-PENDING/{number}',
invoice_number=number,
contractor_name=f'Narodowy Fundusz Zdrowia - {NFZ_BRANCH_MAP.get(form.nfz_branch_id.data, form.nfz_branch_id.data)}',
contractor_nip=NFZ_NIP,
issue_date=InvoiceService().today_date(),
received_date=InvoiceService().today_date(),
net_amount=net,
vat_amount=vat,
gross_amount=gross,
seller_bank_account=(company.bank_account or '').strip(),
invoice_type=InvoiceType.SALE,
status=InvoiceStatus.NEW,
source='nfz',
issued_status='draft' if not send_to_ksef else 'pending',
external_metadata=dict(metadata, seller_bank_account=(company.bank_account or '').strip()),
html_preview='',
)
db.session.add(invoice)
db.session.flush()
else:
invoice.customer_id = customer.id
invoice.invoice_number = number
invoice.ksef_number = f'NFZ-PENDING/{number}' if not invoice.issued_to_ksef_at else invoice.ksef_number
invoice.contractor_name = f'Narodowy Fundusz Zdrowia - {NFZ_BRANCH_MAP.get(form.nfz_branch_id.data, form.nfz_branch_id.data)}'
invoice.contractor_nip = NFZ_NIP
invoice.net_amount = net
invoice.vat_amount = vat
invoice.gross_amount = gross
invoice.seller_bank_account = (company.bank_account or '').strip()
invoice.external_metadata = dict(metadata, seller_bank_account=invoice.seller_bank_account)
invoice.issued_status = 'draft' if not send_to_ksef else 'pending'
line = invoice.lines.first()
if not line:
line = InvoiceLine(invoice_id=invoice.id)
db.session.add(line)
line.product_id = product.id
line.description = product.name
line.quantity = qty
line.unit = product.unit
line.unit_net = unit_net
line.vat_rate = product.vat_rate
line.net_amount = net
line.vat_amount = vat
line.gross_amount = gross
payload = InvoiceService().build_ksef_payload(invoice)
InvoiceService().persist_issued_assets(invoice, xml_content=payload['xml_content'])
if send_to_ksef:
result = KSeFService(company_id=company.id).issue_invoice(payload)
invoice.ksef_number = result.get('ksef_number', invoice.ksef_number)
invoice.issued_status = result.get('status', 'issued')
invoice.issued_to_ksef_at = InvoiceService().utcnow()
InvoiceService().persist_issued_assets(invoice, xml_content=result.get('xml_content') or payload['xml_content'])
return invoice, result
return invoice, None
@bp.before_request
@login_required
def ensure_enabled():
company = _company()
if not _module_enabled(company.id if company else None):
abort(404)
@bp.route('/', methods=['GET', 'POST'])
@login_required
def index():
company = _company()
form = NfzInvoiceForm()
customers, products = _prepare_form(form, company)
drafts = (
Invoice.query.filter_by(company_id=company.id, source='nfz')
.order_by(Invoice.created_at.desc())
.limit(10)
.all()
)
if request.method == 'GET':
duplicate_id = request.args.get('duplicate_id', type=int)
if duplicate_id:
_hydrate_form_from_invoice(form, _invoice_or_404(duplicate_id), duplicate=True)
else:
created_customer_id = request.args.get('created_customer_id', type=int)
created_product_id = request.args.get('created_product_id', type=int)
if created_customer_id and any(c.id == created_customer_id for c in customers):
form.customer_id.data = created_customer_id
elif customers and not form.customer_id.data:
form.customer_id.data = customers[0].id
if created_product_id and any(p.id == created_product_id for p in products):
selected = next((p for p in products if p.id == created_product_id), None)
form.product_id.data = created_product_id
form.unit_net.data = selected.net_price if selected else form.unit_net.data
elif products and not form.product_id.data:
form.product_id.data = products[0].id
form.unit_net.data = products[0].net_price
if not form.invoice_number.data:
form.invoice_number.data = InvoiceService().next_sale_number(company.id, 'monthly')
if form.validate_on_submit():
if SettingsService.read_only_enabled(company_id=company.id):
flash('Tryb tylko do odczytu jest aktywny dla tej firmy.', 'warning')
return redirect(url_for('nfz.index'))
if form.settlement_to.data < form.settlement_from.data:
flash('Data końcowa okresu rozliczeniowego nie może być wcześniejsza niż data początkowa.', 'warning')
return render_template('nfz/index.html', form=form, drafts=drafts, company=company, spec_fields=spec_fields())
invoice, result = _save_invoice_from_form(None, form, company, send_to_ksef=bool(form.submit.data))
if result:
flash(result.get('message', 'Zapisano i wysłano fakturę NFZ do KSeF.'), 'success')
AuditService().log('send_nfz_invoice_to_ksef', 'invoice', invoice.id, invoice.ksef_number)
else:
flash('Zapisano roboczą fakturę NFZ.', 'success')
AuditService().log('draft_nfz_invoice', 'invoice', invoice.id, invoice.invoice_number)
db.session.commit()
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
return render_template('nfz/index.html', form=form, drafts=drafts, company=company, spec_fields=spec_fields())
@bp.route('/<int:invoice_id>/edit', methods=['GET', 'POST'])
@login_required
def edit(invoice_id):
invoice = _invoice_or_404(invoice_id)
if InvoiceService.invoice_locked(invoice):
flash('Ta faktura została już wysłana do KSeF i nie można jej edytować.', 'warning')
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
company = _company()
form = NfzInvoiceForm()
_prepare_form(form, company)
if request.method == 'GET':
_hydrate_form_from_invoice(form, invoice)
created_customer_id = request.args.get('created_customer_id', type=int)
created_product_id = request.args.get('created_product_id', type=int)
customers = Customer.query.filter_by(company_id=company.id, is_active=True).order_by(Customer.name).all()
products = Product.query.filter_by(company_id=company.id, is_active=True).order_by(Product.name).all()
if created_customer_id and any(c.id == created_customer_id for c in customers):
form.customer_id.data = created_customer_id
if created_product_id and any(p.id == created_product_id for p in products):
selected = next((p for p in products if p.id == created_product_id), None)
form.product_id.data = created_product_id
if selected:
form.unit_net.data = selected.net_price
if form.validate_on_submit():
if form.settlement_to.data < form.settlement_from.data:
flash('Data końcowa okresu rozliczeniowego nie może być wcześniejsza niż data początkowa.', 'warning')
return render_template('nfz/index.html', form=form, drafts=[], company=company, spec_fields=spec_fields(), editing_invoice=invoice)
invoice, result = _save_invoice_from_form(invoice, form, company, send_to_ksef=bool(form.submit.data))
db.session.commit()
if result:
flash(result.get('message', 'Zapisano i wysłano fakturę NFZ do KSeF.'), 'success')
else:
flash('Zapisano zmiany w fakturze NFZ.', 'success')
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
return render_template('nfz/index.html', form=form, drafts=[], company=company, spec_fields=spec_fields(), editing_invoice=invoice)
@bp.post('/<int:invoice_id>/send-to-ksef')
@login_required
def send_to_ksef(invoice_id):
invoice = _invoice_or_404(invoice_id)
if InvoiceService.invoice_locked(invoice):
flash('Ta faktura została już wysłana do KSeF i jest zablokowana do edycji.', 'warning')
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
payload = InvoiceService().build_ksef_payload(invoice)
result = KSeFService(company_id=invoice.company_id).issue_invoice(payload)
invoice.ksef_number = result.get('ksef_number', invoice.ksef_number)
invoice.issued_status = result.get('status', 'issued')
invoice.issued_to_ksef_at = InvoiceService().utcnow()
invoice.external_metadata = dict(invoice.external_metadata or {}, ksef_send=result)
InvoiceService().persist_issued_assets(invoice, xml_content=result.get('xml_content') or payload['xml_content'])
db.session.commit()
flash(result.get('message', 'Wysłano fakturę NFZ do KSeF.'), 'success')
return redirect(url_for('invoices.detail', invoice_id=invoice.id))
def spec_fields():
return [
('IDWew', 'Identyfikator OW NFZ w Podmiot3/DaneIdentyfikacyjne.'),
('P_6_Od / P_6_Do', 'Zakres dat okresu rozliczeniowego od i do.'),
('identyfikator-szablonu', 'Id szablonu z komunikatu R_UMX, gdy jest wymagany.'),
('identyfikator-swiadczeniodawcy', 'Kod świadczeniodawcy nadany w OW NFZ.'),
('Indeks', 'Kod zakresu świadczeń / wyróżnik / kod świadczenia.'),
('NrUmowy', 'Numer umowy NFZ, a dla aneksu także numer aneksu ugodowego.'),
]

View File

View File

@@ -0,0 +1,12 @@
from flask import Blueprint, render_template
from flask_login import login_required
from app.models.invoice import NotificationLog
bp = Blueprint('notifications', __name__, url_prefix='/notifications')
@bp.route('/')
@login_required
def index():
logs = NotificationLog.query.order_by(NotificationLog.created_at.desc()).limit(100).all()
return render_template('notifications/index.html', logs=logs)

View File

View File

@@ -0,0 +1,108 @@
from datetime import date, timedelta
from sqlalchemy import Integer, extract, or_, func, cast
from app.models.invoice import Invoice, InvoiceStatus, InvoiceType
from app.services.company_service import CompanyService
class InvoiceRepository:
def base_query(self, company_id=None):
if company_id is None:
company = CompanyService.get_current_company()
company_id = company.id if company else None
query = Invoice.query
if company_id:
query = query.filter(Invoice.company_id == company_id)
return query
def incoming_query(self, company_id=None):
query = self.base_query(company_id)
return query.filter(
Invoice.invoice_type != InvoiceType.SALE,
~Invoice.source.in_(['issued', 'nfz']),
)
def query_filtered(self, form_data, company_id=None):
query = self.incoming_query(company_id)
month = form_data.get('month')
year = form_data.get('year')
quick_filter = form_data.get('quick_filter')
min_amount = form_data.get('min_amount')
max_amount = form_data.get('max_amount')
if month:
query = query.filter(extract('month', Invoice.issue_date) == int(month))
if year:
query = query.filter(extract('year', Invoice.issue_date) == int(year))
if form_data.get('contractor'):
query = query.filter(Invoice.contractor_name.ilike(f"%{form_data['contractor']}%"))
if form_data.get('nip'):
query = query.filter(Invoice.contractor_nip.ilike(f"%{form_data['nip']}%"))
if form_data.get('invoice_type'):
query = query.filter(Invoice.invoice_type == InvoiceType(form_data['invoice_type']))
if form_data.get('status'):
query = query.filter(Invoice.status == InvoiceStatus(form_data['status']))
if min_amount:
query = query.filter(Invoice.gross_amount >= float(min_amount))
if max_amount:
query = query.filter(Invoice.gross_amount <= float(max_amount))
if form_data.get('search'):
term = f"%{form_data['search']}%"
query = query.filter(or_(Invoice.invoice_number.ilike(term), Invoice.ksef_number.ilike(term), Invoice.contractor_name.ilike(term), Invoice.contractor_nip.ilike(term)))
today = date.today()
if quick_filter == 'this_month':
query = query.filter(extract('month', Invoice.issue_date) == today.month, extract('year', Invoice.issue_date) == today.year)
elif quick_filter == 'previous_month':
prev = today.replace(day=1) - timedelta(days=1)
query = query.filter(extract('month', Invoice.issue_date) == prev.month, extract('year', Invoice.issue_date) == prev.year)
elif quick_filter == 'unread':
query = query.filter(Invoice.is_unread.is_(True))
elif quick_filter == 'error':
query = query.filter(Invoice.status == InvoiceStatus.ERROR)
elif quick_filter == 'to_send':
query = query.filter(Invoice.status.in_([InvoiceStatus.NEW, InvoiceStatus.READ]))
return query.order_by(Invoice.issue_date.desc(), Invoice.id.desc())
def get_by_ksef_number(self, ksef_number, company_id=None):
return self.base_query(company_id).filter_by(ksef_number=ksef_number).first()
def monthly_summary(self, company_id=None):
return self.base_query(company_id).with_entities(
extract('year', Invoice.issue_date).label('year'),
extract('month', Invoice.issue_date).label('month'),
func.count(Invoice.id).label('count'),
func.sum(Invoice.net_amount).label('net'),
func.sum(Invoice.vat_amount).label('vat'),
func.sum(Invoice.gross_amount).label('gross'),
).group_by('year', 'month').order_by(extract('year', Invoice.issue_date).desc(), extract('month', Invoice.issue_date).desc()).all()
def summary_query(self, company_id=None, *, period='month', search=None):
query = self.base_query(company_id)
if search:
like = f'%{search}%'
query = query.filter(or_(
Invoice.invoice_number.ilike(like),
Invoice.ksef_number.ilike(like),
Invoice.contractor_name.ilike(like),
Invoice.contractor_nip.ilike(like),
))
year_expr = extract('year', Invoice.issue_date)
month_expr = extract('month', Invoice.issue_date)
quarter_expr = ((month_expr - 1) / 3 + 1)
columns = [
year_expr.label('year'),
func.count(Invoice.id).label('count'),
func.sum(Invoice.net_amount).label('net'),
func.sum(Invoice.vat_amount).label('vat'),
func.sum(Invoice.gross_amount).label('gross'),
]
group_by = [year_expr]
order_by = [year_expr.desc()]
if period == 'month':
columns.append(month_expr.label('month'))
group_by.append(month_expr)
order_by.append(month_expr.desc())
elif period == 'quarter':
quarter_cast = cast(quarter_expr, Integer)
columns.append(quarter_cast.label('quarter'))
group_by.append(quarter_cast)
order_by.append(quarter_cast.desc())
return query.with_entities(*columns).group_by(*group_by).order_by(*order_by).all()

62
app/scheduler.py Normal file
View File

@@ -0,0 +1,62 @@
from datetime import datetime, timedelta
from apscheduler.schedulers.background import BackgroundScheduler
from flask import current_app
scheduler = BackgroundScheduler(job_defaults={'coalesce': True, 'max_instances': 1})
def init_scheduler(app):
if scheduler.running:
return
def sync_job():
with app.app_context():
from app.models.company import Company
from app.models.sync_log import SyncLog
from app.services.sync_service import SyncService
current_app.logger.info('Scheduled sync checker started')
companies = Company.query.filter_by(is_active=True, sync_enabled=True).all()
for company in companies:
last_log = SyncLog.query.filter_by(company_id=company.id, status='finished').order_by(SyncLog.finished_at.desc()).first()
due = not last_log or not last_log.finished_at or (datetime.utcnow() - last_log.finished_at) >= timedelta(minutes=company.sync_interval_minutes)
if due:
SyncService(company).run_scheduled_sync()
def refresh_dashboard_cache_job():
with app.app_context():
from app.models.company import Company
from app.models.invoice import Invoice
from app.services.health_service import HealthService
from app.services.redis_service import RedisService
from sqlalchemy import extract
from datetime import date
from decimal import Decimal
today = date.today()
for company in Company.query.filter_by(is_active=True).all():
base = Invoice.query.filter_by(company_id=company.id)
month_invoices = base.filter(
extract('month', Invoice.issue_date) == today.month,
extract('year', Invoice.issue_date) == today.year,
).order_by(Invoice.issue_date.desc(), Invoice.id.desc()).all()
recent_ids = [invoice.id for invoice in base.order_by(Invoice.created_at.desc(), Invoice.id.desc()).limit(200).all()]
totals = {
'net': str(sum(Decimal(invoice.net_amount) for invoice in month_invoices)),
'vat': str(sum(Decimal(invoice.vat_amount) for invoice in month_invoices)),
'gross': str(sum(Decimal(invoice.gross_amount) for invoice in month_invoices)),
}
RedisService.set_json(
f'dashboard.summary.company.{company.id}',
{
'month_invoice_ids': [invoice.id for invoice in month_invoices],
'unread': base.filter_by(is_unread=True).count(),
'totals': totals,
'recent_invoice_ids': recent_ids,
},
ttl=300,
)
HealthService().warm_cache(company.id)
scheduler.add_job(sync_job, 'interval', minutes=1, id='ksef_sync', replace_existing=True)
scheduler.add_job(refresh_dashboard_cache_job, 'interval', minutes=5, id='dashboard_cache_refresh', replace_existing=True)
scheduler.start()

61
app/seed.py Normal file
View File

@@ -0,0 +1,61 @@
from datetime import date, datetime, timedelta
from pathlib import Path
from werkzeug.security import generate_password_hash
from app.extensions import db
from app.models.invoice import Invoice, InvoiceStatus, InvoiceType, Tag
from app.models.setting import AppSetting
from app.models.user import User
from app.models.company import Company, UserCompanyAccess
def seed_data():
company = Company.query.filter_by(name='Demo Sp. z o.o.').first()
if not company:
company = Company(name='Demo Sp. z o.o.', tax_id='5250000001', sync_enabled=True, sync_interval_minutes=60)
db.session.add(company)
db.session.flush()
if not User.query.filter_by(email='admin@example.com').first():
user = User(email='admin@example.com', name='Admin', password_hash=generate_password_hash('admin123'), role='admin')
db.session.add(user)
db.session.flush()
db.session.add(UserCompanyAccess(user_id=user.id, company_id=company.id, access_level='full'))
AppSetting.set(f'company.{company.id}.ksef.last_sync_at', datetime.utcnow().isoformat())
AppSetting.set(f'company.{company.id}.ksef.status', 'ready')
AppSetting.set(f'company.{company.id}.notify.enabled', 'true')
AppSetting.set(f'company.{company.id}.notify.min_amount', '1000')
AppSetting.set(f'company.{company.id}.ksef.base_url', 'https://api.ksef.mf.gov.pl/v2')
AppSetting.set(f'company.{company.id}.ksef.auth_mode', 'token')
AppSetting.set(f'company.{company.id}.ksef.mock_mode', 'true')
AppSetting.set(f'company.{company.id}.app.read_only_mode', 'false')
if Invoice.query.filter_by(company_id=company.id).count() == 0:
tag = Tag.query.filter_by(name='stały dostawca').first() or Tag(name='stały dostawca', color='primary')
db.session.add(tag)
db.session.flush()
archive_dir = Path('storage/archive/company_%s' % company.id)
archive_dir.mkdir(parents=True, exist_ok=True)
for idx in range(1, 13):
issued = date.today() - timedelta(days=idx * 7)
xml_path = archive_dir / f'FV_{idx:03d}.xml'
xml_path.write_text(f'<Invoice><Number>FV/{idx:03d}/2026</Number></Invoice>', encoding='utf-8')
invoice = Invoice(
company_id=company.id,
ksef_number=f'KSEF/C{company.id}/{2025 + (idx % 2)}/{1000 + idx}',
invoice_number=f'FV/{idx:03d}/2026',
contractor_name=f'Kontrahent {idx}',
contractor_nip=f'12345678{idx:02d}',
issue_date=issued,
received_date=issued + timedelta(days=1),
fetched_at=datetime.utcnow() - timedelta(days=idx),
net_amount=1000 * idx,
vat_amount=230 * idx,
gross_amount=1230 * idx,
invoice_type=InvoiceType.PURCHASE if idx % 2 else InvoiceType.SALE,
status=InvoiceStatus.NEW if idx % 3 else InvoiceStatus.ACCOUNTED,
currency='PLN',
xml_path=str(xml_path),
internal_note='Przykładowa faktura testowa.',
queue_accounting=idx % 2 == 0,
)
invoice.tags.append(tag)
db.session.add(invoice)
db.session.commit()

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1279
app/services/ksef_service.py Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

262
app/settings/routes.py Normal file
View File

@@ -0,0 +1,262 @@
import base64
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from app.extensions import db
from app.forms.settings import (
AppearanceSettingsForm,
CompanyForm,
KSeFSettingsForm,
MailSettingsForm,
NfzModuleSettingsForm,
NotificationSettingsForm,
UserForm,
)
from app.models.company import Company
from app.models.setting import AppSetting
from app.models.user import User
from app.services.company_service import CompanyService
from app.services.ksef_service import RequestsKSeFAdapter
from app.services.mail_service import MailService
from app.services.notification_service import NotificationService
from app.services.settings_service import SettingsService
def _can_manage_company_settings(user, company_id):
if not company_id or not getattr(user, 'is_authenticated', False):
return False
if getattr(user, 'role', '') not in {'admin', 'operator'}:
return False
return user.company_access_level(company_id) == 'full'
bp = Blueprint('settings', __name__, url_prefix='/settings')
KSEF_ENV_TO_URL = {
'prod': RequestsKSeFAdapter.ENVIRONMENT_URLS['prod'],
'test': RequestsKSeFAdapter.ENVIRONMENT_URLS['test'],
}
def _resolve_ksef_environment(company_id=None, user_id=None):
env = (SettingsService.get_effective('ksef.environment', '', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user') or '').strip().lower()
if env in KSEF_ENV_TO_URL:
return env
base_url = (SettingsService.get_effective('ksef.base_url', '', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user') or '').strip().lower()
if 'api-test.ksef.mf.gov.pl' in base_url:
return 'test'
return 'prod'
@bp.route('/', methods=['GET', 'POST'])
@login_required
def index():
company = CompanyService.get_current_company()
company_id = company.id if company else None
user_id = current_user.id
company_read_only = AppSetting.get(f'company.{company_id}.app.read_only_mode', 'false') == 'true' if company_id else False
effective_read_only = SettingsService.read_only_enabled(company_id=company_id) if company_id else False
global_read_only = AppSetting.get('app.read_only_mode', 'false') == 'true'
user_read_only = bool(company_id and current_user.is_company_readonly(company_id))
can_manage_company_settings = _can_manage_company_settings(current_user, company_id)
ksef_mode = SettingsService.get_preference('ksef', default='user', user_id=user_id)
mail_mode = SettingsService.get_preference('mail', default='global', user_id=user_id)
notify_mode = SettingsService.get_preference('notify', default='global', user_id=user_id)
nfz_mode = SettingsService.get_preference('modules', default='global', user_id=user_id)
ksef_form = KSeFSettingsForm(
prefix='ksef',
source_mode=ksef_mode,
environment=_resolve_ksef_environment(company_id=company_id, user_id=user_id),
auth_mode=SettingsService.get_effective('ksef.auth_mode', 'token', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user'),
client_id=SettingsService.get_effective('ksef.client_id', '', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user'),
)
mail_form = MailSettingsForm(
prefix='mail',
source_mode=mail_mode,
server=SettingsService.get_effective('mail.server', '', company_id=company_id, user_id=user_id),
port=SettingsService.get_effective('mail.port', '587', company_id=company_id, user_id=user_id),
username=SettingsService.get_effective('mail.username', '', company_id=company_id, user_id=user_id),
sender=SettingsService.get_effective('mail.sender', '', company_id=company_id, user_id=user_id),
security_mode=(SettingsService.get_effective('mail.security_mode', '', company_id=company_id, user_id=user_id) or ('tls' if SettingsService.get_effective('mail.tls', 'true', company_id=company_id, user_id=user_id) == 'true' else 'none')),
)
notify_form = NotificationSettingsForm(
prefix='notify',
source_mode=notify_mode,
pushover_user_key=SettingsService.get_effective('notify.pushover_user_key', '', company_id=company_id, user_id=user_id),
min_amount=SettingsService.get_effective('notify.min_amount', '0', company_id=company_id, user_id=user_id),
quiet_hours=SettingsService.get_effective('notify.quiet_hours', '', company_id=company_id, user_id=user_id),
enabled=SettingsService.get_effective('notify.enabled', 'false', company_id=company_id, user_id=user_id) == 'true',
)
appearance_form = AppearanceSettingsForm(prefix='appearance', theme_preference=current_user.theme_preference or 'light')
nfz_form = NfzModuleSettingsForm(prefix='nfz', source_mode=nfz_mode, enabled=SettingsService.get_effective('modules.nfz_enabled', 'false', company_id=company_id, user_id=user_id) == 'true')
company_form = CompanyForm(
prefix='company',
name=company.name if company else '',
tax_id=company.tax_id if company else '',
sync_enabled=company.sync_enabled if company else False,
sync_interval_minutes=company.sync_interval_minutes if company else 60,
bank_account=company.bank_account if company else '',
read_only_mode=company_read_only,
)
user_form = UserForm(prefix='user')
user_form.company_id.choices = [(0, '— wybierz firmę —')] + [(c.id, c.name) for c in Company.query.order_by(Company.name).all()]
if ksef_form.submit.data and ksef_form.validate_on_submit():
SettingsService.set_preference('ksef', ksef_form.source_mode.data, user_id=user_id)
if ksef_form.source_mode.data == 'user':
submitted_base_url = (request.form.get('ksef-base_url') or '').strip().lower()
environment = (ksef_form.environment.data or ('test' if 'api-test.ksef.mf.gov.pl' in submitted_base_url else 'prod')).lower()
if environment not in KSEF_ENV_TO_URL:
environment = 'prod'
effective_base_url = submitted_base_url or KSEF_ENV_TO_URL[environment]
data = {
'ksef.environment': environment,
'ksef.base_url': effective_base_url,
'ksef.auth_mode': ksef_form.auth_mode.data,
'ksef.client_id': (ksef_form.client_id.data or '').strip(),
}
if ksef_form.token.data:
data['ksef.token'] = (ksef_form.token.data.strip(), True)
if ksef_form.certificate_file.data:
uploaded = ksef_form.certificate_file.data
content = uploaded.read()
if content:
data['ksef.certificate_name'] = (uploaded.filename or '').strip()
data['ksef.certificate_data'] = (base64.b64encode(content).decode('ascii'), True)
SettingsService.set_many_user(data, user_id=user_id)
if company_id:
SettingsService.set_many(data, company_id=company_id)
flash('Zapisano indywidualne ustawienia KSeF.', 'success')
else:
flash('Włączono współdzielony profil KSeF dla aktywnej firmy.', 'success')
return redirect(url_for('settings.index'))
if mail_form.submit.data and mail_form.validate_on_submit():
SettingsService.set_preference('mail', mail_form.source_mode.data, user_id=user_id)
if mail_form.source_mode.data == 'user':
SettingsService.set_many_user({
'mail.server': mail_form.server.data or '',
'mail.port': mail_form.port.data or '587',
'mail.username': mail_form.username.data or '',
'mail.password': (mail_form.password.data or '', True),
'mail.sender': mail_form.sender.data or '',
'mail.security_mode': mail_form.security_mode.data or 'tls',
'mail.tls': str((mail_form.security_mode.data or 'tls') == 'tls').lower(),
}, user_id=user_id)
flash('Zapisano indywidualne ustawienia SMTP.', 'success')
else:
flash('Włączono globalne ustawienia SMTP.', 'success')
return redirect(url_for('settings.index'))
if mail_form.test_submit.data and mail_form.validate_on_submit():
SettingsService.set_preference('mail', mail_form.source_mode.data, user_id=user_id)
recipient = mail_form.test_recipient.data or current_user.email
result = MailService(company_id=company_id).send_test_mail(recipient)
flash(f'Test maila: {result["status"]}.', 'info')
return redirect(url_for('settings.index'))
if notify_form.submit.data and notify_form.validate_on_submit():
SettingsService.set_preference('notify', notify_form.source_mode.data, user_id=user_id)
if notify_form.source_mode.data == 'user':
SettingsService.set_many_user({
'notify.pushover_user_key': notify_form.pushover_user_key.data or '',
'notify.pushover_api_token': (notify_form.pushover_api_token.data or '', True),
'notify.min_amount': notify_form.min_amount.data or '0',
'notify.quiet_hours': notify_form.quiet_hours.data or '',
'notify.enabled': str(bool(notify_form.enabled.data)).lower(),
}, user_id=user_id)
flash('Zapisano indywidualne powiadomienia.', 'success')
else:
flash('Włączono globalne powiadomienia.', 'success')
return redirect(url_for('settings.index'))
if notify_form.test_submit.data and notify_form.validate_on_submit():
SettingsService.set_preference('notify', notify_form.source_mode.data, user_id=user_id)
log = NotificationService(company_id=company_id).send_test_pushover()
flash(f'Test Pushover: {log.status}.', 'info')
return redirect(url_for('settings.index'))
if appearance_form.submit.data and appearance_form.validate_on_submit():
current_user.theme_preference = appearance_form.theme_preference.data
db.session.commit()
flash('Zapisano ustawienia wyglądu.', 'success')
return redirect(url_for('settings.index'))
if nfz_form.submit.data and nfz_form.validate_on_submit():
SettingsService.set_preference('modules', nfz_form.source_mode.data, user_id=user_id)
if nfz_form.source_mode.data == 'user':
SettingsService.set_many_user({'modules.nfz_enabled': str(bool(nfz_form.enabled.data)).lower()}, user_id=user_id)
flash('Zapisano indywidualne ustawienia modułu NFZ.', 'success')
else:
flash('Włączono globalne ustawienia modułu NFZ.', 'success')
return redirect(url_for('settings.index'))
if can_manage_company_settings and company_form.submit.data and company_form.validate_on_submit():
target = company or Company()
target.name = company_form.name.data
target.tax_id = company_form.tax_id.data or ''
target.sync_enabled = bool(company_form.sync_enabled.data)
target.sync_interval_minutes = company_form.sync_interval_minutes.data or 60
target.bank_account = (company_form.bank_account.data or '').strip()
db.session.add(target)
db.session.flush()
AppSetting.set(f'company.{target.id}.app.read_only_mode', 'true' if company_form.read_only_mode.data else 'false')
db.session.commit()
if not company:
CompanyService.assign_user(current_user, target, 'full', switch_after=True)
else:
CompanyService.switch_company(target.id)
flash('Zapisano firmę i harmonogram.', 'success')
return redirect(url_for('settings.index'))
users = User.query.order_by(User.name).all() if current_user.role == 'admin' else []
companies = Company.query.order_by(Company.name).all() if current_user.role == 'admin' else []
read_only_reasons = []
if global_read_only:
read_only_reasons.append('globalny tryb tylko odczytu')
if company_read_only:
read_only_reasons.append('blokada ustawiona dla tej firmy')
if user_read_only:
read_only_reasons.append('Twoje uprawnienia do firmy są tylko do odczytu')
certificate_name = SettingsService.get_effective('ksef.certificate_name', '', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user')
token_configured = bool(SettingsService.get_effective_secret('ksef.token', '', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user'))
certificate_configured = bool(SettingsService.get_effective_secret('ksef.certificate_data', '', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user'))
company_token_configured = bool(SettingsService.get_secret('ksef.token', '', company_id=company_id)) if company_id else False
company_certificate_name = SettingsService.get('ksef.certificate_name', '', company_id=company_id) if company_id else ''
company_certificate_configured = bool(SettingsService.get_secret('ksef.certificate_data', '', company_id=company_id)) if company_id else False
ksef_environment = _resolve_ksef_environment(company_id=company_id, user_id=user_id)
return render_template(
'settings/index.html',
company=company,
ksef_form=ksef_form,
mail_form=mail_form,
notify_form=notify_form,
appearance_form=appearance_form,
nfz_form=nfz_form,
nfz_enabled=SettingsService.get_effective('modules.nfz_enabled', 'false', company_id=company_id, user_id=user_id) == 'true',
company_form=company_form,
user_form=user_form,
users=users,
companies=companies,
certificate_name=certificate_name,
token_configured=token_configured,
certificate_configured=certificate_configured,
company_certificate_name=company_certificate_name,
company_token_configured=company_token_configured,
company_certificate_configured=company_certificate_configured,
company_read_only=company_read_only,
effective_read_only=effective_read_only,
global_read_only=global_read_only,
user_read_only=user_read_only,
can_manage_company_settings=can_manage_company_settings,
read_only_reasons=read_only_reasons,
ksef_environment=ksef_environment,
ksef_mode=ksef_mode,
mail_mode=mail_mode,
notify_mode=notify_mode,
nfz_mode=nfz_mode,
)

71
app/static/css/app.css Normal file
View File

@@ -0,0 +1,71 @@
body { min-height: 100vh; background: var(--bs-tertiary-bg); }
pre { white-space: pre-wrap; }
html, body { overflow-x: hidden; }
.app-shell { min-height: 100vh; align-items: stretch; }
.sidebar { width: 292px; min-width: 292px; max-width: 292px; flex: 0 0 292px; min-height: 100vh; position: sticky; top: 0; overflow-y: auto; overflow-x: hidden; }
.main-column { flex: 1 1 auto; min-width: 0; width: calc(100% - 292px); }
.main-column > .p-4, .main-column section.p-4 { width: 100%; max-width: 100%; }
.page-topbar { backdrop-filter: blur(6px); }
.page-content-wrap { background: linear-gradient(180deg, rgba(13,110,253,.03), transparent 180px); }
.brand-icon { display: inline-flex; align-items: center; justify-content: center; width: 42px; height: 42px; border-radius: 14px; background: linear-gradient(135deg, #0d6efd, #6ea8fe); color: #fff; }
.menu-section-label { font-size: .75rem; text-transform: uppercase; letter-spacing: .08em; color: var(--bs-secondary-color); margin-bottom: .5rem; margin-top: 1rem; }
.nav-link { border-radius: .85rem; color: inherit; padding: .7rem .85rem; font-weight: 500; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav-link i { width: 1.2rem; text-align: center; flex: 0 0 1.2rem; }
.nav-link:hover { background: rgba(13,110,253,.08); }
.nav-link-accent { background: rgba(13,110,253,.08); border: 1px solid rgba(13,110,253,.14); }
.nav-link-highlight { background: rgba(25,135,84,.08); border: 1px solid rgba(25,135,84,.14); }
.top-chip { padding: .35rem .65rem; border-radius: 999px; background: rgba(127,127,127,.08); }
.readonly-pill { display: inline-flex; align-items: center; padding: .4rem .75rem; border-radius: 999px; background: rgba(255,193,7,.16); color: var(--bs-emphasis-color); border: 1px solid rgba(255,193,7,.35); font-size: .875rem; }
.readonly-pill-compact { font-size: .8rem; padding: .35rem .6rem; }
.page-title { font-size: 1.15rem; }
.card { border: 0; box-shadow: 0 .35rem 1rem rgba(15, 23, 42, .08); border-radius: 1rem; }
.card-header { font-weight: 600; background: color-mix(in srgb, var(--bs-body-bg) 80%, var(--bs-primary-bg-subtle)); border-bottom: 1px solid var(--bs-border-color); border-top-left-radius: 1rem !important; border-top-right-radius: 1rem !important; }
.table thead th { font-size: .85rem; color: var(--bs-secondary-color); }
.page-section-header { padding: 1.25rem; border-radius: 1.1rem; background: var(--bs-body-bg); box-shadow: 0 .35rem 1rem rgba(15, 23, 42, .08); }
.section-eyebrow { letter-spacing: .08em; }
.section-toolbar .btn, .page-section-header .btn { border-radius: .8rem; }
.surface-muted { background: color-mix(in srgb, var(--bs-tertiary-bg) 85%, white); border-radius: 1rem; }
.settings-tab .nav-link { justify-content: flex-start; }
.settings-tab .nav-link.active { background: var(--bs-primary); color: #fff; }
.stat-card { min-height: 120px; border: 0; }
.stat-blue { background: linear-gradient(135deg, #0d6efd, #4aa3ff); }
.stat-green { background: linear-gradient(135deg, #198754, #44c28a); }
.stat-purple { background: linear-gradient(135deg, #6f42c1, #9a6bff); }
.stat-orange { background: linear-gradient(135deg, #fd7e14, #ffad5c); }
.stat-dark { background: linear-gradient(135deg, #343a40, #586069); }
.compact-card-body { padding: .95rem 1.1rem; }
.text-wrap-balanced { max-width: 46rem; }
.nfz-badge { font-size: .78rem; }
[data-bs-theme="dark"] .nav-link:hover { background: rgba(255,255,255,.08); }
[data-bs-theme="dark"] .top-chip { background: rgba(255,255,255,.06); }
[data-bs-theme="dark"] .page-content-wrap { background: linear-gradient(180deg, rgba(13,110,253,.08), transparent 200px); }
@media (max-width: 991px) { .app-shell { flex-direction: column; } .sidebar { width: 100%; min-width: 100%; max-width: 100%; flex-basis: auto; min-height: auto; position: static; } .main-column { width: 100%; } }
.invoice-detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 1rem; }
.invoice-detail-main, .invoice-detail-sidebar { min-width: 0; }
.invoice-detail-sticky { position: sticky; top: 1rem; }
.invoice-preview-surface { max-height: none; }
.invoice-preview-surface table { min-width: 720px; }
@media (max-width: 991px) { .invoice-detail-layout { grid-template-columns: 1fr; } .invoice-detail-sticky { position: static; } }
.source-switch { display: flex; flex-wrap: wrap; gap: .75rem; }
.btn-source { border: 1px solid var(--bs-border-color); border-radius: 999px; padding: .65rem 1rem; background: var(--bs-body-bg); }
.btn-check:checked + .btn-source { background: var(--bs-primary); color: #fff; border-color: var(--bs-primary); }
.source-panel { padding: 1rem; border: 1px solid var(--bs-border-color); border-radius: 1rem; background: color-mix(in srgb, var(--bs-body-bg) 92%, var(--bs-primary-bg-subtle)); }
.source-panel-note .alert { border-radius: 1rem; }
.settings-module-intro { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; margin-bottom: 1rem; padding: 1rem; border-radius: 1rem; background: color-mix(in srgb, var(--bs-tertiary-bg) 88%, var(--bs-primary-bg-subtle)); }
.login-page { background: linear-gradient(135deg, rgba(13,110,253,.08), rgba(111,66,193,.08)); }
.login-hero { align-items: center; justify-content: center; padding: 3rem; }
.login-hero-card { max-width: 34rem; }
.login-form-card { max-width: 34rem; border-radius: 1.5rem; }
.login-feature-list { display: grid; gap: 1rem; margin-top: 2rem; font-weight: 500; }
.invoice-actions-cell { min-width: 170px; }
.invoice-action-btn { min-width: 88px; height: 34px; display: inline-flex; align-items: center; justify-content: center; border-radius: .7rem; white-space: nowrap; flex: 0 0 auto; }
.table td .invoice-action-btn i { line-height: 1; }
.table td.text-end { white-space: nowrap; }
.ksef-break, td.text-break { word-break: break-word; overflow-wrap: anywhere; }
.invoice-ksef-col { min-width: 190px; max-width: 260px; }
.invoice-number-col { min-width: 180px; }
.invoice-actions-stack { display: flex; flex-wrap: nowrap; justify-content: flex-end; gap: .5rem; }
.pagination { gap: .2rem; }
.pagination .page-link { border-radius: .65rem; }
@media (max-width: 1400px) { .invoice-actions-cell { min-width: 150px; } .invoice-action-btn { min-width: 80px; padding-left: .6rem; padding-right: .6rem; } .invoice-ksef-col { min-width: 170px; max-width: 220px; } }
@media (max-width: 1200px) { .invoice-actions-stack { flex-wrap: wrap; } .table td.text-end { white-space: normal; } }

View File

@@ -0,0 +1,21 @@
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<div class="d-flex flex-wrap gap-2 align-items-center">
<a class="btn btn-sm {{ 'btn-primary' if request.endpoint == 'admin.index' else 'btn-outline-primary' }}" href="{{ url_for('admin.index') }}"><i class="fa-solid fa-shield-halved me-1"></i>Start</a>
<a class="btn btn-sm {{ 'btn-primary' if request.endpoint in ['admin.users', 'admin.user_form', 'admin.user_access', 'admin.reset_password'] else 'btn-outline-primary' }}" href="{{ url_for('admin.users') }}"><i class="fa-solid fa-users me-1"></i>Użytkownicy</a>
<a class="btn btn-sm {{ 'btn-primary' if request.endpoint in ['admin.companies', 'admin.company_form'] else 'btn-outline-primary' }}" href="{{ url_for('admin.companies') }}"><i class="fa-solid fa-building me-1"></i>Firmy</a>
<a class="btn btn-sm {{ 'btn-primary' if request.endpoint == 'admin.audit' else 'btn-outline-primary' }}" href="{{ url_for('admin.audit') }}"><i class="fa-solid fa-clipboard-check me-1"></i>Logi audytu</a>
<a class="btn btn-sm {{ 'btn-primary' if request.endpoint == 'admin.global_settings' else 'btn-outline-primary' }}" href="{{ url_for('admin.global_settings') }}"><i class="fa-solid fa-sliders me-1"></i>Ustawienia globalne</a>
<a class="btn btn-sm {{ 'btn-primary' if request.endpoint in ['admin.system_data', 'admin.health'] else 'btn-outline-primary' }}" href="{{ url_for('admin.system_data') }}"><i class="fa-solid fa-microchip me-1"></i>Dane systemowe</a>
<div class="dropdown">
<button class="btn btn-sm {{ 'btn-primary' if request.endpoint == 'admin.maintenance' else 'btn-outline-primary' }} dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-toolbox me-1"></i>Narzędzia
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm">
<li><a class="dropdown-item" href="{{ url_for('admin.maintenance') }}"><i class="fa-solid fa-screwdriver-wrench me-2"></i>Logi i backupy</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.system_data') }}#integrations"><i class="fa-solid fa-plug me-2"></i>Integracje</a></li>
</ul>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,5 @@
{% extends 'base.html' %}
{% block content %}
{% include 'admin/_nav.html' %}
{% block admin_content %}{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,5 @@
{% extends 'admin/admin_base.html' %}
{% block title %}Audit log{% endblock %}
{% block admin_content %}
<div class="card shadow-sm"><div class="card-body"><div class="table-responsive"><table class="table table-sm"><thead><tr><th>Czas</th><th>Akcja</th><th>Typ</th><th>ID</th><th>Szczegóły</th><th>IP</th></tr></thead><tbody>{% for log in logs %}<tr><td>{{ log.created_at }}</td><td>{{ log.action }}</td><td>{{ log.target_type }}</td><td>{{ log.target_id or '' }}</td><td>{{ log.details }}</td><td>{{ log.remote_addr }}</td></tr>{% endfor %}</tbody></table></div></div></div>
{% endblock %}

View File

@@ -0,0 +1,6 @@
{% extends 'admin/admin_base.html' %}
{% block title %}<i class="fa-solid fa-building me-2 text-primary"></i>Firmy{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between align-items-center mb-3"><div><h4 class="mb-0">Firmy</h4><div class="text-secondary small">Oddzielne ustawienia KSeF, certyfikaty, powiadomienia i harmonogramy.</div></div><a class="btn btn-primary" href="{{ url_for('admin.company_form') }}"><i class="fa-solid fa-plus me-2"></i>Dodaj firmę</a></div>
<div class="card shadow-sm"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Firma</th><th>Harmonogram</th><th>Status</th><th>Adres / konto</th><th>Notatka</th><th></th></tr></thead><tbody>{% for company in companies %}<tr><td><div class="fw-semibold">{{ company.name }}</div><div class="small text-secondary">NIP: {{ company.tax_id or 'brak' }}{% if company.regon %} · REGON: {{ company.regon }}{% endif %}</div></td><td><span class="badge text-bg-light border">co {{ company.sync_interval_minutes }} min</span> {% if company.sync_enabled %}<span class="badge text-bg-success">włączony</span>{% else %}<span class="badge text-bg-secondary">wyłączony</span>{% endif %}</td><td>{% if company.is_active %}<span class="badge text-bg-success">aktywna</span>{% else %}<span class="badge text-bg-secondary">nieaktywna</span>{% endif %}</td><td class="text-secondary small">{{ company.address or '—' }}<div>{% if company.bank_account %}Konto: {{ company.bank_account }}{% endif %}</div></td><td class="text-secondary small">{{ company.note or '—' }}</td><td class="text-end text-nowrap"><a class="btn btn-sm btn-outline-secondary" href="{{ url_for('dashboard.switch_company', company_id=company.id) }}"><i class="fa-solid fa-check"></i> Wybierz</a> <a class="btn btn-sm btn-outline-primary" href="{{ url_for('admin.company_form', company_id=company.id) }}"><i class="fa-solid fa-pen"></i> Edytuj</a></td></tr>{% endfor %}</tbody></table></div></div>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends 'admin/admin_base.html' %}
{% block title %}{{ 'Edycja firmy' if company else 'Nowa firma' }}{% endblock %}
{% block admin_content %}
<form method="post" class="card shadow-sm border-0">
<div class="card-body p-4">
{{ form.hidden_tag() }}
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h4 class="mb-1">{{ 'Edycja firmy' if company else 'Nowa firma' }}</h4>
<div class="text-secondary">Podaj NIP a następnie klikniej z Pobierz z CEIDG aby wypełnić pola.</div>
</div>
{% if company %}<a class="btn btn-sm btn-outline-secondary" href="{{ url_for('dashboard.switch_company', company_id=company.id) }}">Wybierz tę firmę</a>{% endif %}
</div>
<div class="row g-4">
<div class="col-xl-8">
<div class="card border-0 bg-body-tertiary h-100">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-7">{{ form.name.label(class='form-label') }}{{ form.name(class='form-control', placeholder='Po pobraniu z CEIDG pole uzupełni się automatycznie') }}</div>
<div class="col-md-3">{{ form.tax_id.label(class='form-label') }}{{ form.tax_id(class='form-control', placeholder='NIP') }}</div>
<div class="col-md-2 d-grid">{{ form.fetch_submit(class='btn btn-outline-secondary btn-sm') }}</div>
<div class="col-md-4">{{ form.regon.label(class='form-label') }}{{ form.regon(class='form-control') }}</div>
<div class="col-md-8">{{ form.address.label(class='form-label') }}{{ form.address(class='form-control') }}</div>
<div class="col-md-6">{{ form.bank_account.label(class='form-label') }}{{ form.bank_account(class='form-control', placeholder='np. 11 1111 1111 1111 1111 1111 1111') }}</div>
<div class="col-md-6">{{ form.note.label(class='form-label') }}{{ form.note(class='form-control', rows='3') }}</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="card border-0 bg-body-tertiary h-100">
<div class="card-body">
<div class="small text-secondary text-uppercase mb-3">Ustawienia</div>
<div class="form-check form-switch mb-3">{{ form.is_active(class='form-check-input') }} {{ form.is_active.label(class='form-check-label') }}</div>
<div class="form-check form-switch mb-3">{{ form.sync_enabled(class='form-check-input') }} {{ form.sync_enabled.label(class='form-check-label') }}</div>
<div class="form-check form-switch mb-3">{{ form.mock_mode(class='form-check-input') }} {{ form.mock_mode.label(class='form-check-label') }}</div>
<div class="mt-4">{{ form.sync_interval_minutes.label(class='form-label') }}{{ form.sync_interval_minutes(class='form-control') }}</div>
</div>
</div>
</div>
</div>
<div class="mt-4 d-flex gap-2">{{ form.submit(class='btn btn-primary') }}</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends 'admin/admin_base.html' %}
{% block title %}<i class="fa-solid fa-sliders me-2 text-primary"></i>Ustawienia globalne{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div><h4 class="mb-1">Ustawienia globalne</h4><div class="text-secondary">Wspólna konfiguracja systemu dla SMTP, Pushover i NFZ oraz model współdzielonego profilu KSeF per firma.</div></div>
</div>
<div class="alert alert-info">KSeF nie jest ustawieniem w pełni globalnym dla całego systemu. Administrator ustawia parametry domyślne oraz osobny profil współdzielony dla aktywnej firmy. Użytkownik może świadomie wybrać profil współdzielony albo własny.</div>
<div class="row g-3">
<div class="col-xl-6"><div class="card"><div class="card-header">SMTP globalne</div><div class="card-body"><form method="post" class="row g-3">{{ mail_form.hidden_tag() }}<div class="col-md-6">{{ mail_form.server.label(class='form-label') }}{{ mail_form.server(class='form-control') }}</div><div class="col-md-3">{{ mail_form.port.label(class='form-label') }}{{ mail_form.port(class='form-control') }}</div><div class="col-md-3">{{ mail_form.username.label(class='form-label') }}{{ mail_form.username(class='form-control') }}</div><div class="col-md-6">{{ mail_form.password.label(class='form-label') }}{{ mail_form.password(class='form-control', placeholder='Pozostaw puste aby zachować hasło') }}</div><div class="col-md-6">{{ mail_form.sender.label(class='form-label') }}{{ mail_form.sender(class='form-control') }}</div><div class="col-md-6">{{ mail_form.security_mode.label(class='form-label') }}{{ mail_form.security_mode(class='form-select') }}</div><div class="col-12">{{ mail_form.submit(class='btn btn-primary') }}</div></form></div></div></div>
<div class="col-xl-6"><div class="card"><div class="card-header">Pushover globalny</div><div class="card-body"><form method="post" class="row g-3">{{ notify_form.hidden_tag() }}<div class="col-md-6">{{ notify_form.pushover_user_key.label(class='form-label') }}{{ notify_form.pushover_user_key(class='form-control') }}</div><div class="col-md-6">{{ notify_form.pushover_api_token.label(class='form-label') }}{{ notify_form.pushover_api_token(class='form-control', placeholder='Pozostaw puste aby zachować token') }}</div><div class="col-md-4">{{ notify_form.min_amount.label(class='form-label') }}{{ notify_form.min_amount(class='form-control') }}</div><div class="col-md-8">{{ notify_form.quiet_hours.label(class='form-label') }}{{ notify_form.quiet_hours(class='form-control') }}</div><div class="col-12 form-check">{{ notify_form.enabled(class='form-check-input') }}{{ notify_form.enabled.label(class='form-check-label') }}</div><div class="col-12">{{ notify_form.submit(class='btn btn-primary') }}</div></form></div></div></div>
<div class="col-xl-6"><div class="card"><div class="card-header">NFZ globalnie</div><div class="card-body"><form method="post">{{ nfz_form.hidden_tag() }}<div class="form-check form-switch fs-5 mb-3">{{ nfz_form.enabled(class='form-check-input') }}{{ nfz_form.enabled.label(class='form-check-label') }}</div>{{ nfz_form.submit(class='btn btn-primary') }}</form></div></div></div>
<div class="col-xl-6"><div class="card"><div class="card-header">Domyślne parametry KSeF</div><div class="card-body"><form method="post" class="row g-3">{{ ksef_defaults_form.hidden_tag() }}<div class="col-md-4">{{ ksef_defaults_form.environment.label(class='form-label') }}{{ ksef_defaults_form.environment(class='form-select') }}</div><div class="col-md-4">{{ ksef_defaults_form.auth_mode.label(class='form-label') }}{{ ksef_defaults_form.auth_mode(class='form-select') }}</div><div class="col-md-4">{{ ksef_defaults_form.client_id.label(class='form-label') }}{{ ksef_defaults_form.client_id(class='form-control') }}</div><div class="col-12"><div class="form-text">Te parametry podpowiadają start nowej konfiguracji, ale nie nadpisują sekretów użytkowników ani współdzielonych profili firm.</div></div><div class="col-12">{{ ksef_defaults_form.submit(class='btn btn-primary') }}</div></form></div></div></div>
<div class="col-12"><div class="card"><div class="card-header">Współdzielony profil KSeF dla aktywnej firmy</div><div class="card-body"><div class="mb-3 small text-secondary">Aktywna firma: <strong>{{ current_company.name if current_company else 'brak' }}</strong>. Ten profil mogą wybrać użytkownicy tej firmy zamiast własnych danych KSeF.</div><form method="post" class="row g-3">{{ shared_ksef_form.hidden_tag() }}<div class="col-md-3">{{ shared_ksef_form.environment.label(class='form-label') }}{{ shared_ksef_form.environment(class='form-select') }}</div><div class="col-md-3">{{ shared_ksef_form.auth_mode.label(class='form-label') }}{{ shared_ksef_form.auth_mode(class='form-select') }}</div><div class="col-md-3">{{ shared_ksef_form.client_id.label(class='form-label') }}{{ shared_ksef_form.client_id(class='form-control') }}</div><div class="col-md-3">{{ shared_ksef_form.certificate_name.label(class='form-label') }}{{ shared_ksef_form.certificate_name(class='form-control') }}</div><div class="col-md-6">{{ shared_ksef_form.token.label(class='form-label') }}{{ shared_ksef_form.token(class='form-control', placeholder='Pozostaw puste aby zachować token') }}<div class="form-text">{{ 'Token zapisany.' if shared_token_configured else 'Brak zapisanego tokena.' }}</div></div><div class="col-md-6">{{ shared_ksef_form.certificate_data.label(class='form-label') }}{{ shared_ksef_form.certificate_data(class='form-control', placeholder='Pozostaw puste aby zachować certyfikat') }}<div class="form-text">{{ 'Certyfikat zapisany.' if shared_cert_configured else 'Brak zapisanego certyfikatu.' }}</div></div><div class="col-12">{{ shared_ksef_form.submit(class='btn btn-primary') }}</div></form></div></div></div>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'admin/admin_base.html' %}
{% block title %}<i class="fa-solid fa-heart-pulse me-2 text-danger"></i>Zdrowie systemu{% endblock %}
{% block admin_content %}
<div class="row g-3">
<div class="col-md-3"><div class="card shadow-sm h-100"><div class="card-body"><div class="small text-secondary mb-2"><i class="fa-solid fa-database me-2"></i>Baza danych</div><div class="fw-bold fs-5">{{ status.db }}</div></div></div></div>
<div class="col-md-3"><div class="card shadow-sm h-100"><div class="card-body"><div class="small text-secondary mb-2"><i class="fa-solid fa-envelope me-2"></i>SMTP</div><div class="fw-bold fs-5">{{ status.smtp }}</div></div></div></div>
<div class="col-md-3"><div class="card shadow-sm h-100"><div class="card-body"><div class="small text-secondary mb-2"><i class="fa-brands fa-redis me-2"></i>Redis</div><div class="fw-bold fs-5">{{ status.redis }}</div></div></div></div>
<div class="col-md-3"><div class="card shadow-sm h-100"><div class="card-body"><div class="small text-secondary mb-2"><i class="fa-solid fa-network-wired me-2"></i>KSeF</div><div class="fw-bold fs-5">{{ status.ksef }}</div></div></div></div>
</div>
<div class="card mt-3 shadow-sm"><div class="card-header"><i class="fa-solid fa-link me-2"></i>Status połączenia do API KSeF</div><div class="card-body"><p class="mb-2">{{ status.ksef_message }}</p>{% if status.mock_mode %}<div class="alert alert-info mb-0"><i class="fa-solid fa-flask me-2"></i>Tryb mock jest włączony. Synchronizacja i wystawianie faktur działają lokalnie i nie wysyłają danych do produkcyjnego KSeF.</div>{% endif %}</div></div>
{% endblock %}

View File

@@ -0,0 +1,229 @@
{% extends 'admin/admin_base.html' %}
{% block title %}<i class="fa-solid fa-shield-halved me-2 text-primary"></i>Panel admina{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h4 class="mb-1">Administracja systemem</h4>
<div class="text-secondary">
Jedno miejsce do zarządzania konfiguracją globalną, firmami, użytkownikami i danymi testowymi.
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card shadow-sm h-100 border-0">
<div class="card-body">
<div class="small text-secondary mb-1">Użytkownicy</div>
<div class="display-6">{{ users }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm h-100 border-0">
<div class="card-body">
<div class="small text-secondary mb-1">Firmy</div>
<div class="display-6">{{ companies }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm h-100 border-0">
<div class="card-body">
<div class="small text-secondary mb-1">Firmy z mock</div>
<div class="display-6">{{ mock_enabled }}</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-xl-6">
<div class="card shadow-sm h-100 border-0">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
<div>
<div class="small text-secondary mb-1">Tryb tylko do odczytu</div>
<h5 class="mb-1">Globalna blokada zapisów</h5>
<div class="text-secondary">
Szybkie przełączenie systemu między pracą operacyjną i bezpiecznym trybem tylko do odczytu.
</div>
</div>
<span class="badge rounded-pill {{ 'text-bg-warning' if global_ro else 'text-bg-light border text-secondary' }}">
{{ 'Aktywna' if global_ro else 'Wyłączona' }}
</span>
</div>
<div class="d-flex flex-wrap gap-2">
<form method="post" action="{{ url_for('admin.toggle_global_read_only') }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="enabled" value="1">
<button class="btn btn-sm btn-outline-warning" {{ 'disabled' if global_ro else '' }}>
<i class="fa-solid fa-eye me-1"></i>Włącz R/O
</button>
</form>
<form method="post" action="{{ url_for('admin.toggle_global_read_only') }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="enabled" value="0">
<button class="btn btn-sm btn-outline-secondary" {{ '' if global_ro else 'disabled' }}>
<i class="fa-solid fa-pen me-1"></i>Wyłącz R/O
</button>
</form>
</div>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="card shadow-sm h-100 border-0">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
<div>
<div class="small text-secondary mb-1">Dane testowe i mock</div>
<h5 class="mb-1">Środowisko demonstracyjne</h5>
<div class="text-secondary">
Generowanie zestawu startowego i szybkie czyszczenie danych do testów prezentacyjnych.
</div>
</div>
<span class="badge rounded-pill text-bg-light border text-secondary">
{{ mock_enabled }} firm
</span>
</div>
<div class="d-flex flex-wrap gap-2">
<form method="post" action="{{ url_for('admin.generate_mock_data') }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-outline-primary">
<i class="fa-solid fa-database me-1"></i>Generuj dane mock
</button>
</form>
<form method="post" action="{{ url_for('admin.clear_mock_data') }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-outline-danger">
<i class="fa-solid fa-trash-can me-1"></i>Usuń dane mock
</button>
</form>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-header bg-body-tertiary">
<div class="fw-semibold">
<i class="fa-solid fa-link me-2"></i>API CEIDG
</div>
</div>
<div class="card-body p-4">
<form method="post" action="{{ url_for('admin.save_ceidg_settings') }}" id="ceidg-config-form">
{{ ceidg_form.hidden_tag() }}
{{ ceidg_form.environment(value='production') }}
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="border rounded-3 p-3 h-100 bg-body-tertiary-subtle">
<div class="d-flex align-items-center justify-content-between gap-2 mb-2">
<label class="form-label mb-0 fw-semibold">Środowisko CEIDG</label>
<span
class="text-secondary"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="CEIDG działa wyłącznie w środowisku produkcyjnym. Wybór środowiska został ukryty."
>
<i class="fa-regular fa-circle-question"></i>
</span>
</div>
<div class="d-flex align-items-center justify-content-between">
<span class="badge rounded-pill text-bg-success-subtle text-success-emphasis border border-success-subtle">
PROD
</span>
</div>
<div class="form-text mt-2 mb-0">
Tryb produkcyjny jest używany stale.
</div>
</div>
</div>
<div class="col-md-6">
<div class="border rounded-3 p-3 h-100 bg-body-tertiary-subtle">
<div class="d-flex align-items-center justify-content-between gap-2 mb-2">
<label class="form-label mb-0 fw-semibold">Status klucza</label>
<span
class="text-secondary"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="{% if ceidg_api_key_configured %}W systemie zapisano klucz CEIDG. Pozostawienie pustego pola nie usuwa obecnej wartości.{% else %}Klucz CEIDG nie został jeszcze zapisany. Wklej go poniżej i zapisz ustawienia.{% endif %}"
>
<i class="fa-regular fa-circle-question"></i>
</span>
</div>
<div class="d-flex align-items-center justify-content-between">
<span class="badge rounded-pill {{ 'text-bg-success-subtle text-success-emphasis border border-success-subtle' if ceidg_api_key_configured else 'text-bg-danger-subtle text-danger-emphasis border border-danger-subtle' }}">
{{ 'Klucz dodany' if ceidg_api_key_configured else 'Brak klucza' }}
</span>
</div>
<div class="form-text mt-2 mb-0">
{% if ceidg_api_key_configured %}
Klucz jest zapisany w systemie.
{% else %}
Wprowadź klucz i zapisz formularz.
{% endif %}
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-12">
<label class="form-label d-flex align-items-center gap-2" for="{{ ceidg_form.api_key.id }}">
<span>{{ ceidg_form.api_key.label.text }}</span>
<span
class="text-secondary"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Wklej nowy token CEIDG. Pozostawienie pustego pola zachowuje aktualnie zapisany token."
>
<i class="fa-regular fa-circle-question"></i>
</span>
</label>
<div class="input-group">
{{ ceidg_form.api_key(class='form-control', autocomplete='off', placeholder='Wklej nowy token CEIDG') }}
<span class="input-group-text">
<i class="fa-solid fa-key text-secondary"></i>
</span>
</div>
<div class="form-text">
Pozostaw puste, aby zachować zapisany klucz. CEIDG działa stale na PROD.
</div>
</div>
<div class="col-12 pt-2">
{{ ceidg_form.submit(class='btn btn-primary px-4') }}
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
if (window.bootstrap && bootstrap.Tooltip) {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function (el) {
new bootstrap.Tooltip(el);
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends 'admin/admin_base.html' %}
{% block title %}<i class="fa-solid fa-toolbox me-2 text-primary"></i>Narzędzia administracyjne{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h4 class="mb-1">Logi i backupy</h4>
<div class="text-secondary">Podstrona administracyjna do porządków technicznych, kopii bazy i operacji pomocniczych.</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-lg-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<div class="small text-secondary mb-2">Baza danych</div>
<h5 class="mb-3">Kopia bazy</h5>
<div class="small text-secondary mb-1">Silnik</div>
<div class="mb-3">{{ backup_meta.engine }}</div>
<div class="small text-secondary mb-1">Katalog backupów</div>
<div class="small text-break mb-3">{{ backup_meta.backup_dir }}</div>
<div class="alert alert-{{ 'success' if backup_meta.sqlite_supported else 'warning' }} py-2 mb-3">
{% if backup_meta.sqlite_supported %}
Kopia z panelu działa bezpośrednio dla SQLite.
{% else %}
Kopia z panelu nie wykonuje natywnego dumpa dla tego silnika.
{% endif %}
</div>
{% if backup_meta.sqlite_path %}
<div class="small text-secondary mb-1">Plik SQLite</div>
<div class="small text-break mb-3">{{ backup_meta.sqlite_path }}</div>
{% endif %}
<ul class="small text-secondary ps-3 mb-3">
{% for note in backup_meta.notes %}
<li>{{ note }}</li>
{% endfor %}
</ul>
<form method="post" action="{{ url_for('admin.database_backup') }}">
{{ backup_form.hidden_tag() }}
{{ backup_form.submit(class='btn btn-primary w-100') }}
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<div class="small text-secondary mb-2">Porządki techniczne</div>
<h5 class="mb-3">Czyszczenie starych logów</h5>
<div class="text-secondary small mb-3">Usuwa rekordy logów i stare rotowane pliki `.log.*` starsze niż wskazana liczba dni.</div>
<form method="post" action="{{ url_for('admin.cleanup_logs') }}">
{{ cleanup_form.hidden_tag() }}
<div class="mb-3">
{{ cleanup_form.days.label(class='form-label') }}
{{ cleanup_form.days(class='form-control') }}
</div>
{{ cleanup_form.submit(class='btn btn-outline-danger w-100') }}
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<div class="small text-secondary mb-2">Szybkie informacje</div>
<h5 class="mb-3">Podsumowanie</h5>
<div class="d-flex justify-content-between border-bottom py-2 small"><span>Użytkownicy</span><strong>{{ users }}</strong></div>
<div class="d-flex justify-content-between border-bottom py-2 small"><span>Firmy</span><strong>{{ companies }}</strong></div>
<div class="d-flex justify-content-between border-bottom py-2 small"><span>Logi audytu</span><strong>{{ audits }}</strong></div>
<div class="d-flex justify-content-between border-bottom py-2 small"><span>Mock aktywny</span><strong>{{ mock_enabled }}</strong></div>
<div class="d-flex justify-content-between py-2 small"><span>Tryb R/O</span><strong>{{ 'ON' if global_ro else 'OFF' }}</strong></div>
<hr>
<div class="small text-secondary">Przy bazach innych niż SQLite przycisk backupu zapisze plik informacyjny, a właściwą kopię należy wykonać narzędziem serwera bazy.</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,5 @@
{% extends 'admin/admin_base.html' %}
{% block title %}Reset hasła{% endblock %}
{% block admin_content %}
<form method="post" class="card shadow-sm"><div class="card-body">{{ form.hidden_tag() }}<p>Użytkownik: <strong>{{ user.email }}</strong></p><div class="mb-3">{{ form.password.label(class='form-label') }}{{ form.password(class='form-control') }}</div><div class="form-check mb-3">{{ form.force_password_change(class='form-check-input') }} {{ form.force_password_change.label(class='form-check-label') }}</div>{{ form.submit(class='btn btn-warning') }}</div></form>
{% endblock %}

View File

@@ -0,0 +1,215 @@
{% extends 'admin/admin_base.html' %}
{% block title %}<i class="fa-solid fa-microchip me-2 text-primary"></i>Dane systemowe{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
<div>
<h4 class="mb-1">Dane systemowe</h4>
<div class="text-secondary">Skrócony widok techniczny: proces, health, baza, integracje i katalogi.</div>
</div>
</div>
<div class="alert alert-{{ 'warning' if data.health.redis in ['fallback', 'error'] else 'secondary' }} py-2 mb-3">
<div class="d-flex flex-wrap justify-content-between gap-2 align-items-center">
<strong>Redis: {{ data.health.redis|upper }}</strong>
<span class="small text-break">{{ data.health.redis_details or 'brak szczegółów' }}</span>
</div>
</div>
<div class="row g-3 mb-3">
{% for card in data.overview %}
<div class="col-sm-6 col-xl-3">
<div class="card shadow-sm border-0 h-100">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<div class="small text-secondary text-uppercase mb-1">{{ card.label }}</div>
<div class="fs-4 fw-semibold mb-1">{{ card.value }}</div>
<div class="small text-secondary">{{ card.subvalue }}</div>
</div>
<span class="badge text-bg-{{ card.tone }} rounded-pill"><i class="fa-solid {{ card.icon }}"></i></span>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row g-3 mb-3">
<div class="col-xl-7">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-body-tertiary py-2"><strong>Proces i health</strong></div>
<div class="card-body p-3">
<div class="row g-3">
<div class="col-md-6">
<div class="row g-2 small">
<div class="col-6"><div class="text-secondary">CPU</div><div class="fw-semibold">{{ data.process.cpu_percent }}%</div></div>
<div class="col-6"><div class="text-secondary">RAM</div><div class="fw-semibold">{{ data.process.rss_human }}</div></div>
<div class="col-6"><div class="text-secondary">PID</div><div>{{ data.process.pid }}</div></div>
<div class="col-6"><div class="text-secondary">Wątki</div><div>{{ data.process.threads }}</div></div>
<div class="col-6"><div class="text-secondary">Otwarte pliki</div><div>{{ data.process.open_files }}</div></div>
<div class="col-6"><div class="text-secondary">Pamięć hosta</div><div>{{ data.process.system_memory_percent }}%</div></div>
</div>
</div>
<div class="col-md-6">
<div class="row g-2">
{% set ok_values = ['ok', 'mock', 'configured', 'fallback'] %}
{% set health_items = [('Baza', data.health.db), ('SMTP', data.health.smtp), ('Redis', data.health.redis), ('KSeF', data.health.ksef), ('CEIDG', data.health.ceidg)] %}
{% for label, value in health_items %}
<div class="col-6">
<div class="border rounded p-2 h-100 small">
<div class="text-secondary">{{ label }}</div>
<div class="fw-semibold">
<span class="badge text-bg-{{ 'success' if value in ok_values else 'danger' if value == 'error' else 'secondary' }} rounded-pill">{{ value }}</span>
</div>
</div>
</div>
{% endfor %}
<div class="col-12">
<div class="border rounded p-2 small d-flex justify-content-between align-items-center">
<span>Podsumowanie health</span><strong>{{ data.overview[5].value }}</strong>
</div>
</div>
</div>
</div>
</div>
<hr>
<div class="row g-2 small text-secondary">
<div class="col-md-6"><strong>Python:</strong> {{ data.process.python }}</div>
<div class="col-md-6 text-break"><strong>Platforma:</strong> {{ data.process.platform }}</div>
</div>
</div>
</div>
</div>
<div class="col-xl-5">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-body-tertiary py-2"><strong>Aplikacja</strong></div>
<div class="card-body p-3">
<div class="row g-2 mb-3 text-center">
<div class="col-6"><div class="border rounded p-2"><div class="small text-secondary">Użytkownicy</div><div class="fs-4 fw-semibold">{{ data.app.users_count }}</div></div></div>
<div class="col-6"><div class="border rounded p-2"><div class="small text-secondary">Firmy</div><div class="fs-4 fw-semibold">{{ data.app.companies_count }}</div></div></div>
<div class="col-12"><div class="border rounded p-2 d-flex justify-content-between align-items-center"><span>Tryb tylko do odczytu</span><span class="badge text-bg-{{ 'warning' if data.app.read_only_global else 'success' }} rounded-pill">{{ 'ON' if data.app.read_only_global else 'OFF' }}</span></div></div>
</div>
<div class="small text-secondary mb-1">Aktywna firma</div>
<div class="mb-2">{{ data.app.current_company }}</div>
<div class="small text-secondary mb-1">Strefa czasowa</div>
<div class="mb-3">{{ data.app.app_timezone }}</div>
<div class="small text-secondary mb-2">Największe zbiory danych</div>
{% for item in data.app.counts_top[:5] %}
<div class="d-flex justify-content-between small border-bottom py-1"><span>{{ item.label }}</span><strong>{{ item.count }}</strong></div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-xl-5">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-body-tertiary py-2"><strong>Baza danych</strong></div>
<div class="card-body p-3 small">
<div class="mb-1"><span class="text-secondary">Silnik:</span> {{ data.database.engine }}</div>
<div class="mb-2 text-break"><span class="text-secondary">Połączenie:</span> {{ data.database.uri }}</div>
{% if data.database.sqlite_path %}
<div class="mb-2 text-break"><span class="text-secondary">SQLite:</span> {{ data.database.sqlite_path }} <span class="text-secondary">({{ data.database.sqlite_size }})</span></div>
{% endif %}
<div class="small text-secondary mb-2">Największe tabele</div>
{% for item in data.database.largest_tables %}
<div class="d-flex justify-content-between border-bottom py-1"><span>{{ item.table }}</span><strong>{{ item.rows }}</strong></div>
{% endfor %}
</div>
</div>
</div>
<div class="col-xl-7">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-body-tertiary py-2"><strong>Katalogi robocze</strong></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Katalog</th><th>Rozmiar</th><th>Wolne</th><th class="text-end">Zajęcie</th></tr></thead>
<tbody>
{% for item in data.storage %}
<tr>
<td><div>{{ item.label }}</div><div class="small text-secondary text-break">{{ item.path }}</div></td>
<td>{{ item.size_human }}</td>
<td>{{ item.disk_free }}</td>
<td class="text-end">{{ item.disk_percent }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div id="integrations" class="row g-3 mb-3">
<div class="col-xl-6">
<div class="card shadow-sm border-0 h-100">
<div class="card-header d-flex justify-content-between align-items-center bg-body-tertiary py-2">
<strong>Połączenie KSeF</strong>
<span class="badge text-bg-{{ 'success' if data.integrations.ksef.status in ['ok', 'mock'] else 'danger' }} rounded-pill">{{ data.integrations.ksef.status }}</span>
</div>
<div class="card-body p-3 small">
<div class="mb-2"><span class="text-secondary">Komunikat:</span> {{ data.integrations.ksef.message }}</div>
<div class="mb-2 text-break"><span class="text-secondary">Endpoint:</span> {{ data.integrations.ksef.base_url or '—' }}</div>
{% if data.integrations.ksef.auth_mode %}<div class="mb-2"><span class="text-secondary">Tryb autoryzacji:</span> {{ data.integrations.ksef.auth_mode }}</div>{% endif %}
<details>
<summary class="text-secondary">Przykładowa odpowiedź API</summary>
<pre class="small bg-body-tertiary p-3 rounded overflow-auto mt-2" style="max-height:18rem;">{{ json_preview(data.integrations.ksef.sample) }}</pre>
</details>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="card shadow-sm border-0 h-100">
<div class="card-header d-flex justify-content-between align-items-center bg-body-tertiary py-2">
<strong>Połączenie CEIDG</strong>
<span class="badge text-bg-{{ 'success' if data.integrations.ceidg.status == 'ok' else 'danger' }} rounded-pill">{{ data.integrations.ceidg.status }}</span>
</div>
<div class="card-body p-3 small">
<div class="mb-2"><span class="text-secondary">Komunikat:</span> {{ data.integrations.ceidg.message }}</div>
<div class="mb-2"><span class="text-secondary">Tryb:</span> {{ data.integrations.ceidg.environment }}</div>
<div class="mb-2 text-break"><span class="text-secondary">Endpoint:</span> {{ data.integrations.ceidg.url }}</div>
{% if data.integrations.ceidg.technical_details %}<div class="mb-2 text-break"><span class="text-secondary">Szczegóły:</span> {{ data.integrations.ceidg.technical_details }}</div>{% endif %}
<details>
<summary class="text-secondary">Przykładowa odpowiedź API</summary>
<pre class="small bg-body-tertiary p-3 rounded overflow-auto mt-2" style="max-height:18rem;">{{ json_preview(data.integrations.ceidg.sample) }}</pre>
</details>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-xl-6">
<div class="card shadow-sm border-0">
<div class="card-header bg-body-tertiary py-2"><strong>Modele aplikacji</strong></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Obiekt</th><th class="text-end">Liczba</th></tr></thead>
<tbody>{% for item in data.app.counts %}<tr><td>{{ item.label }}</td><td class="text-end">{{ item.count }}</td></tr>{% endfor %}</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="card shadow-sm border-0">
<div class="card-header bg-body-tertiary py-2"><strong>Tabele bazy</strong></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Tabela</th><th class="text-end">Rekordy</th></tr></thead>
<tbody>{% for item in data.database.table_rows %}<tr><td>{{ item.table }}</td><td class="text-end">{{ item.rows }}</td></tr>{% endfor %}</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends 'admin/admin_base.html' %}
{% block title %}Uprawnienia: {{ user.name }}{% endblock %}
{% block admin_content %}
<div class="row g-4">
<div class="col-lg-5"><form method="post" class="card shadow-sm"><div class="card-body">{{ form.hidden_tag() }}<div class="mb-3">{{ form.company_id.label(class='form-label') }}{{ form.company_id(class='form-select') }}</div><div class="mb-3">{{ form.access_level.label(class='form-label') }}{{ form.access_level(class='form-select') }}</div>{{ form.submit(class='btn btn-primary') }}</div></form></div>
<div class="col-lg-7"><div class="card shadow-sm"><div class="card-body"><table class="table"><thead><tr><th>Firma</th><th>Dostęp</th><th></th></tr></thead><tbody>{% for access in accesses %}<tr><td>{{ access.company.name }}</td><td><span class="badge text-bg-{{ 'warning' if access.access_level=='readonly' else 'success' }}">{{ access.access_level }}</span></td><td class="text-end"><form method="post" action="{{ url_for('admin.delete_access', user_id=user.id, access_id=access.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-danger">Usuń</button></form></td></tr>{% endfor %}</tbody></table></div></div></div>
</div>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends 'admin/admin_base.html' %}
{% block title %}{{ 'Edycja użytkownika' if user else 'Nowy użytkownik' }}{% endblock %}
{% block admin_content %}
<form method="post" class="card shadow-sm">
<div class="card-body">{{ form.hidden_tag() }}
<div class="row g-4">
<div class="col-lg-6">
<div class="border rounded-4 p-3 h-100">
<h5 class="mb-3">Dane użytkownika</h5>
<div class="mb-3">{{ form.name.label(class='form-label') }}{{ form.name(class='form-control') }}</div>
<div class="mb-3">{{ form.email.label(class='form-label') }}{{ form.email(class='form-control') }}</div>
<div class="mb-3">{{ form.role.label(class='form-label') }}{{ form.role(class='form-select') }}</div>
<div class="form-check mb-2">{{ form.is_blocked(class='form-check-input') }} {{ form.is_blocked.label(class='form-check-label') }}</div>
</div>
</div>
<div class="col-lg-6">
<div class="border rounded-4 p-3 h-100">
<h5 class="mb-3">Hasło i dostęp startowy</h5>
<div class="mb-3">{{ form.password.label(class='form-label') }}{{ form.password(class='form-control') }}<div class="form-text">Pozostaw puste, aby nie zmieniać hasła.</div></div>
<div class="form-check mb-3">{{ form.force_password_change(class='form-check-input') }} {{ form.force_password_change.label(class='form-check-label') }}</div>
<div class="mb-3">{{ form.company_id.label(class='form-label') }}{{ form.company_id(class='form-select') }}</div>
<div class="mb-3">{{ form.access_level.label(class='form-label') }}{{ form.access_level(class='form-select') }}</div>
</div>
</div>
{% if user %}
<div class="col-12">
<div class="border rounded-4 p-3">
<h5 class="mb-3">Przypisane firmy</h5>
<div class="d-flex gap-2 flex-wrap">{% for access in accesses %}<span class="badge text-bg-{{ 'warning' if access.access_level=='readonly' else 'primary' }} p-2">{{ access.company.name }} / {{ access.access_level }}</span>{% else %}<span class="text-secondary">Brak przypisanych firm.</span>{% endfor %}</div>
<div class="mt-3"><a class="btn btn-outline-secondary" href="{{ url_for('admin.user_access', user_id=user.id) }}">Zarządzaj wieloma firmami</a></div>
</div>
</div>
{% endif %}
</div>
<div class="mt-3">{{ form.submit(class='btn btn-primary') }}</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% extends 'admin/admin_base.html' %}
{% block title %}<i class="fa-solid fa-users me-2 text-primary"></i>Użytkownicy{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between align-items-center mb-3"><div><h4 class="mb-0">Użytkownicy</h4><div class="text-secondary small">Zarządzanie kontami, blokadami i resetem hasła.</div></div><a class="btn btn-primary" href="{{ url_for('admin.user_form') }}"><i class="fa-solid fa-user-plus me-2"></i>Dodaj użytkownika</a></div>
<div class="card shadow-sm"><div class="table-responsive"><table class="table table-hover align-middle mb-0">
<thead><tr><th>Użytkownik</th><th>Rola</th><th>Status</th><th>Dostęp do firm</th><th></th></tr></thead>
<tbody>{% for user in users %}<tr><td><div class="fw-semibold">{{ user.name }}</div><div class="small text-secondary">{{ user.email }}</div></td><td><span class="badge text-bg-light border">{{ user.role }}</span></td><td>{% if user.is_blocked %}<span class="badge text-bg-danger">zablokowany</span>{% else %}<span class="badge text-bg-success">aktywny</span>{% endif %}{% if user.force_password_change %}<span class="badge text-bg-warning ms-1">zmiana hasła</span>{% endif %}</td><td>{% for access in user.company_access %}<span class="badge text-bg-{{ 'warning' if access.access_level=='readonly' else 'primary' }} me-1 mb-1">{{ access.company.name }} / {{ access.access_level }}</span>{% else %}<span class="text-secondary small">brak przypisanych firm</span>{% endfor %}</td><td class="text-end text-nowrap"><a class="btn btn-sm btn-outline-primary" href="{{ url_for('admin.user_form', user_id=user.id) }}"><i class="fa-solid fa-pen"></i></a> <a class="btn btn-sm btn-outline-secondary" href="{{ url_for('admin.user_access', user_id=user.id) }}"><i class="fa-solid fa-key"></i></a> <a class="btn btn-sm btn-outline-warning" href="{{ url_for('admin.reset_password', user_id=user.id) }}"><i class="fa-solid fa-unlock-keyhole"></i></a> <form class="d-inline" method="post" action="{{ url_for('admin.toggle_block', user_id=user.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-danger">{% if user.is_blocked %}<i class="fa-solid fa-user-check"></i>{% else %}<i class="fa-solid fa-user-lock"></i>{% endif %}</button></form></td></tr>{% endfor %}</tbody>
</table></div></div>
{% endblock %}

View File

@@ -0,0 +1,41 @@
<!doctype html>
<html lang="pl" data-bs-theme="{{ 'dark' if theme == 'dark' else 'light' }}">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet">
<link href="{{ static_asset('css/app.css') }}" rel="stylesheet">
<title>Logowanie | KSeF Manager</title>
</head>
<body class="login-page">
<div class="container-fluid">
<div class="row min-vh-100">
<div class="col-lg-6 login-hero d-none d-lg-flex">
<div class="login-hero-card">
<span class="brand-icon mb-4"><i class="fa-solid fa-file-invoice-dollar"></i></span>
<h1 class="mt-4 mb-3">KSeF Manager</h1>
<p class="lead text-secondary">Logowanie do panelu faktur, KSeF, powiadomień i konfiguracji administracyjnej.</p>
<div class="login-feature-list">
<div><i class="fa-solid fa-check text-primary me-2"></i>Zarządzj fakturami w jednym miejscu</div>
</div>
</div>
</div>
<div class="col-lg-6 d-flex align-items-center justify-content-center p-4">
<div class="card login-form-card shadow-lg border-0 w-100">
<div class="card-body p-4 p-md-5">
<div class="d-flex align-items-center gap-3 mb-4">
<span class="brand-icon"><i class="fa-solid fa-lock"></i></span>
<div><h3 class="mb-1">Logowanie</h3><div class="text-secondary small">Zaloguj się, aby przejść do panelu.</div></div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}{% for category, message in messages if message != 'Please log in to access this page.' %}<div class="alert alert-{{ category }}">{{ message }}</div>{% endfor %}{% endwith %}
<form method="post" class="row g-3">{{ form.hidden_tag() }}
<div class="col-12">{{ form.email.label(class='form-label') }}{{ form.email(class='form-control form-control-lg', placeholder='twoj@email.pl') }}</div>
<div class="col-12">{{ form.password.label(class='form-label') }}{{ form.password(class='form-control form-control-lg', placeholder='Hasło') }}</div>
<div class="col-12 d-grid">{{ form.submit(class='btn btn-primary btn-lg') }}</div>
</form>
</div>
</div>
</div>
</div>
</div>
</body></html>

96
app/templates/base.html Normal file
View File

@@ -0,0 +1,96 @@
<!doctype html>
<html lang="pl" data-bs-theme="{{ 'dark' if theme == 'dark' else 'light' }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ app_name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet">
<link href="{{ static_asset('css/app.css') }}" rel="stylesheet">
</head>
<body>
<div class="app-shell d-flex">
<aside class="sidebar bg-body-tertiary border-end p-3">
<div class="mb-4">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="brand-icon"><i class="fa-solid fa-file-invoice-dollar"></i></span>
<div>
<h5 class="mb-0">{{ app_name }}</h5>
<div class="small text-secondary">Panel KSeF i archiwum</div>
</div>
</div>
</div>
<div class="menu-section-label">Główne</div>
<nav class="nav flex-column gap-1 mb-3">
<a class="nav-link" href="{{ url_for('dashboard.index') }}"><i class="fa-solid fa-chart-column me-2"></i>Dashboard</a>
<a class="nav-link" href="{{ url_for('invoices.index') }}"><i class="fa-solid fa-table-list me-2"></i>Faktury Otrzymane</a>
<a class="nav-link" href="{{ url_for('invoices.issued_list') }}"><i class="fa-solid fa-paper-plane me-2"></i>Faktury Wystawione</a>
<a class="nav-link nav-link-accent" href="{{ url_for('invoices.issued_new') }}"><i class="fa-solid fa-square-plus me-2"></i>Wystaw fakturę</a>
{% if nfz_module_enabled %}
<a class="nav-link nav-link-highlight" href="{{ url_for('nfz.index') }}"><i class="fa-solid fa-hospital me-2"></i>Faktury NFZ</a>
{% endif %}
<a class="nav-link" href="{{ url_for('invoices.monthly') }}"><i class="fa-solid fa-calendar-days me-2"></i>Zestawienia</a>
</nav>
<div class="menu-section-label">Kartoteki</div>
<nav class="nav flex-column gap-1 mb-3">
<a class="nav-link" href="{{ url_for('invoices.customers') }}"><i class="fa-solid fa-address-book me-2"></i>Kontrahenci</a>
<a class="nav-link" href="{{ url_for('invoices.products') }}"><i class="fa-solid fa-boxes-stacked me-2"></i>Towary i usługi</a>
</nav>
<div class="menu-section-label">Konfiguracja</div>
<nav class="nav flex-column gap-1">
<a class="nav-link" href="{{ url_for('notifications.index') }}"><i class="fa-solid fa-bell me-2"></i>Powiadomienia</a>
<a class="nav-link" href="{{ url_for('settings.index') }}"><i class="fa-solid fa-gear me-2"></i>Ustawienia</a>
{% if current_user.is_authenticated and current_user.role == 'admin' %}
<a class="nav-link" href="{{ url_for('admin.index') }}"><i class="fa-solid fa-shield-halved me-2"></i>Admin</a>
{% endif %}
<a class="nav-link text-danger-emphasis" href="{{ url_for('auth.logout') }}"><i class="fa-solid fa-right-from-bracket me-2"></i>Wyloguj</a>
</nav>
</aside>
<main class="flex-grow-1 main-column">
<header class="border-bottom px-4 py-3 bg-body sticky-top page-topbar">
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div>
<div class="text-secondary small mb-1">{{ current_company.name if current_company else 'Brak aktywnej firmy' }}</div>
<strong class="page-title">{% block title %}{% endblock %}</strong>
</div>
<div class="d-flex gap-2 align-items-center flex-wrap">
{% if current_company %}
<div class="d-flex align-items-center gap-2 top-chip">
<i class="fa-solid fa-building text-primary"></i>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown">{{ current_company.name }}</button>
<ul class="dropdown-menu dropdown-menu-end">
{% for item in available_companies %}
<li><a class="dropdown-item" href="{{ url_for('dashboard.switch_company', company_id=item.id) }}">{{ item.name }}</a></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<span class="small text-secondary top-chip"><i class="fa-solid fa-circle-half-stroke me-1"></i>{{ 'Ciemny' if theme == 'dark' else 'Jasny' }}</span>
{% if read_only_mode %}<span class="readonly-pill readonly-pill-compact"><i class="fa-solid fa-eye me-1"></i>R/O</span>{% endif %}
<a class="btn btn-sm btn-primary" href="{{ url_for('dashboard.index') }}" title="Strona główna"><i class="fa-solid fa-house"></i></a>
</div>
</div>
</header>
<section class="p-4 page-content-wrap">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} shadow-sm alert-dismissible fade show" role="alert"><i class="fa-solid fa-circle-info me-2"></i>{{ message }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</section>
<footer class="global-footer border-top bg-body px-4 py-3 text-secondary small">{{ global_footer_text }}</footer>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,135 @@
{% extends 'base.html' %}
{% block title %}<i class="fa-solid fa-chart-pie me-2 text-primary"></i>Dashboard{% endblock %}
{% block content %}
{% if not company %}
{% set eyebrow='Pulpit firmy' %}{% set heading='Dashboard operacyjny' %}{% set description='Podsumowanie pracy na aktywnej firmie.' %}
{% include 'partials/page_header.html' with context %}
<div class="card"><div class="card-body py-5"><h4 class="mb-2">Brak wybranej firmy</h4><p class="text-secondary mb-3">Najpierw wybierz firmę z przełącznika w górnym pasku albo dodaj ją w panelu administracyjnym.</p><a class="btn btn-primary" href="{{ url_for('admin.company_form') if current_user.role == 'admin' else url_for('settings.index') }}">{{ 'Dodaj firmę' if current_user.role == 'admin' else 'Przejdź do ustawień' }}</a></div></div>
{% else %}
{% set eyebrow='Pulpit firmy' %}{% set heading='Dashboard operacyjny' %}{% set description='Podsumowanie bieżącego miesiąca, synchronizacji i ostatnich dokumentów.' %}
{% include 'partials/page_header.html' with context %}
<div class="row g-3 mb-4">
<div class="col-md-3"><div class="card stat-card stat-blue text-white"><div class="card-body"><div class="small opacity-75"><i class="fa-solid fa-file-invoice me-1"></i>Faktury w miesiącu</div><div class="display-6">{{ month_invoices|length }}</div><div class="small">{{ company.name }}</div></div></div></div>
<div class="col-md-3"><div class="card stat-card stat-green text-white"><div class="card-body"><div class="small opacity-75"><i class="fa-solid fa-envelope-open-text me-1"></i>Nowe</div><div class="display-6">{{ unread }}</div></div></div></div>
<div class="col-md-2"><div class="card stat-card stat-dark text-white"><div class="card-body"><div class="small opacity-75"><i class="fa-solid fa-wallet me-1"></i>Netto</div><div>{{ totals.net|pln }}</div></div></div></div>
<div class="col-md-2"><div class="card stat-card stat-purple text-white"><div class="card-body"><div class="small opacity-75"><i class="fa-solid fa-percent me-1"></i>VAT</div><div>{{ totals.vat|pln }}</div></div></div></div>
<div class="col-md-2"><div class="card stat-card stat-orange text-white"><div class="card-body"><div class="small opacity-75"><i class="fa-solid fa-sack-dollar me-1"></i>Brutto</div><div>{{ totals.gross|pln }}</div></div></div></div>
</div>
<div class="row g-3">
<div class="col-lg-8">
<div class="card mb-3"><div class="card-header d-flex justify-content-between align-items-center"><span><i class="fa-solid fa-rotate me-2"></i>Synchronizacja KSeF</span><button id="syncBtn" class="btn btn-sm btn-primary" data-sync-url="{{ url_for("dashboard.sync_start") }}" data-csrf-token="{{ csrf_token() }}"><i class="fa-solid fa-download me-1"></i>Pobierz ręcznie</button></div><div class="card-body"><div class="d-flex justify-content-between align-items-center flex-wrap gap-2 small mb-2"><span>Status: <span id="syncStatusText" class="badge text-bg-info">{{ sync_status }}</span></span><span class="d-inline-flex align-items-center gap-2"><span class="text-secondary">Ostatni sync:</span><span class="badge rounded-pill text-bg-light border">{{ last_sync_display }}</span></span></div><div class="progress" style="height: 22px;"><div id="syncProgressBar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%">0%</div></div><div id="syncMessage" class="small text-secondary mt-2">Kliknij "Pobierz ręcznie" aby pobrać faktury z KSeF.</div></div></div>
<div class="card"><div class="card-header"><i class="fa-solid fa-clock-rotate-left me-2"></i>Ostatnie faktury</div><div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th>Numer</th><th>Kontrahent</th><th>Brutto</th><th></th></tr></thead><tbody>{% for invoice in recent_invoices %}<tr><td>{{ invoice.invoice_number }}</td><td>{{ invoice.contractor_name }}</td><td>{{ invoice.gross_amount|pln }}</td><td class="text-end"><div class="d-inline-flex gap-2 flex-wrap justify-content-end"><a href="{{ url_for('invoices.detail', invoice_id=invoice.id) }}" class="btn btn-sm btn-outline-primary invoice-action-btn"><i class="fa-solid fa-folder-open me-1"></i>Otwórz</a><button type="button" class="btn btn-sm btn-success invoice-action-btn" data-bs-toggle="modal" data-bs-target="#payModalDashboard{{ invoice.id }}"><i class="fa-solid fa-wallet me-1"></i>Opłać</button></div>{% set payment_details = payment_details_map.get(invoice.id, {}) %}{% set modal_id = 'payModalDashboard' ~ invoice.id %}{% include 'partials/payment_modal.html' %}</td></tr>{% else %}<tr><td colspan="4" class="text-center text-secondary py-4">Brak danych.</td></tr>{% endfor %}</tbody></table></div><div class="card-body border-top py-2"><nav><ul class="pagination pagination-sm justify-content-end mb-0">{% if recent_pagination and recent_pagination.has_prev %}<li class="page-item"><a class="page-link" href="{{ url_for('dashboard.index', dashboard_page=recent_pagination.prev_num) }}">Poprz.</a></li>{% endif %}{% if recent_pagination and recent_pagination.pages > 1 %}{% for pg in range(1, recent_pagination.pages + 1) %}<li class="page-item {{ 'active' if pg == recent_pagination.page else '' }}"><a class="page-link" href="{{ url_for('dashboard.index', dashboard_page=pg) }}">{{ pg }}</a></li>{% endfor %}{% endif %}{% if recent_pagination and recent_pagination.has_next %}<li class="page-item"><a class="page-link" href="{{ url_for('dashboard.index', dashboard_page=recent_pagination.next_num) }}">Dalej</a></li>{% endif %}</ul></nav></div></div>
</div>
<div class="col-lg-4">
{% if health.critical %}
<div class="card mb-3 border-danger">
<div class="card-header bg-danger text-white">
<i class="fa-solid fa-triangle-exclamation me-2"></i>Raport krytyczny
</div>
<div class="card-body small">
{% if health.ksef != 'ok' %}
<div>API KSeF: {{ health.ksef }}</div>
{% if health.ksef_message %}
<div class="text-muted">{{ health.ksef_message }}</div>
{% endif %}
{% endif %}
{% if health.ceidg != 'ok' %}
<div>API CEIDG: {{ health.ceidg }}</div>
{% if health.ceidg_message %}
<div class="text-muted">{{ health.ceidg_message }}</div>
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
<div class="card">
<div class="card-header">
<i class="fa-solid fa-building-shield me-2"></i>Harmonogram
</div>
<div class="card-body small">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>Automatyczna synchronizacja</span>
<span class="badge {{ 'bg-success' if company.sync_enabled else 'bg-secondary' }}">
{{ 'włączona' if company.sync_enabled else 'wyłączona' }}
</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<span>Interwał</span>
<span class="badge bg-info text-dark">
{{ company.sync_interval_minutes }} min
</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span>Tryb pracy</span>
{% if read_only %}
<span class="badge bg-warning text-dark">tylko pobieranie</span>
{% else %}
<span class="badge bg-primary">pełna synchronizacja</span>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
let syncTimer = null;
const syncBtn = document.getElementById('syncBtn');
const statusMap = {queued: 'W kolejce', started: 'W toku', finished: 'Zakończona', error: 'Błąd'};
syncBtn?.addEventListener('click', async () => {
syncBtn.disabled = true;
syncBtn.classList.add('disabled');
document.getElementById('syncMessage').textContent = 'Uruchamianie ręcznego pobierania...';
try {
const res = await fetch(syncBtn.dataset.syncUrl, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': syncBtn.dataset.csrfToken,
},
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.log_id) {
const message = data.error || data.message || 'Nie udało się uruchomić ręcznego pobierania.';
document.getElementById('syncMessage').textContent = message;
return;
}
document.getElementById('syncMessage').textContent = 'Rozpoczęto pobieranie...';
syncTimer = setInterval(async () => {
const r = await fetch(`/sync/status/${data.log_id}`, {credentials: 'same-origin'});
const s = await r.json();
const bar = document.getElementById('syncProgressBar');
bar.style.width = `${s.progress}%`;
bar.textContent = `${s.progress}%`;
document.getElementById('syncStatusText').textContent = statusMap[s.status] || s.status || '—';
document.getElementById('syncMessage').textContent = s.message || '';
if (s.status === 'finished' || s.status === 'error') {
clearInterval(syncTimer);
syncBtn.disabled = false;
syncBtn.classList.remove('disabled');
if (s.status === 'finished') {
window.location.reload();
}
}
}, 1200);
} catch (err) {
document.getElementById('syncMessage').textContent = 'Nie udało się połączyć z usługą synchronizacji.';
syncBtn.disabled = false;
syncBtn.classList.remove('disabled');
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1 @@
{% extends 'base.html' %}{% block title %}403{% endblock %}{% block content %}<div class="text-center py-5"><h1>403</h1><p>Brak uprawnień do tej operacji.</p></div>{% endblock %}

View File

@@ -0,0 +1 @@
{% extends 'base.html' %}{% block title %}404{% endblock %}{% block content %}<div class="text-center py-5"><h1>404</h1><p>Nie znaleziono strony lub zasobu.</p></div>{% endblock %}

View File

@@ -0,0 +1 @@
{% extends 'base.html' %}{% block title %}500{% endblock %}{% block content %}<div class="text-center py-5"><h1>500</h1><p>Wystąpił błąd serwera. Spróbuj ponownie.</p></div>{% endblock %}

View File

@@ -0,0 +1 @@
{% extends 'base.html' %}{% block title %}503{% endblock %}{% block content %}<div class="container py-5"><div class="alert alert-warning shadow-sm"><h1 class="h3 mb-3">Usługa chwilowo niedostępna</h1><p class="mb-2">{{ message or 'Usługa pomocnicza jest chwilowo niedostępna.' }}</p><p class="mb-0 text-secondary small">Najczęściej oznacza to brak połączenia z Redisem. Błąd został przechwycony i nie powoduje już surowego błędu 500.</p></div></div>{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends 'base.html' %}
{% block title %}<i class="fa-solid fa-address-book me-2 text-primary"></i>Kontrahenci{% endblock %}
{% block content %}
{% set eyebrow='Kartoteka' %}{% set heading='Kontrahenci' %}{% set description='Lista kontrahentów i szybka edycja danych.' %}
{% include 'partials/page_header.html' with context %}
<div class="row g-4">
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header"><i class="fa-solid fa-user-tie me-2"></i>{{ 'Edytuj kontrahenta' if editing else 'Nowy kontrahent' }}</div>
<div class="card-body">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2"><label class="form-label">Nazwa</label><input class="form-control" name="name" value="{{ editing.name if editing else '' }}" placeholder="Przy pobieraniu z CEIDG uzupełni się automatycznie" {% if read_only_mode %}disabled{% endif %}></div>
<div class="mb-2"><label class="form-label">NIP</label><div class="input-group"><input class="form-control" name="tax_id" value="{{ editing.tax_id if editing else '' }}" placeholder="Wystarczy podać NIP" {% if read_only_mode %}disabled{% endif %}><button class="btn btn-outline-secondary" name="fetch_ceidg" value="1" {% if read_only_mode %}disabled{% endif %}>CEIDG</button></div><div class="form-text">Do pobrania danych z CEIDG wystarczy sam NIP.</div></div>
<div class="mb-2"><label class="form-label">REGON</label><input class="form-control" name="regon" value="{{ editing.regon if editing else '' }}" {% if read_only_mode %}disabled{% endif %}></div>
<div class="mb-2"><label class="form-label">Adres</label><input class="form-control" name="address" value="{{ editing.address if editing else '' }}" {% if read_only_mode %}disabled{% endif %}></div>
<div class="mb-3"><label class="form-label">E-mail</label><input class="form-control" name="email" value="{{ editing.email if editing else '' }}" {% if read_only_mode %}disabled{% endif %}></div>
<div class="d-grid gap-2"><button class="btn btn-primary" {% if read_only_mode %}disabled{% endif %}>{{ 'Zapisz kontrahenta' if editing else 'Dodaj kontrahenta' }}</button>{% if editing %}<a class="btn btn-outline-secondary" href="{{ url_for('invoices.customers') }}">Anuluj edycję</a>{% endif %}</div>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div><i class="fa-solid fa-users me-2"></i>Baza kontrahentów</div>
<form method="get" class="row g-2 align-items-end w-100 ms-0">
<div class="col-md-7"><label class="form-label form-label-sm mb-1">Szukaj</label><input class="form-control form-control-sm" type="search" name="q" value="{{ search or '' }}" placeholder="Szukaj po nazwie, NIP, REGON, mailu..."></div>
<div class="col-md-4"><label class="form-label form-label-sm mb-1">Sortowanie</label><select class="form-select form-select-sm" name="sort">
<option value="name_asc" {{ 'selected' if sort == 'name_asc' else '' }}>A-Z</option>
<option value="name_desc" {{ 'selected' if sort == 'name_desc' else '' }}>Z-A</option>
<option value="tax_id_asc" {{ 'selected' if sort == 'tax_id_asc' else '' }}>NIP rosnąco</option>
<option value="tax_id_desc" {{ 'selected' if sort == 'tax_id_desc' else '' }}>NIP malejąco</option>
</select></div>
<div class="col-md-1 d-grid"><button class="btn btn-sm btn-outline-secondary"><i class="fa-solid fa-magnifying-glass"></i></button></div>
</form>
</div>
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead><tr><th>Nazwa</th><th>NIP</th><th>Adres</th><th>E-mail</th><th></th></tr></thead>
<tbody>
{% for item in items %}
<tr><td><div class="fw-semibold">{{ item.name }}</div><div class="small text-secondary">{{ item.regon }}</div></td><td>{{ item.tax_id }}</td><td>{{ item.address }}</td><td>{{ item.email }}</td><td class="text-end"><a class="btn btn-sm btn-outline-primary" href="{{ url_for('invoices.customers', customer_id=item.id) }}">Edytuj</a></td></tr>
{% else %}
<tr><td colspan="5" class="text-secondary text-center py-4">Brak kontrahentów.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-body border-top py-2">
<nav>
<ul class="pagination justify-content-end mb-0">
{% if pagination.has_prev %}<li class="page-item"><a class="page-link" href="{{ url_for('invoices.customers', page=pagination.prev_num, q=search, sort=sort) }}">Poprz.</a></li>{% endif %}
<li class="page-item disabled"><span class="page-link">{{ pagination.page }} / {{ pagination.pages or 1 }}</span></li>
{% if pagination.has_next %}<li class="page-item"><a class="page-link" href="{{ url_for('invoices.customers', page=pagination.next_num, q=search, sort=sort) }}">Dalej</a></li>{% endif %}
</ul>
</nav>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,103 @@
{% extends 'base.html' %}
{% block title %}<i class="fa-solid fa-file-lines me-2 text-primary"></i>Faktura{% endblock %}
{% block content %}
<div class="invoice-detail-layout align-items-start">
<div class="invoice-detail-main">
<div class="card shadow-sm mb-3">
<div class="card-header"><i class="fa-solid fa-circle-info me-2"></i>Szczegóły faktury</div>
<div class="card-body">
<div class="row small g-2">
<div class="col-md-6"><strong>Numer:</strong> {{ invoice.invoice_number }}</div>
<div class="col-md-6"><strong>Numer KSeF:</strong> {{ invoice.ksef_number }}</div>
<div class="col-md-6"><strong>Kontrahent:</strong> {{ invoice.contractor_name }}</div>
<div class="col-md-6"><strong>NIP:</strong> {{ invoice.contractor_nip }}</div>
<div class="col-md-6"><strong>Adres:</strong> {{ invoice.contractor_address or '—' }}</div>
<div class="col-md-6"><strong>Kartoteka klientów:</strong> {{ linked_customer.name if linked_customer else 'brak powiązania' }}</div>
<div class="col-md-4"><strong>Netto:</strong> {{ invoice.net_amount|pln }}</div>
<div class="col-md-4"><strong>VAT:</strong> {{ invoice.vat_amount|pln }}</div>
<div class="col-md-4"><strong>Brutto:</strong> {{ invoice.gross_amount|pln }}</div>
<div class="col-md-6"><strong>Split payment:</strong> <span class="badge text-bg-{{ 'warning' if invoice.split_payment else 'secondary' }}">{{ 'Tak' if invoice.split_payment else 'Nie' }}</span></div>
<div class="col-md-6"><strong>Forma płatności:</strong> {{ payment_details.payment_form_label or '—' }}</div><div class="col-md-6"><strong>Rachunek bankowy:</strong> {{ payment_details.bank_account or (invoice.company.bank_account if invoice.company and invoice.source in ['issued', 'nfz'] else '') or '—' }}</div>{% if payment_details.bank_name %}<div class="col-md-6"><strong>Bank:</strong> {{ payment_details.bank_name }}</div>{% endif %}{% if payment_details.payment_due_date %}<div class="col-md-6"><strong>Termin płatności:</strong> {{ payment_details.payment_due_date }}</div>{% endif %}
{% if invoice.source in ['issued', 'nfz'] %}
<div class="col-md-6"><strong>Status wystawienia:</strong> {{ invoice.issued_status_label }}</div>
<div class="col-md-6"><strong>KSeF:</strong> <span class="badge text-bg-{{ 'success' if invoice.issued_to_ksef_at else 'secondary' }}">{{ 'Przesłana do KSeF' if invoice.issued_to_ksef_at else 'Nieprzesłana do KSeF' }}</span></div>
{% endif %}
</div>
{% if invoice.source in ['issued', 'nfz'] and not invoice.issued_to_ksef_at %}<div class="alert alert-warning mt-3 mb-0">Ta faktura nie została jeszcze wysłana do KSeF. Możesz ją edytować i wysłać później.</div>{% elif invoice.source in ['issued', 'nfz'] and invoice.issued_to_ksef_at %}<div class="alert alert-info mt-3 mb-0">Faktura została wysłana do KSeF {{ invoice.issued_to_ksef_at }}. Edycja jest zablokowana.</div>{% endif %}
{% if invoice.external_metadata and invoice.external_metadata.get('nfz') %}<div class="alert alert-success mt-3 mb-0"><strong>Moduł NFZ:</strong> {{ invoice.external_metadata.get('nfz', {}).get('recipient_branch_name') }} · okres {{ invoice.external_metadata.get('nfz', {}).get('settlement_from') }} - {{ invoice.external_metadata.get('nfz', {}).get('settlement_to') }} · umowa {{ invoice.external_metadata.get('nfz', {}).get('contract_number') }}</div>{% endif %}
<hr>
<div class="mb-3">{% for tag in invoice.tags %}<span class="badge text-bg-{{ tag.color }} me-1">{{ tag.name }}</span>{% endfor %}</div>
<div class="d-flex gap-2 flex-wrap mb-0">
<a class="btn btn-outline-primary" href="{{ url_for('invoices.pdf', invoice_id=invoice.id) }}"><i class="fa-solid fa-file-pdf me-1"></i>Pobierz PDF</a>
{% if can_add_seller_customer %}
<form method="post" action="{{ url_for('invoices.add_seller_customer', invoice_id=invoice.id) }}">
{{ form.csrf_token }}
<button class="btn btn-outline-success" {% if edit_locked %}disabled{% endif %}>
<i class="fa-solid fa-user-plus me-1"></i>{{ 'Przejdź do kontrahenta' if linked_customer else 'Dodaj sprzedawcę do kontrahentów' }}
</button>
</form>
{% elif linked_customer %}
<a class="btn btn-outline-success" href="{{ url_for('invoices.customers', customer_id=linked_customer.id) }}"><i class="fa-solid fa-address-book me-1"></i>Otwórz kontrahenta</a>
{% endif %}
{% if invoice.source in ['issued', 'nfz'] %}
<a class="btn btn-outline-secondary {% if edit_locked %}disabled{% endif %}" href="{{ url_for('invoices.duplicate', invoice_id=invoice.id) }}">
<i class="fa-solid fa-copy me-1"></i>Duplikuj do wystawienia
</a>
{% endif %}
{% if invoice.source == 'issued' and not invoice.issued_to_ksef_at %}<a class="btn btn-outline-secondary" href="{{ url_for('invoices.issued_edit', invoice_id=invoice.id) }}"><i class="fa-solid fa-pen-to-square me-1"></i>Edytuj fakturę</a><form method="post" action="{{ url_for('invoices.send_to_ksef', invoice_id=invoice.id) }}">{{ form.csrf_token }}<button class="btn btn-primary"><i class="fa-solid fa-paper-plane me-1"></i>Wyślij do KSeF</button></form>{% elif invoice.source == 'nfz' and not invoice.issued_to_ksef_at %}<a class="btn btn-outline-secondary" href="{{ url_for('nfz.edit', invoice_id=invoice.id) }}"><i class="fa-solid fa-pen-to-square me-1"></i>Edytuj fakturę NFZ</a><form method="post" action="{{ url_for('nfz.send_to_ksef', invoice_id=invoice.id) }}">{{ form.csrf_token }}<button class="btn btn-primary"><i class="fa-solid fa-paper-plane me-1"></i>Wyślij NFZ do KSeF</button></form>{% endif %}
</div>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header"><i class="fa-solid fa-eye me-2"></i>Podgląd faktury</div>
<div class="card-body">
<div class="border rounded p-3 bg-white overflow-auto invoice-preview-surface">{{ invoice.html_preview|safe if invoice.html_preview else 'Brak podglądu HTML.' }}</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-code me-2"></i>Surowy XML</span>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#invoiceRawXml" aria-expanded="false" aria-controls="invoiceRawXml">
<i class="fa-solid fa-chevron-down me-1"></i>Pokaż XML
</button>
</div>
<div class="collapse" id="invoiceRawXml">
<div class="card-body">
<pre class="small bg-body-tertiary p-3 overflow-auto mb-0" style="max-height:26rem; white-space:pre-wrap;">{{ xml_content if xml_content else 'Brak XML.' }}</pre>
</div>
</div>
</div>
</div>
<aside class="invoice-detail-sidebar">
<div class="invoice-detail-sticky">
<div class="card shadow-sm mb-3">
<div class="card-header"><i class="fa-solid fa-pen-to-square me-2"></i>Metadane</div>
<div class="card-body">
<form method="post">
{{ form.hidden_tag() }}
<div class="mb-2">{{ form.status.label(class='form-label') }}{{ form.status(class='form-select', disabled=edit_locked) }}</div>
<div class="mb-2">{{ form.tags.label(class='form-label') }}{{ form.tags(class='form-control', disabled=edit_locked) }}</div>
<div class="mb-2">{{ form.internal_note.label(class='form-label') }}{{ form.internal_note(class='form-control', rows=4, disabled=edit_locked) }}</div>
<div class="form-check">{{ form.queue_accounting(class='form-check-input', disabled=edit_locked) }}{{ form.queue_accounting.label(class='form-check-label') }}</div>
<div class="form-check mb-2">{{ form.pinned(class='form-check-input', disabled=edit_locked) }}{{ form.pinned.label(class='form-check-label') }}</div>
{{ form.submit(class='btn btn-primary w-100', disabled=edit_locked) }}
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header"><i class="fa-solid fa-paper-plane me-2"></i>Wyślij mailem</div>
<div class="card-body">
<form method="post" action="{{ url_for('invoices.send', invoice_id=invoice.id) }}">
{{ form.csrf_token }}
<input class="form-control mb-2" type="email" name="recipient" placeholder="odbiorca@example.com" {% if edit_locked %}disabled{% endif %}>
<button class="btn btn-outline-primary w-100" {% if edit_locked %}disabled{% endif %}><i class="fa-solid fa-envelope me-1"></i>Wyślij PDF</button>
</form>
</div>
</div>
</div>
</aside>
</div>
{% endblock %}

View File

@@ -0,0 +1,149 @@
{% extends 'base.html' %}
{% block title %}<i class="fa-solid fa-file-import me-2 text-primary"></i>Faktury otrzymane{% endblock %}
{% block content %}
{% set args = request.args.to_dict() %}
{% if 'page' in args %}
{% set _ = args.pop('page') %}
{% endif %}
<div class="card mb-3 shadow-sm">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-1">Faktury otrzymane</h5>
<div class="text-muted small">Tutaj widzisz tylko dokumenty kosztowe i otrzymane od kontrahentów.</div>
</div>
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('invoices.issued_list') }}">
<i class="fa-solid fa-arrow-up-right-from-square me-1"></i>Przejdź do wystawionych
</a>
</div>
<form method="get" class="row g-2">
<div class="col-md-1">{{ form.month(class='form-select') }}</div>
<div class="col-md-1">{{ form.year(class='form-control', placeholder='Rok') }}</div>
<div class="col-md-2">{{ form.contractor(class='form-control', placeholder='Kontrahent') }}</div>
<div class="col-md-1">{{ form.nip(class='form-control', placeholder='NIP') }}</div>
<div class="col-md-2">{{ form.invoice_type(class='form-select') }}</div>
<div class="col-md-2">{{ form.status(class='form-select') }}</div>
<div class="col-md-1">{{ form.quick_filter(class='form-select') }}</div>
<div class="col-md-2">{{ form.search(class='form-control', placeholder='Szukaj') }}</div>
<div class="col-md-2">{{ form.min_amount(class='form-control', placeholder='Min') }}</div>
<div class="col-md-2">{{ form.max_amount(class='form-control', placeholder='Max') }}</div>
<div class="col-md-2">{{ form.submit(class='btn btn-primary w-100') }}</div>
<div class="col-md-2">
<a class="btn btn-outline-secondary w-100" href="{{ url_for('invoices.export_csv', **request.args) }}">
<i class="fa-solid fa-file-csv me-1"></i>CSV
</a>
</div>
<div class="col-md-2">
<a class="btn btn-outline-secondary w-100" href="{{ url_for('invoices.export_zip', **request.args) }}">
<i class="fa-solid fa-file-zipper me-1"></i>ZIP
</a>
</div>
</form>
</div>
</div>
<form method="post" action="{{ url_for('invoices.bulk_action') }}">
{{ form.csrf_token }}
<div class="d-flex gap-2 mb-2 flex-wrap">
{% if read_only_mode %}
<div class="small text-warning-emphasis align-self-center">Akcje masowe są zablokowane w trybie read only.</div>
{% endif %}
<button class="btn btn-sm btn-outline-primary" name="action" value="mark_accounted" {% if read_only_mode %}disabled{% endif %}>
<i class="fa-solid fa-book me-1"></i>Masowo zaksięguj
</button>
<button class="btn btn-sm btn-outline-warning" name="action" value="queue_accounting" {% if read_only_mode %}disabled{% endif %}>
<i class="fa-solid fa-inbox me-1"></i>Do księgowości
</button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th style="width: 40px;"></th>
<th>Numer</th>
<th>KSeF</th>
<th>Kontrahent</th>
<th>NIP</th>
<th>Data</th>
<th>Netto</th>
<th>VAT</th>
<th>Brutto</th>
<th>Typ</th>
<th>Status</th>
<th class="text-end">Akcje</th>
</tr>
</thead>
<tbody>
{% for invoice in pagination.items %}
<tr>
<td>
<input type="checkbox" name="invoice_ids" value="{{ invoice.id }}" {% if read_only_mode %}disabled{% endif %}>
</td>
<td class="invoice-number-col">{{ invoice.invoice_number }}</td>
<td class="invoice-ksef-col ksef-break small">{{ invoice.ksef_number }}</td>
<td>{{ invoice.contractor_name }}</td>
<td>{{ invoice.contractor_nip }}</td>
<td>{{ invoice.issue_date }}</td>
<td>{{ invoice.net_amount|pln }}</td>
<td>{{ invoice.vat_amount|pln }}</td>
<td>{{ invoice.gross_amount|pln }}</td>
<td>{{ invoice.invoice_type_label }}</td>
<td>
<span class="badge text-bg-secondary">{{ invoice.status_label }}</span>
</td>
<td class="text-end invoice-actions-cell">
<div class="invoice-actions-stack">
<a class="btn btn-sm btn-outline-primary invoice-action-btn" href="{{ url_for('invoices.detail', invoice_id=invoice.id) }}">
<i class="fa-solid fa-folder-open me-1"></i>Otwórz
</a>
<button type="button" class="btn btn-sm btn-success invoice-action-btn" data-bs-toggle="modal" data-bs-target="#payModalReceived{{ invoice.id }}">
<i class="fa-solid fa-wallet me-1"></i>Opłać
</button>
</div>
{% set payment_details = payment_details_map.get(invoice.id, {}) %}
{% set modal_id = 'payModalReceived' ~ invoice.id %}
{% include 'partials/payment_modal.html' %}
</td>
</tr>
{% else %}
<tr>
<td colspan="12" class="text-center text-muted py-4">Brak faktur dla wybranych filtrów.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
{% if pagination.pages and pagination.pages > 1 %}
<nav aria-label="Paginacja faktur">
<ul class="pagination flex-wrap">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('invoices.index', page=pagination.prev_num, **args) }}">Poprz.</a>
</li>
{% endif %}
{% for pg in range(1, (pagination.pages or 1) + 1) %}
<li class="page-item {{ 'active' if pg == pagination.page else '' }}">
<a class="page-link" href="{{ url_for('invoices.index', page=pg, **args) }}">{{ pg }}</a>
</li>
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('invoices.index', page=pagination.next_num, **args) }}">Dalej</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,120 @@
{% extends 'base.html' %}
{% block title %}<i class="fa-solid fa-square-plus me-2 text-primary"></i>{{ 'Edytuj fakturę' if editing_invoice else 'Wystaw fakturę' }}{% endblock %}
{% block content %}
{% set eyebrow='Sprzedaż' %}{% set heading=('Edytuj fakturę' if editing_invoice else 'Wystaw fakturę') %}{% set description='Widok uproszczony, dopasowany do stylu panelu administracyjnego.' %}
{% include 'partials/page_header.html' with context %}
<div class="row g-4">
<div class="col-xl-8">
<div class="card">
<div class="card-body">
{% if read_only_mode %}<div class="alert alert-warning">Tryb tylko do odczytu - wystawianie faktur jest zablokowane.</div>{% endif %}
<form method="post">
{{ form.hidden_tag() }}
<div class="row g-3">
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1">
{{ form.customer_id.label(class='form-label mb-0') }}
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#customerQuickAddModal"><i class="fa-solid fa-plus me-1"></i>Dodaj</button>
</div>
{{ form.customer_id(class='form-select', disabled=read_only_mode) }}
<div class="form-text"><a href="{{ url_for('invoices.customers') }}">Pełna kartoteka klientów</a></div>
</div>
<div class="col-md-6">{{ form.numbering_template.label(class='form-label') }}{{ form.numbering_template(class='form-select', disabled=read_only_mode) }}</div>
<div class="col-md-6">{{ form.invoice_number.label(class='form-label') }}{{ form.invoice_number(class='form-control', disabled=read_only_mode, placeholder=preview_number) }}<div class="form-text">Puste pole = numer zostanie nadany automatycznie.</div></div>
<div class="col-md-6 d-flex align-items-end"><div class="alert alert-secondary w-100 mb-0 small">Proponowany numer: <strong>{{ preview_number or '—' }}</strong></div></div>
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1">
{{ form.product_id.label(class='form-label mb-0') }}
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#productQuickAddModal"><i class="fa-solid fa-plus me-1"></i>Dodaj</button>
</div>
<select class="form-select" id="productField" name="{{ form.product_id.name }}" {% if read_only_mode %}disabled{% endif %}>
{% for product in products %}
<option value="{{ product.id }}" data-net-price="{{ product.net_price }}" data-vat-rate="{{ product.vat_rate }}" data-split-payment="{{ 'true' if product.split_payment_default else 'false' }}" {{ 'selected' if form.product_id.data == product.id else '' }}>{{ product.name }} - {{ product.net_price }} PLN</option>
{% endfor %}
</select>
<div class="form-text"><a href="{{ url_for('invoices.products') }}">Pełna kartoteka towarów i usług</a></div>
</div>
<div class="col-md-3">{{ form.quantity.label(class='form-label') }}{{ form.quantity(class='form-control', disabled=read_only_mode, id='quantityField') }}</div>
<div class="col-md-3">{{ form.unit_net.label(class='form-label') }}{{ form.unit_net(class='form-control', disabled=read_only_mode, id='unitNetField') }}</div>
<div class="col-12">
<div class="form-check form-switch">
{{ form.split_payment(class='form-check-input', disabled=read_only_mode, id='splitPaymentField') }}
{{ form.split_payment.label(class='form-check-label') }}
</div>
<div class="form-text" id="splitPaymentHint">Domyślnie włączane dla usług oznaczonych w kartotece. Dla faktur powyżej 15 000 PLN brutto jest wymuszane.</div>
</div>
</div>
<div class="mt-3 d-flex gap-2 flex-wrap">{% if editing_invoice %}<button class="btn btn-outline-primary" name="save_submit" type="submit" {% if read_only_mode %}disabled{% endif %}>Zapisz zmiany</button><button class="btn btn-primary" name="submit" type="submit" {% if read_only_mode %}disabled{% endif %}>Zapisz i wyślij do KSeF</button>{% else %}{{ form.save_submit(class='btn btn-outline-primary', disabled=read_only_mode) }}{{ form.submit(class='btn btn-primary', disabled=read_only_mode) }}{% endif %}</div>
</form>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="card h-100">
<div class="card-header"><i class="fa-solid fa-circle-info me-2"></i>Podpowiedzi</div>
<div class="card-body small text-secondary d-flex flex-column gap-3">
<div>Dodawanie klientów działa teraz przez wspólne formularze dostępne także w formularzu NFZ. (jeśli moduł włączony)</div>
<div>Po zapisaniu nowy klient lub towar zostanie automatycznie podstawiony do formularza.</div>
<div>Pełne kartoteki nadal są dostępne z linków pod polami wyboru.</div>
</div>
</div>
</div>
</div>
{% set quick_return_endpoint = 'invoices.issued_edit' if editing_invoice else 'invoices.issued_new' %}
{% set quick_invoice_id = editing_invoice.id if editing_invoice else None %}
{% include 'partials/invoice_quick_add_modals.html' %}
<script>
(() => {
const productField = document.getElementById('productField');
const quantityField = document.getElementById('quantityField');
const unitNetField = document.getElementById('unitNetField');
const splitPaymentField = document.getElementById('splitPaymentField');
const splitPaymentHint = document.getElementById('splitPaymentHint');
if (!productField || !quantityField || !unitNetField || !splitPaymentField) return;
let userModifiedSplit = false;
const parseNumber = (value) => {
const normalized = String(value || '').replace(/\s+/g, '').replace(',', '.');
const parsed = Number.parseFloat(normalized);
return Number.isFinite(parsed) ? parsed : 0;
};
const syncSplitPayment = (resetToProductDefault = false) => {
const selected = productField.options[productField.selectedIndex];
const vatRate = parseNumber(selected?.dataset?.vatRate || 0);
const defaultSplit = (selected?.dataset?.splitPayment || 'false') === 'true';
const gross = parseNumber(quantityField.value) * parseNumber(unitNetField.value) * (1 + vatRate / 100);
const forced = gross > 15000;
if (forced) {
splitPaymentField.checked = true;
splitPaymentField.disabled = true;
if (splitPaymentHint) splitPaymentHint.textContent = 'Split payment jest wymagany, ponieważ wartość brutto faktury przekracza 15 000 PLN.';
return;
}
splitPaymentField.disabled = {{ 'true' if read_only_mode else 'false' }};
if (resetToProductDefault || !userModifiedSplit) {
splitPaymentField.checked = defaultSplit;
}
if (splitPaymentHint) {
splitPaymentHint.textContent = defaultSplit
? 'Dla wybranej usługi split payment jest domyślnie włączony. Możesz go zmienić dla tej faktury.'
: 'Split payment możesz włączyć ręcznie. Dla faktur powyżej 15 000 PLN brutto jest wymuszany.';
}
};
productField.addEventListener('change', () => {
const selected = productField.options[productField.selectedIndex];
if (selected?.dataset?.netPrice && !unitNetField.value) unitNetField.value = selected.dataset.netPrice;
userModifiedSplit = false;
syncSplitPayment(true);
});
quantityField.addEventListener('input', () => syncSplitPayment(false));
unitNetField.addEventListener('input', () => syncSplitPayment(false));
splitPaymentField.addEventListener('change', () => {
userModifiedSplit = true;
});
syncSplitPayment(false);
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}<i class="fa-solid fa-paper-plane me-2 text-primary"></i>Faktury wystawione{% endblock %}
{% block content %}
{% set eyebrow='Sprzedaż' %}{% set heading='Faktury wystawione' %}{% set description='Dokumenty sprzedażowe przygotowane w systemie, z oznaczeniem statusu wysyłki do KSeF.' %}
{% set actions %}<a class="btn btn-primary {% if read_only_mode %}disabled{% endif %}" href="{{ url_for('invoices.issued_new') }}"><i class="fa-solid fa-plus me-2"></i>Nowa faktura</a>{% endset %}
{% include 'partials/page_header.html' with context %}
<div class="card mb-3"><div class="card-body"><form method="get" class="row g-2 align-items-end"><div class="col-md-10"><label class="form-label">Szukaj</label><input class="form-control" type="text" name="q" value="{{ search or '' }}" placeholder="Numer, KSeF, kontrahent, NIP"></div><div class="col-md-2 d-grid"><button class="btn btn-outline-primary"><i class="fa-solid fa-magnifying-glass me-2"></i>Szukaj</button></div></form></div></div>
<div class="card">
<div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Numer</th><th>Kontrahent</th><th>Brutto</th><th>KSeF</th><th>Status</th><th></th></tr></thead><tbody>{% for invoice in invoices %}<tr><td><div class="fw-semibold">{{ invoice.invoice_number }}</div><div class="small text-secondary">{{ invoice.issue_date }}</div>{% if invoice.source == 'nfz' %}<div><span class="badge text-bg-success-subtle border nfz-badge mt-1">NFZ</span></div>{% endif %}</td><td>{{ invoice.contractor_name }}</td><td>{{ invoice.gross_amount|pln }}</td><td>{% if invoice.issued_to_ksef_at %}<span class="badge text-bg-success">Przesłana do KSeF</span>{% else %}<span class="badge text-bg-secondary">Nieprzesłana do KSeF</span>{% endif %}<div class="small text-secondary mt-1">{{ invoice.ksef_number }}</div></td><td><span class="badge text-bg-{{ 'success' if invoice.issued_to_ksef_at else 'secondary' }}">{{ invoice.issued_status_label }}</span></td><td class="text-end"><div class="d-inline-flex gap-2 flex-wrap justify-content-end"><a class="btn btn-sm btn-outline-primary invoice-action-btn" href="{{ url_for('invoices.detail', invoice_id=invoice.id) }}"><i class="fa-solid fa-folder-open me-1"></i>Otwórz</a><button type="button" class="btn btn-sm btn-success invoice-action-btn" data-bs-toggle="modal" data-bs-target="#payModalIssued{{ invoice.id }}"><i class="fa-solid fa-wallet me-1"></i>Opłać</button></div>{% set payment_details = payment_details_map.get(invoice.id, {}) %}{% set modal_id = 'payModalIssued' ~ invoice.id %}{% include 'partials/payment_modal.html' %}</td></tr>{% else %}<tr><td colspan="6" class="text-center text-secondary py-4">Brak faktur.</td></tr>{% endfor %}</tbody></table></div>
<div class="card-body border-top py-2"><nav><ul class="pagination justify-content-end mb-0">{% if pagination.has_prev %}<li class="page-item"><a class="page-link" href="{{ url_for('invoices.issued_list', page=pagination.prev_num, q=search) }}">Poprz.</a></li>{% endif %}{% for pg in range(1, (pagination.pages or 1) + 1) %}<li class="page-item {{ 'active' if pg == pagination.page else '' }}"><a class="page-link" href="{{ url_for('invoices.issued_list', page=pg, q=search) }}">{{ pg }}</a></li>{% endfor %}{% if pagination.has_next %}<li class="page-item"><a class="page-link" href="{{ url_for('invoices.issued_list', page=pagination.next_num, q=search) }}">Dalej</a></li>{% endif %}</ul></nav></div>
</div>
{% endblock %}

View File

@@ -0,0 +1 @@
<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:Helvetica,Arial,sans-serif;font-size:12px}table{width:100%;border-collapse:collapse}td,th{border:1px solid #ccc;padding:6px}</style></head><body><h1>{{ title }}</h1><table><thead><tr><th>Numer</th><th>Kontrahent</th><th>Netto</th><th>VAT</th><th>Brutto</th></tr></thead><tbody>{% for invoice in invoices %}<tr><td>{{ invoice.invoice_number }}</td><td>{{ invoice.contractor_name }}</td><td>{{ invoice.net_amount|pln }}</td><td>{{ invoice.vat_amount|pln }}</td><td>{{ invoice.gross_amount|pln }}</td></tr>{% endfor %}</tbody></table></body></html>

View File

@@ -0,0 +1,9 @@
{% extends 'base.html' %}
{% block title %}<i class="fa-solid fa-calendar-days me-2 text-primary"></i>Zestawienia {{ period_title }}{% endblock %}
{% block content %}
{% set eyebrow='Analiza' %}{% set heading='Zestawienia ' ~ period_title %}{% set description='Widok roczny, kwartalny i miesięczny z porównaniem do innych lat.' %}
{% set actions %}<div class="btn-group" role="group"><a class="btn {{ 'btn-primary' if period == 'year' else 'btn-outline-primary' }}" href="{{ url_for('invoices.monthly', period='year', q=search) }}">Roczne</a><a class="btn {{ 'btn-primary' if period == 'quarter' else 'btn-outline-primary' }}" href="{{ url_for('invoices.monthly', period='quarter', q=search) }}">Kwartalne</a><a class="btn {{ 'btn-primary' if period == 'month' else 'btn-outline-primary' }}" href="{{ url_for('invoices.monthly', period='month', q=search) }}">Miesięczne</a></div>{% endset %}
{% include 'partials/page_header.html' with context %}
<div class="row g-3 mb-3"><div class="col-lg-8"><div class="card h-100"><div class="card-body"><form method="get" class="row g-2 align-items-end"><input type="hidden" name="period" value="{{ period }}"><div class="col-md-9"><label class="form-label">Szukaj w zestawieniach</label><input class="form-control" type="text" name="q" value="{{ search }}" placeholder="Numer, KSeF, kontrahent, NIP"></div><div class="col-md-3 d-grid"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-magnifying-glass me-2"></i>Szukaj</button></div></form></div></div></div><div class="col-lg-4"><div class="card h-100"><div class="card-header">Statystyka innych lat</div><div class="card-body">{% for item in comparisons %}<div class="d-flex justify-content-between border-bottom py-2 small"><div><div class="fw-semibold">{{ item.year }}</div><div class="text-secondary">{{ item.count }} faktur · netto {{ item.net|pln }}</div></div><div class="text-end"><div>{{ item.gross|pln }}</div>{% if item.delta is not none %}<div class="text-{{ 'success' if item.delta >= 0 else 'danger' }}">{{ '+' if item.delta >= 0 else '' }}{{ item.delta|pln }}</div>{% else %}<div class="text-secondary">bazowy</div>{% endif %}</div></div>{% else %}<div class="text-secondary">Brak danych porównawczych.</div>{% endfor %}</div></div></div></div>
{% for group in groups %}<div class="card mb-3"><div class="card-header d-flex justify-content-between flex-wrap gap-2"><span class="fw-semibold">{{ group.label }}</span><span>{{ group.gross|pln }}</span></div><div class="card-body"><div class="row small g-2 mb-3"><div class="col-md-3"><strong>Liczba faktur:</strong> {{ group.count }}</div><div class="col-md-3"><strong>Netto:</strong> {{ group.net|pln }}</div><div class="col-md-3"><strong>VAT:</strong> {{ group.vat|pln }}</div><div class="col-md-3"><strong>Brutto:</strong> {{ group.gross|pln }}</div></div>{% if period == 'month' %}<div class="mb-2"><a class="btn btn-sm btn-outline-primary" href="{{ url_for('invoices.month_pdf', period=group.key) }}">PDF miesiąca</a></div>{% endif %}<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Numer</th><th>Kontrahent</th><th>Data</th><th>Brutto</th><th></th></tr></thead><tbody>{% for invoice in group.entries %}<tr><td>{{ invoice.invoice_number }}</td><td>{{ invoice.contractor_name }}</td><td>{{ invoice.issue_date }}</td><td>{{ invoice.gross_amount|pln }}</td><td class="text-end"><a class="btn btn-sm btn-outline-primary invoice-action-btn" href="{{ url_for('invoices.detail', invoice_id=invoice.id) }}"><i class="fa-solid fa-folder-open me-1"></i>Otwórz</a></td></tr>{% else %}<tr><td colspan="5" class="text-center text-secondary py-3">Brak faktur w tym okresie.</td></tr>{% endfor %}</tbody></table></div></div></div>{% else %}<div class="alert alert-info">Brak danych.</div>{% endfor %}
{% endblock %}

View File

@@ -0,0 +1 @@
<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:Helvetica,Arial,sans-serif;font-size:12px}table{width:100%;border-collapse:collapse}td,th{border:1px solid #ccc;padding:6px}</style></head><body><h1>Faktura {{ invoice.invoice_number }}</h1><table><tr><th>KSeF</th><td>{{ invoice.ksef_number }}</td></tr><tr><th>Kontrahent</th><td>{{ invoice.contractor_name }}</td></tr><tr><th>NIP</th><td>{{ invoice.contractor_nip }}</td></tr><tr><th>Data</th><td>{{ invoice.issue_date }}</td></tr><tr><th>Netto</th><td>{{ invoice.net_amount|pln }}</td></tr><tr><th>VAT</th><td>{{ invoice.vat_amount|pln }}</td></tr><tr><th>Brutto</th><td>{{ invoice.gross_amount|pln }}</td></tr><tr><th>Rachunek bankowy</th><td>{{ invoice.seller_bank_account or (invoice.company.bank_account if invoice.company else '') or '—' }}</td></tr></table></body></html>

View File

@@ -0,0 +1,65 @@
{% extends 'base.html' %}
{% block title %}<i class="fa-solid fa-boxes-stacked me-2 text-primary"></i>Towary i usługi{% endblock %}
{% block content %}
{% set eyebrow='Kartoteka' %}{% set heading='Towary i usługi' %}{% set description='Jednolity widok kartoteki z sortowaniem i paginacją.' %}
{% include 'partials/page_header.html' with context %}
<div class="row g-4">
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header"><i class="fa-solid fa-box-open me-2"></i>{{ 'Edytuj pozycję' if editing else 'Nowa pozycja' }}</div>
<div class="card-body">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2"><label class="form-label">Nazwa</label><input class="form-control" name="name" value="{{ editing.name if editing else '' }}" {% if read_only_mode %}disabled{% endif %}></div>
<div class="mb-2"><label class="form-label">SKU</label><input class="form-control" name="sku" value="{{ editing.sku if editing else '' }}" {% if read_only_mode %}disabled{% endif %}></div>
<div class="mb-2"><label class="form-label">Jednostka</label><input class="form-control" name="unit" value="{{ editing.unit if editing else 'szt.' }}" {% if read_only_mode %}disabled{% endif %}></div>
<div class="mb-2"><label class="form-label">Cena netto</label><input class="form-control" name="net_price" value="{{ editing.net_price if editing else '' }}" {% if read_only_mode %}disabled{% endif %}></div>
<div class="mb-2"><label class="form-label">VAT %</label><input class="form-control" name="vat_rate" value="{{ editing.vat_rate if editing else '23' }}" {% if read_only_mode %}disabled{% endif %}></div>
<div class="form-check mb-3"><input class="form-check-input" type="checkbox" id="split_payment_default" name="split_payment_default" value="1" {{ 'checked' if editing and editing.split_payment_default else '' }} {% if read_only_mode %}disabled{% endif %}><label class="form-check-label" for="split_payment_default">Domyślnie włączaj split payment dla tej usługi</label></div>
<div class="d-grid gap-2"><button class="btn btn-primary" {% if read_only_mode %}disabled{% endif %}>{{ 'Zapisz pozycję' if editing else 'Dodaj pozycję' }}</button>{% if editing %}<a class="btn btn-outline-secondary" href="{{ url_for('invoices.products') }}">Anuluj edycję</a>{% endif %}</div>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div><i class="fa-solid fa-warehouse me-2"></i>Baza towarów i usług</div>
<form method="get" class="row g-2 align-items-end w-100 ms-0">
<div class="col-md-7"><label class="form-label form-label-sm mb-1">Szukaj</label><input class="form-control form-control-sm" type="search" name="q" value="{{ search or '' }}" placeholder="Szukaj po nazwie, SKU lub jednostce..."></div>
<div class="col-md-4"><label class="form-label form-label-sm mb-1">Sortowanie</label><select class="form-select form-select-sm" name="sort">
<option value="name_asc" {{ 'selected' if sort == 'name_asc' else '' }}>A-Z</option>
<option value="name_desc" {{ 'selected' if sort == 'name_desc' else '' }}>Z-A</option>
<option value="price_asc" {{ 'selected' if sort == 'price_asc' else '' }}>Cena rosnąco</option>
<option value="price_desc" {{ 'selected' if sort == 'price_desc' else '' }}>Cena malejąco</option>
<option value="sku_asc" {{ 'selected' if sort == 'sku_asc' else '' }}>SKU A-Z</option>
<option value="sku_desc" {{ 'selected' if sort == 'sku_desc' else '' }}>SKU Z-A</option>
</select></div>
<div class="col-md-1 d-grid"><button class="btn btn-sm btn-outline-secondary"><i class="fa-solid fa-magnifying-glass"></i></button></div>
</form>
</div>
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead><tr><th>Nazwa</th><th>SKU</th><th>Cena netto</th><th>VAT</th><th>Split payment</th><th></th></tr></thead>
<tbody>
{% for item in items %}
<tr><td><i class="fa-solid fa-cube text-primary me-2"></i>{{ item.name }}</td><td>{{ item.sku }}</td><td>{{ item.net_price|pln }}</td><td>{{ item.vat_rate }}%</td><td>{% if item.split_payment_default %}<span class="badge text-bg-warning">Domyślny</span>{% else %}<span class="text-secondary">Wyłączony</span>{% endif %}</td><td class="text-end"><a class="btn btn-sm btn-outline-primary" href="{{ url_for('invoices.products', product_id=item.id) }}">Edytuj</a></td></tr>
{% else %}
<tr><td colspan="6" class="text-secondary text-center py-4">Brak pozycji.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-body border-top py-2">
<nav>
<ul class="pagination justify-content-end mb-0">
{% if pagination.has_prev %}<li class="page-item"><a class="page-link" href="{{ url_for('invoices.products', page=pagination.prev_num, q=search, sort=sort) }}">Poprz.</a></li>{% endif %}
<li class="page-item disabled"><span class="page-link">{{ pagination.page }} / {{ pagination.pages or 1 }}</span></li>
{% if pagination.has_next %}<li class="page-item"><a class="page-link" href="{{ url_for('invoices.products', page=pagination.next_num, q=search, sort=sort) }}">Dalej</a></li>{% endif %}
</ul>
</nav>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends 'base.html' %}
{% block title %}<i class="fa-solid fa-hospital me-2 text-primary"></i>Faktury NFZ{% endblock %}
{% block content %}
{% set editing_invoice = editing_invoice|default(None) %}
{% set eyebrow='Moduł dodatkowy' %}{% set heading='Wystawianie faktur NFZ' if not editing_invoice else 'Edycja faktury NFZ' %}{% set description='Formularz zawiera pola wymagane przez NFZ dla faktur ustrukturyzowanych FA(2)/FA(3) w KSeF.' %}
{% include 'partials/page_header.html' with context %}
<div class="row g-4">
<div class="col-xl-8">
<div class="card">
<div class="card-header"><i class="fa-solid fa-file-circle-plus me-2"></i>{{ 'Nowa faktura NFZ' if not editing_invoice else 'Edycja faktury NFZ ' ~ editing_invoice.invoice_number }}</div>
<div class="card-body">
{% if read_only_mode %}<div class="alert alert-warning">Tryb tylko do odczytu jest aktywny. Zapisy są zablokowane.</div>{% endif %}
<form method="post" class="row g-3">
{{ form.hidden_tag() }}
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1">
{{ form.customer_id.label(class='form-label mb-0') }}
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#customerQuickAddModal"><i class="fa-solid fa-plus me-1"></i>Dodaj</button>
</div>
{{ form.customer_id(class='form-select', disabled=read_only_mode) }}
</div>
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-1">
{{ form.product_id.label(class='form-label mb-0') }}
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#productQuickAddModal"><i class="fa-solid fa-plus me-1"></i>Dodaj</button>
</div>
{{ form.product_id(class='form-select', disabled=read_only_mode) }}
</div>
<div class="col-md-4">{{ form.invoice_number.label(class='form-label') }}{{ form.invoice_number(class='form-control', disabled=read_only_mode) }}</div>
<div class="col-md-4">{{ form.nfz_branch_id.label(class='form-label') }}{{ form.nfz_branch_id(class='form-select', disabled=read_only_mode) }}</div>
<div class="col-md-4">{{ form.provider_identifier.label(class='form-label') }}{{ form.provider_identifier(class='form-control', disabled=read_only_mode, placeholder='id-swd') }}</div>
<div class="col-md-3">{{ form.settlement_from.label(class='form-label') }}{{ form.settlement_from(class='form-control', disabled=read_only_mode) }}</div>
<div class="col-md-3">{{ form.settlement_to.label(class='form-label') }}{{ form.settlement_to(class='form-control', disabled=read_only_mode) }}</div>
<div class="col-md-6">{{ form.template_identifier.label(class='form-label') }}{{ form.template_identifier(class='form-control', disabled=read_only_mode, placeholder='id-szablonu z R_UMX') }}</div>
<div class="col-md-8">{{ form.service_code.label(class='form-label') }}{{ form.service_code(class='form-control', disabled=read_only_mode, placeholder='02.1500.001.02/1 lub 01.0010.094.01/1/5.01.00.0000127') }}</div>
<div class="col-md-4">{{ form.contract_number.label(class='form-label') }}{{ form.contract_number(class='form-control', disabled=read_only_mode, placeholder='120/999999/01/2025[23]') }}</div>
<div class="col-md-3">{{ form.quantity.label(class='form-label') }}{{ form.quantity(class='form-control', disabled=read_only_mode) }}</div>
<div class="col-md-3">{{ form.unit_net.label(class='form-label') }}{{ form.unit_net(class='form-control', disabled=read_only_mode) }}</div>
<div class="col-12 d-flex gap-2 flex-wrap">{{ form.save_submit(class='btn btn-outline-primary', disabled=read_only_mode) }}{{ form.submit(class='btn btn-primary', disabled=read_only_mode) }}</div>
</form>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="card mb-3">
<div class="card-header"><i class="fa-solid fa-list-check me-2"></i>Pola wymagane</div>
<div class="card-body small">
{% for key, desc in spec_fields %}
<div class="border-bottom py-2"><div class="fw-semibold">{{ key }}</div><div class="text-secondary">{{ desc }}</div></div>
{% endfor %}
</div>
</div>
<div class="card">
<div class="card-header"><i class="fa-solid fa-clock-rotate-left me-2"></i>Ostatnie faktury NFZ</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
{% for invoice in drafts %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start gap-2">
<a class="text-decoration-none" href="{{ url_for('invoices.detail', invoice_id=invoice.id) }}">
<div class="fw-semibold">{{ invoice.invoice_number }}</div>
<div class="small text-secondary">{{ invoice.contractor_name }}</div>
</a>
<a class="btn btn-sm btn-outline-secondary" href="{{ url_for('invoices.duplicate', invoice_id=invoice.id) }}">Duplikuj</a>
</div>
</div>
{% else %}
<div class="p-3 text-secondary small">Brak faktur NFZ.</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% set quick_return_endpoint = 'nfz.edit' if editing_invoice else 'nfz.index' %}
{% set quick_invoice_id = editing_invoice.id if editing_invoice else None %}
{% include 'partials/invoice_quick_add_modals.html' %}
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends 'base.html' %}
{% block title %}<i class="fa-solid fa-bell me-2 text-primary"></i>Powiadomienia{% endblock %}
{% block content %}
{% set eyebrow='Log systemowy' %}{% set heading='Dziennik powiadomień' %}{% set description='Ostatnie wpisy z kanałów Pushover, maili i pozostałych integracji.' %}
{% include 'partials/page_header.html' with context %}
{% set tone_map = {'wysłano':'success','wysłane':'success','sukces':'success','błąd':'danger','blad':'danger','pominięto':'secondary','oczekuje':'warning'} %}
<div class="card shadow-sm border-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr><th>Data</th><th>Kanał</th><th>Status</th><th>Treść</th></tr>
</thead>
<tbody>
{% for log in logs %}
{% set status = status_pl(log.status) %}
{% set badge = tone_map.get(status|lower, 'light') %}
<tr>
<td class="text-nowrap small">{{ log.created_at.strftime('%Y-%m-%d %H:%M') if log.created_at else '—' }}</td>
<td><span class="badge rounded-pill text-bg-light border">{{ channel_pl(log.channel) }}</span></td>
<td><span class="badge rounded-pill text-bg-{{ badge }}">{{ status }}</span></td>
<td class="small">{{ log.message }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-secondary py-5">Brak wpisów.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% set quick_return_endpoint = quick_return_endpoint|default('invoices.issued_new') %}
{% set quick_invoice_id = quick_invoice_id|default(None) %}
<div class="modal fade" id="customerQuickAddModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<form method="post" action="{{ url_for('invoices.quick_create_customer') }}">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-user-plus me-2"></i>Dodaj klienta</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="return_endpoint" value="{{ quick_return_endpoint }}">
{% if quick_invoice_id %}<input type="hidden" name="invoice_id" value="{{ quick_invoice_id }}">{% endif %}
<div class="row g-3">
<div class="col-12"><label class="form-label">Nazwa klienta</label><input class="form-control" name="name" required {% if read_only_mode %}disabled{% endif %}></div>
<div class="col-md-6"><label class="form-label">NIP</label><input class="form-control" name="tax_id" {% if read_only_mode %}disabled{% endif %}></div>
<div class="col-md-6"><label class="form-label">REGON</label><input class="form-control" name="regon" {% if read_only_mode %}disabled{% endif %}></div>
<div class="col-12"><label class="form-label">Adres</label><input class="form-control" name="address" {% if read_only_mode %}disabled{% endif %}></div>
<div class="col-12"><label class="form-label">E-mail</label><input class="form-control" name="email" {% if read_only_mode %}disabled{% endif %}></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Anuluj</button>
<button class="btn btn-primary" {% if read_only_mode %}disabled{% endif %}>Dodaj klienta</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="productQuickAddModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<form method="post" action="{{ url_for('invoices.quick_create_product') }}">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-box-open me-2"></i>Dodaj towar lub usługę</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="return_endpoint" value="{{ quick_return_endpoint }}">
{% if quick_invoice_id %}<input type="hidden" name="invoice_id" value="{{ quick_invoice_id }}">{% endif %}
<div class="row g-3">
<div class="col-12"><label class="form-label">Nazwa pozycji</label><input class="form-control" name="name" required {% if read_only_mode %}disabled{% endif %}></div>
<div class="col-md-6"><label class="form-label">SKU</label><input class="form-control" name="sku" {% if read_only_mode %}disabled{% endif %}></div>
<div class="col-md-3"><label class="form-label">JM</label><input class="form-control" name="unit" value="szt." {% if read_only_mode %}disabled{% endif %}></div>
<div class="col-md-3"><label class="form-label">VAT</label><input class="form-control" name="vat_rate" value="23" {% if read_only_mode %}disabled{% endif %}></div>
<div class="col-12"><label class="form-label">Cena netto</label><input class="form-control" name="net_price" {% if read_only_mode %}disabled{% endif %}></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Anuluj</button>
<button class="btn btn-primary" {% if read_only_mode %}disabled{% endif %}>Dodaj pozycję</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,8 @@
<div class="page-section-header d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="small text-secondary text-uppercase fw-semibold section-eyebrow">{{ eyebrow or 'Panel operacyjny' }}</div>
<h4 class="mb-1">{{ heading }}</h4>
{% if description %}<div class="text-secondary text-wrap-balanced">{{ description }}</div>{% endif %}
</div>
{% if actions %}<div class="d-flex gap-2 align-items-center flex-wrap">{{ actions|safe }}</div>{% endif %}
</div>

View File

@@ -0,0 +1,26 @@
<div class="modal fade" id="{{ modal_id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-wallet me-2"></i>Opłać</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<div class="small text-secondary mb-3">Dane do płatności dla dokumentu <strong>{{ invoice.invoice_number }}</strong>.</div>
<div class="vstack gap-2 small">
<div><span class="text-secondary">Metoda płatności:</span> <strong>{{ payment_details.payment_form_label or 'brak danych' }}</strong>{% if payment_details.payment_form_code %} <span class="badge text-bg-light border">kod {{ payment_details.payment_form_code }}</span>{% endif %}</div>
<div><span class="text-secondary">Numer faktury:</span> <strong>{{ invoice.invoice_number }}</strong></div>
<div><span class="text-secondary">Kontrahent:</span> <strong>{{ invoice.contractor_name or '—' }}</strong></div>
{% if invoice.contractor_address %}<div><span class="text-secondary">Adres:</span> {{ invoice.contractor_address }}</div>{% endif %}
<div><span class="text-secondary">Kwota brutto:</span> <strong>{{ invoice.gross_amount|pln }}</strong></div>
<div><span class="text-secondary">Numer konta:</span> <strong>{{ payment_details.bank_account or 'brak danych' }}</strong></div>
{% if payment_details.bank_name %}<div><span class="text-secondary">Bank:</span> {{ payment_details.bank_name }}</div>{% endif %}
{% if payment_details.payment_due_date %}<div><span class="text-secondary">Termin płatności:</span> {{ payment_details.payment_due_date }}</div>{% endif %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Zamknij</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,69 @@
{% extends 'base.html' %}
{% macro source_switch(name, current, first_value, first_label, second_value, second_label) -%}
<div class="source-switch" data-source-switch>
<input class="btn-check" type="radio" name="{{ name }}" id="{{ name }}-{{ first_value }}" value="{{ first_value }}" autocomplete="off" {{ 'checked' if current == first_value else '' }}>
<label class="btn btn-source" for="{{ name }}-{{ first_value }}">{{ first_label }}</label>
<input class="btn-check" type="radio" name="{{ name }}" id="{{ name }}-{{ second_value }}" value="{{ second_value }}" autocomplete="off" {{ 'checked' if current == second_value else '' }}>
<label class="btn btn-source" for="{{ name }}-{{ second_value }}">{{ second_label }}</label>
</div>
{%- endmacro %}
{% block title %}<i class="fa-solid fa-gear me-2 text-primary"></i>Ustawienia{% endblock %}
{% block content %}
{% set eyebrow='Konfiguracja' %}{% set heading='Ustawienia użytkownika i firmy' %}{% set description='Wybierz, które moduły mają korzystać z profilu globalnego, a które z indywidualnych ustawień użytkownika.' %}
{% include 'partials/page_header.html' with context %}
<div class="row g-3 mb-4">
<div class="col-lg-4"><div class="card h-100"><div class="card-header">Aktywna firma</div><div class="card-body compact-card-body"><div class="fw-semibold">{{ company.name if company else 'Brak przypisanej firmy' }}</div><div class="small text-secondary mt-2">KSeF współdzielony dotyczy aktywnej firmy. SMTP, Pushover i NFZ mogą działać globalnie lub indywidualnie per użytkownik.</div><div class="mt-3 d-flex gap-2 flex-wrap"><span class="badge text-bg-light border">KSeF {{ ksef_environment|upper }}</span><span class="badge text-bg-light border">SMTP {{ 'global' if mail_mode == 'global' else 'indywidualny' }}</span><span class="badge text-bg-light border">Pushover {{ 'global' if notify_mode == 'global' else 'indywidualny' }}</span><span class="badge text-bg-light border">NFZ {{ 'globalny' if nfz_mode == 'global' else 'indywidualny' }}</span></div></div></div></div>
<div class="col-lg-8"><div class="card h-100"><div class="card-header">Wygląd interfejsu</div><div class="card-body compact-card-body"><form method="post" class="row g-3 align-items-end">{{ appearance_form.hidden_tag() }}<div class="col-md-7">{{ appearance_form.theme_preference.label(class='form-label') }}{{ appearance_form.theme_preference(class='form-select') }}</div><div class="col-md-5 d-grid">{{ appearance_form.submit(class='btn btn-primary') }}</div></form></div></div></div>
</div>
<div class="row g-3">
<div class="col-lg-3">
<div class="nav flex-column nav-pills settings-tab gap-2">
{% if can_manage_company_settings %}<button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-company" type="button">Firma</button>{% endif %}
<button class="nav-link {{ '' if can_manage_company_settings else 'active' }}" data-bs-toggle="pill" data-bs-target="#tab-ksef" type="button">KSeF</button>
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-mail" type="button">SMTP</button>
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-notify" type="button">Pushover</button>
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-nfz" type="button">NFZ</button>
</div>
</div>
<div class="col-lg-9">
<div class="tab-content">
{% if can_manage_company_settings %}
<div class="tab-pane fade show active" id="tab-company"><div class="card"><div class="card-header">Ustawienia firmy</div><div class="card-body"><form method="post" class="row g-3 align-items-end">{{ company_form.hidden_tag() }}<div class="col-md-6">{{ company_form.name.label(class='form-label') }}{{ company_form.name(class='form-control') }}</div><div class="col-md-3">{{ company_form.tax_id.label(class='form-label') }}{{ company_form.tax_id(class='form-control') }}</div><div class="col-md-3">{{ company_form.sync_interval_minutes.label(class='form-label') }}{{ company_form.sync_interval_minutes(class='form-control') }}</div><div class="col-md-6">{{ company_form.bank_account.label(class='form-label') }}{{ company_form.bank_account(class='form-control', placeholder='np. 11 1111 1111 1111 1111 1111 1111') }}</div><div class="col-md-6 form-check ms-2">{{ company_form.sync_enabled(class='form-check-input') }}{{ company_form.sync_enabled.label(class='form-check-label') }}</div><div class="col-md-5"><div class="form-check form-switch fs-5">{{ company_form.read_only_mode(class='form-check-input') }}{{ company_form.read_only_mode.label(class='form-check-label') }}</div><div class="form-text">Rzeczywisty tryb może być dodatkowo ograniczony globalnie lub uprawnieniami użytkownika.</div></div><div class="col-12"><div class="alert alert-{{ 'warning' if effective_read_only else 'success' }} mb-0">Tryb efektywny: <strong>{{ 'R/O' if effective_read_only else 'R/W' }}</strong>{% if read_only_reasons %}<div class="small mt-2">Źródło: {{ read_only_reasons|join(', ') }}</div>{% endif %}</div></div><div class="col-12 d-grid d-md-flex">{{ company_form.submit(class='btn btn-primary') }}</div></form></div></div></div>
{% endif %}
<div class="tab-pane fade {% if can_manage_company_settings %}{% else %}show active{% endif %}" id="tab-ksef"><div class="card"><div class="card-header">KSeF</div><div class="card-body">
<div class="settings-module-intro"><div><h6 class="mb-1">Model biznesowy KSeF</h6><div class="text-secondary small">Domyślnie użytkownik pracuje na własnym profilu. W razie potrzeby może przełączyć się na współdzielony profil aktywnej firmy przygotowany przez administratora.</div></div><span class="badge text-bg-{{ 'primary' if ksef_mode == 'user' else 'secondary' }}">{{ 'profil indywidualny' if ksef_mode == 'user' else 'profil współdzielony firmy' }}</span></div>
<form method="post" enctype="multipart/form-data" class="row g-3" data-mode-target="ksefFields">{{ ksef_form.hidden_tag() }}{{ source_switch(ksef_form.source_mode.name, ksef_mode, 'user', 'Moje ustawienia KSeF', 'global', 'Użyj profilu współdzielonego firmy') }}<div class="col-12"></div><div class="col-12 source-panel {{ '' if ksef_mode == 'user' else 'd-none' }}" id="ksefFields"><div class="row g-3"><div class="col-md-4">{{ ksef_form.environment.label(class='form-label') }}{{ ksef_form.environment(class='form-select') }}</div><div class="col-md-4">{{ ksef_form.auth_mode.label(class='form-label') }}{{ ksef_form.auth_mode(class='form-select') }}</div><div class="col-md-4">{{ ksef_form.client_id.label(class='form-label') }}{{ ksef_form.client_id(class='form-control') }}</div><div class="col-md-6">{{ ksef_form.token.label(class='form-label') }}{{ ksef_form.token(class='form-control', autocomplete='new-password', placeholder='Podaj nowy token tylko przy zmianie') }}<div class="form-text">{{ 'Token KSeF jest zapisany w konfiguracji tej firmy.' if company_token_configured else ('Token zapisany.' if token_configured else 'Brak zapisanego tokena.') }}</div></div><div class="col-md-6">{{ ksef_form.certificate_file.label(class='form-label') }}{{ ksef_form.certificate_file(class='form-control') }}<div class="form-text">{% if company_certificate_name %}Certyfikat KSeF jest zapisany w konfiguracji tej firmy. Wgrany plik: {{ company_certificate_name }}{% elif certificate_name %}Wgrany plik: {{ certificate_name }}{% elif company_certificate_configured %}Certyfikat KSeF jest zapisany w konfiguracji tej firmy.{% else %}Brak zapisanego certyfikatu.{% endif %}</div></div></div></div><div class="col-12 source-panel-note {{ '' if ksef_mode == 'global' else 'd-none' }}" id="ksefFieldsNote"><div class="alert alert-info mb-0">Po zapisaniu system będzie używał współdzielonego profilu KSeF aktywnej firmy. Parametry i certyfikat konfiguruje administrator w panelu Admin → Ustawienia globalne.</div></div><div class="col-12 d-flex gap-2 flex-wrap">{{ ksef_form.submit(class='btn btn-primary') }}</div></form>
</div></div></div>
<div class="tab-pane fade" id="tab-mail"><div class="card"><div class="card-header">SMTP</div><div class="card-body"><form method="post" class="row g-3" data-mode-target="mailFields">{{ mail_form.hidden_tag() }}{{ source_switch(mail_form.source_mode.name, mail_mode, 'global', 'Użyj ustawień globalnych', 'user', 'Podaj indywidualne ustawienia') }}<div class="col-12"></div><div class="col-12 source-panel {{ '' if mail_mode == 'user' else 'd-none' }}" id="mailFields"><div class="row g-3"><div class="col-md-4">{{ mail_form.server.label(class='form-label') }}{{ mail_form.server(class='form-control') }}</div><div class="col-md-2">{{ mail_form.port.label(class='form-label') }}{{ mail_form.port(class='form-control') }}</div><div class="col-md-3">{{ mail_form.username.label(class='form-label') }}{{ mail_form.username(class='form-control') }}</div><div class="col-md-3">{{ mail_form.password.label(class='form-label') }}{{ mail_form.password(class='form-control') }}</div><div class="col-md-6">{{ mail_form.sender.label(class='form-label') }}{{ mail_form.sender(class='form-control') }}</div><div class="col-md-6">{{ mail_form.test_recipient.label(class='form-label') }}{{ mail_form.test_recipient(class='form-control') }}</div><div class="col-md-4">{{ mail_form.security_mode.label(class='form-label') }}{{ mail_form.security_mode(class='form-select') }}</div></div></div><div class="col-12 source-panel-note {{ '' if mail_mode == 'global' else 'd-none' }}" id="mailFieldsNote"><div class="alert alert-info mb-0">Przy trybie globalnym wiadomości będą wysyłane przez konfigurację ustawioną przez administratora.</div></div><div class="col-12 d-flex gap-2 flex-wrap">{{ mail_form.submit(class='btn btn-primary') }}{{ mail_form.test_submit(class='btn btn-outline-secondary') }}</div></form></div></div></div>
<div class="tab-pane fade" id="tab-notify"><div class="card"><div class="card-header">Pushover</div><div class="card-body"><form method="post" class="row g-3" data-mode-target="notifyFields">{{ notify_form.hidden_tag() }}{{ source_switch(notify_form.source_mode.name, notify_mode, 'global', 'Użyj ustawień globalnych', 'user', 'Podaj indywidualne ustawienia') }}<div class="col-12"></div><div class="col-12 source-panel {{ '' if notify_mode == 'user' else 'd-none' }}" id="notifyFields"><div class="row g-3"><div class="col-md-6">{{ notify_form.pushover_user_key.label(class='form-label') }}{{ notify_form.pushover_user_key(class='form-control') }}</div><div class="col-md-6">{{ notify_form.pushover_api_token.label(class='form-label') }}{{ notify_form.pushover_api_token(class='form-control') }}</div><div class="col-md-4">{{ notify_form.min_amount.label(class='form-label') }}{{ notify_form.min_amount(class='form-control') }}</div><div class="col-md-8">{{ notify_form.quiet_hours.label(class='form-label') }}{{ notify_form.quiet_hours(class='form-control') }}</div><div class="col-12 form-check">{{ notify_form.enabled(class='form-check-input') }}{{ notify_form.enabled.label(class='form-check-label') }}</div></div></div><div class="col-12 source-panel-note {{ '' if notify_mode == 'global' else 'd-none' }}" id="notifyFieldsNote"><div class="alert alert-info mb-0">Przy trybie globalnym powiadomienia trafią według konfiguracji wspólnej systemu.</div></div><div class="col-12 d-flex gap-2 flex-wrap">{{ notify_form.submit(class='btn btn-primary') }}{{ notify_form.test_submit(class='btn btn-outline-secondary') }}</div></form></div></div></div>
<div class="tab-pane fade" id="tab-nfz"><div class="card"><div class="card-header">Moduł NFZ</div><div class="card-body"><form method="post" class="row g-3" data-mode-target="nfzFields">{{ nfz_form.hidden_tag() }}{{ source_switch(nfz_form.source_mode.name, nfz_mode, 'global', 'Użyj ustawień globalnych', 'user', 'Ustaw indywidualnie') }}<div class="col-12"></div><div class="col-12 source-panel {{ '' if nfz_mode == 'user' else 'd-none' }}" id="nfzFields"><div class="form-check form-switch fs-5">{{ nfz_form.enabled(class='form-check-input') }}{{ nfz_form.enabled.label(class='form-check-label') }}</div><div class="form-text">Własne ustawienie użytkownika nadpisze konfigurację globalną tylko dla Twojego konta.</div></div><div class="col-12 source-panel-note {{ '' if nfz_mode == 'global' else 'd-none' }}" id="nfzFieldsNote"><div class="alert alert-info mb-0">Moduł NFZ odziedziczy ustawienie globalne administratora.</div></div><div class="col-md-4 d-grid">{{ nfz_form.submit(class='btn btn-primary') }}</div></form></div></div></div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-source-switch]').forEach(function (group) {
const form = group.closest('form');
if (!form) return;
const targetId = form.dataset.modeTarget;
const fields = targetId ? document.getElementById(targetId) : null;
const note = targetId ? document.getElementById(targetId + 'Note') : null;
const update = function () {
const checked = group.querySelector('input:checked');
const isUser = checked && checked.value === 'user';
if (fields) fields.classList.toggle('d-none', !isUser);
if (note) note.classList.toggle('d-none', isUser);
};
group.querySelectorAll('input').forEach(function (input) { input.addEventListener('change', update); });
update();
});
});
</script>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More