push
This commit is contained in:
29
.dockerignore
Normal file
29
.dockerignore
Normal 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
90
.env.example
Normal 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
28
.gitignore
vendored
Normal 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
20
DEPLOY_SSL_NOTES.txt
Normal 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
30
Dockerfile
Normal 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
106
README.md
Normal 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
165
app/__init__.py
Normal 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
0
app/admin/__init__.py
Normal file
498
app/admin/routes.py
Normal file
498
app/admin/routes.py
Normal 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
0
app/api/__init__.py
Normal file
24
app/api/routes.py
Normal file
24
app/api/routes.py
Normal 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
0
app/auth/__init__.py
Normal file
48
app/auth/routes.py
Normal file
48
app/auth/routes.py
Normal 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
72
app/cli.py
Normal 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}')
|
||||
0
app/dashboard/__init__.py
Normal file
0
app/dashboard/__init__.py
Normal file
204
app/dashboard/routes.py
Normal file
204
app/dashboard/routes.py
Normal 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
17
app/extensions.py
Normal 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
0
app/forms/__init__.py
Normal file
120
app/forms/admin.py
Normal file
120
app/forms/admin.py
Normal 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
7
app/forms/auth.py
Normal 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
26
app/forms/invoices.py
Normal 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
15
app/forms/issued.py
Normal 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
41
app/forms/nfz.py
Normal 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
73
app/forms/settings.py
Normal 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
0
app/invoices/__init__.py
Normal file
725
app/invoices/routes.py
Normal file
725
app/invoices/routes.py
Normal 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
12
app/logging_config.py
Normal 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
0
app/models/__init__.py
Normal file
10
app/models/audit_log.py
Normal file
10
app/models/audit_log.py
Normal 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
6
app/models/base.py
Normal 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
50
app/models/catalog.py
Normal 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
29
app/models/company.py
Normal 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
185
app/models/invoice.py
Normal 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)
|
||||
1
app/models/notification.py
Normal file
1
app/models/notification.py
Normal file
@@ -0,0 +1 @@
|
||||
from app.models.invoice import NotificationLog
|
||||
50
app/models/setting.py
Normal file
50
app/models/setting.py
Normal 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
18
app/models/sync_log.py
Normal 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
40
app/models/user.py
Normal 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
0
app/nfz/__init__.py
Normal file
281
app/nfz/routes.py
Normal file
281
app/nfz/routes.py
Normal 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.'),
|
||||
]
|
||||
0
app/notifications/__init__.py
Normal file
0
app/notifications/__init__.py
Normal file
12
app/notifications/routes.py
Normal file
12
app/notifications/routes.py
Normal 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)
|
||||
0
app/repositories/__init__.py
Normal file
0
app/repositories/__init__.py
Normal file
108
app/repositories/invoice_repository.py
Normal file
108
app/repositories/invoice_repository.py
Normal 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
62
app/scheduler.py
Normal 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
61
app/seed.py
Normal 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
0
app/services/__init__.py
Normal file
19
app/services/audit_service.py
Normal file
19
app/services/audit_service.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from flask import request
|
||||
from flask_login import current_user
|
||||
from app.extensions import db
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
|
||||
class AuditService:
|
||||
def log(self, action, target_type, target_id=None, details=''):
|
||||
entry = AuditLog(
|
||||
user_id=current_user.id if getattr(current_user, 'is_authenticated', False) else None,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
remote_addr=request.remote_addr if request else None,
|
||||
details=details,
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
return entry
|
||||
66
app/services/backup_service.py
Normal file
66
app/services/backup_service.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from flask import current_app
|
||||
|
||||
|
||||
class BackupService:
|
||||
def create_backup(self):
|
||||
target = Path(current_app.config['BACKUP_PATH']) / f'backup_{datetime.utcnow().strftime("%Y%m%d_%H%M%S")}'
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
base_dir = Path(current_app.root_path).parent
|
||||
for part in ['instance', 'storage/archive', 'storage/pdf']:
|
||||
src = base_dir / part
|
||||
if src.exists():
|
||||
shutil.copytree(src, target / Path(part).name, dirs_exist_ok=True)
|
||||
archive = shutil.make_archive(str(target), 'zip', root_dir=target)
|
||||
return archive
|
||||
|
||||
def get_database_backup_meta(self) -> dict:
|
||||
uri = current_app.config.get('SQLALCHEMY_DATABASE_URI', '')
|
||||
backup_dir = Path(current_app.config['BACKUP_PATH'])
|
||||
engine = 'unknown'
|
||||
if '://' in uri:
|
||||
engine = uri.split('://', 1)[0]
|
||||
sqlite_supported = uri.startswith('sqlite:///') and not uri.endswith(':memory:')
|
||||
sqlite_path = None
|
||||
sqlite_exists = False
|
||||
if sqlite_supported:
|
||||
sqlite_path = Path(uri.replace('sqlite:///', '', 1))
|
||||
sqlite_exists = sqlite_path.exists()
|
||||
return {
|
||||
'engine': engine,
|
||||
'backup_dir': str(backup_dir),
|
||||
'sqlite_supported': sqlite_supported,
|
||||
'sqlite_path': str(sqlite_path) if sqlite_path else None,
|
||||
'sqlite_exists': sqlite_exists,
|
||||
'notes': [
|
||||
'Kopia z panelu działa plikowo dla SQLite.',
|
||||
'Dla PostgreSQL, MySQL i innych silników wymagany jest natywny dump bazy poza aplikacją.',
|
||||
],
|
||||
}
|
||||
|
||||
def create_database_backup(self) -> str:
|
||||
target_dir = Path(current_app.config['BACKUP_PATH'])
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
|
||||
uri = current_app.config.get('SQLALCHEMY_DATABASE_URI', '')
|
||||
if uri.startswith('sqlite:///') and not uri.endswith(':memory:'):
|
||||
source = Path(uri.replace('sqlite:///', '', 1))
|
||||
if not source.exists():
|
||||
raise FileNotFoundError(f'Plik bazy nie istnieje: {source}')
|
||||
target = target_dir / f'db_backup_{timestamp}.sqlite3'
|
||||
shutil.copy2(source, target)
|
||||
return str(target)
|
||||
target = target_dir / f'db_backup_{timestamp}.txt'
|
||||
target.write_text(
|
||||
"""Automatyczna kopia DB dla bieżącego silnika nie jest obsługiwana plikowo.
|
||||
W panelu admina kopia działa bezpośrednio tylko dla SQLite.
|
||||
Wykonaj backup natywnym narzędziem bazy danych.
|
||||
""",
|
||||
encoding='utf-8',
|
||||
)
|
||||
return str(target)
|
||||
334
app/services/ceidg_service.py
Normal file
334
app/services/ceidg_service.py
Normal file
@@ -0,0 +1,334 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from app.models.setting import AppSetting
|
||||
|
||||
|
||||
class CeidgService:
|
||||
DEFAULT_TIMEOUT = 12
|
||||
API_URLS = {
|
||||
'production': 'https://dane.biznes.gov.pl/api/ceidg/v3/firmy',
|
||||
'test': 'https://test-dane.biznes.gov.pl/api/ceidg/v3/firmy',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _digits(value: str | None) -> str:
|
||||
return re.sub(r'\D+', '', value or '')
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(value: str | None) -> str:
|
||||
if value is None:
|
||||
return ''
|
||||
value = re.sub(r'\s+', ' ', str(value))
|
||||
return value.strip(' ,;:-')
|
||||
|
||||
@staticmethod
|
||||
def _normalize_empty(value: Any) -> Any:
|
||||
if value is None:
|
||||
return ''
|
||||
if isinstance(value, dict):
|
||||
return {key: CeidgService._normalize_empty(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [CeidgService._normalize_empty(item) for item in value]
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def get_environment(cls) -> str:
|
||||
environment = AppSetting.get('ceidg.environment', 'production')
|
||||
return environment if environment in cls.API_URLS else 'production'
|
||||
|
||||
@classmethod
|
||||
def get_api_url(cls, environment: str | None = None) -> str:
|
||||
env = environment or cls.get_environment()
|
||||
return cls.API_URLS.get(env, cls.API_URLS['production'])
|
||||
|
||||
@staticmethod
|
||||
def get_api_key() -> str:
|
||||
return (AppSetting.get('ceidg.api_key', '', decrypt=True) or '').strip()
|
||||
|
||||
@classmethod
|
||||
def has_api_key(cls) -> bool:
|
||||
return bool(cls.get_api_key())
|
||||
|
||||
@classmethod
|
||||
def _headers(cls) -> dict[str, str]:
|
||||
headers = {'Accept': 'application/json', 'User-Agent': 'KSeF Manager/1.0'}
|
||||
api_key = cls.get_api_key()
|
||||
if api_key:
|
||||
token = api_key.strip()
|
||||
headers['Authorization'] = token if token.lower().startswith('bearer ') else f'Bearer {token}'
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _safe_payload(response: requests.Response):
|
||||
try:
|
||||
return CeidgService._normalize_empty(response.json())
|
||||
except Exception:
|
||||
return response.text[:1000]
|
||||
|
||||
def fetch_company(self, identifier: str | None = None, *, nip: str | None = None) -> dict:
|
||||
nip = self._digits(nip or identifier)
|
||||
if len(nip) != 10:
|
||||
return {'ok': False, 'message': 'Podaj poprawny 10-cyfrowy NIP.'}
|
||||
|
||||
if not self.has_api_key():
|
||||
return {
|
||||
'ok': False,
|
||||
'message': 'Brak API KEY do CEIDG. Uzupełnij klucz w panelu admina.',
|
||||
'fallback': {'tax_id': nip},
|
||||
}
|
||||
|
||||
environment = self.get_environment()
|
||||
api_url = self.get_api_url(environment)
|
||||
try:
|
||||
response = requests.get(api_url, headers=self._headers(), params={'nip': nip}, timeout=self.DEFAULT_TIMEOUT)
|
||||
if response.status_code in {401, 403}:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': 'CEIDG odrzucił token. Sprawdź klucz w panelu admina.',
|
||||
'error': response.text[:500],
|
||||
'debug': {
|
||||
'environment': environment,
|
||||
'url': api_url,
|
||||
'auth_prefix': 'Bearer' if self._headers().get('Authorization', '').startswith('Bearer ') else 'raw',
|
||||
'token_len': len(self.get_api_key()),
|
||||
},
|
||||
'fallback': {'tax_id': nip},
|
||||
}
|
||||
if response.status_code == 404:
|
||||
return {'ok': False, 'message': 'Nie znaleziono firmy o podanym NIP w CEIDG.', 'fallback': {'tax_id': nip}}
|
||||
response.raise_for_status()
|
||||
parsed = self._parse_payload(response.text, nip)
|
||||
if parsed:
|
||||
parsed['ok'] = True
|
||||
parsed['source_url'] = api_url
|
||||
parsed['environment'] = environment
|
||||
parsed['message'] = 'Pobrano dane z API CEIDG.'
|
||||
return parsed
|
||||
return {
|
||||
'ok': False,
|
||||
'message': 'Nie znaleziono firmy w CEIDG. Podmiot może być zarejestrowany w KRS.',
|
||||
'sample': self._safe_payload(response),
|
||||
'fallback': {'tax_id': nip},
|
||||
}
|
||||
except requests.exceptions.Timeout as exc:
|
||||
error = f'Timeout: {exc}'
|
||||
except requests.exceptions.RequestException as exc:
|
||||
error = str(exc)
|
||||
return {
|
||||
'ok': False,
|
||||
'message': 'Nie udało się pobrać danych z API CEIDG. Sprawdź konfigurację połączenia i API KEY w panelu admina.',
|
||||
'error': error,
|
||||
'fallback': {'tax_id': nip},
|
||||
}
|
||||
|
||||
def diagnostics(self) -> dict:
|
||||
environment = self.get_environment()
|
||||
api_url = self.get_api_url(environment)
|
||||
if not self.has_api_key():
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': 'Brak API KEY do CEIDG.',
|
||||
'environment': environment,
|
||||
'url': api_url,
|
||||
'sample': {'error': 'missing_api_key'},
|
||||
'technical_details': None,
|
||||
'token_length': 0,
|
||||
}
|
||||
try:
|
||||
headers = self._headers()
|
||||
response = requests.get(api_url, headers=headers, params={'nip': '3563457932'}, timeout=self.DEFAULT_TIMEOUT)
|
||||
payload = self._safe_payload(response)
|
||||
status = 'ok' if response.status_code not in {401, 403} and response.status_code < 500 else 'error'
|
||||
technical_details = None
|
||||
if response.status_code in {401, 403}:
|
||||
technical_details = 'Autoryzacja odrzucona przez CEIDG.'
|
||||
elif response.status_code in {400, 404, 422}:
|
||||
technical_details = 'Połączenie działa, ale zapytanie diagnostyczne nie zwróciło danych testowych.'
|
||||
return {
|
||||
'status': status,
|
||||
'message': f'HTTP {response.status_code}',
|
||||
'environment': environment,
|
||||
'url': api_url,
|
||||
'sample': payload,
|
||||
'technical_details': technical_details,
|
||||
'token_length': len(self.get_api_key()),
|
||||
'authorization_preview': headers.get('Authorization', '')[:20],
|
||||
}
|
||||
except Exception as exc:
|
||||
message = f'Timeout: {exc}' if isinstance(exc, requests.exceptions.Timeout) else str(exc)
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': message,
|
||||
'environment': environment,
|
||||
'url': api_url,
|
||||
'sample': {'error': str(exc)},
|
||||
'technical_details': None,
|
||||
'token_length': len(self.get_api_key()),
|
||||
}
|
||||
|
||||
def _parse_payload(self, payload: str, nip: str) -> dict | None:
|
||||
try:
|
||||
obj = json.loads(payload)
|
||||
except Exception:
|
||||
return None
|
||||
found = self._walk_candidate(obj, nip)
|
||||
if not found:
|
||||
return None
|
||||
found = self._normalize_empty(found)
|
||||
found['tax_id'] = nip
|
||||
found['name'] = self._clean_text(found.get('name'))
|
||||
found['regon'] = self._digits(found.get('regon'))
|
||||
found['address'] = self._clean_text(found.get('address'))
|
||||
found['phone'] = self._clean_text(found.get('phone'))
|
||||
found['email'] = self._clean_text(found.get('email'))
|
||||
if not found['phone']:
|
||||
found.pop('phone', None)
|
||||
if not found['email']:
|
||||
found.pop('email', None)
|
||||
return found if found['name'] else None
|
||||
|
||||
def _walk_candidate(self, candidate: Any, nip: str) -> dict | None:
|
||||
if isinstance(candidate, dict):
|
||||
candidate_nip = self._extract_nip(candidate)
|
||||
if candidate_nip == nip:
|
||||
return {
|
||||
'name': self._extract_name(candidate),
|
||||
'regon': self._extract_regon(candidate),
|
||||
'address': self._compose_address(candidate),
|
||||
'phone': self._extract_phone(candidate),
|
||||
'email': self._extract_email(candidate),
|
||||
}
|
||||
for value in candidate.values():
|
||||
nested = self._walk_candidate(value, nip)
|
||||
if nested:
|
||||
return nested
|
||||
elif isinstance(candidate, list):
|
||||
for item in candidate:
|
||||
nested = self._walk_candidate(item, nip)
|
||||
if nested:
|
||||
return nested
|
||||
return None
|
||||
|
||||
def _extract_nip(self, candidate: dict) -> str:
|
||||
owner = candidate.get('wlasciciel') or candidate.get('owner') or {}
|
||||
values = [
|
||||
candidate.get('nip'),
|
||||
candidate.get('Nip'),
|
||||
candidate.get('taxId'),
|
||||
candidate.get('tax_id'),
|
||||
candidate.get('NIP'),
|
||||
owner.get('nip') if isinstance(owner, dict) else '',
|
||||
]
|
||||
for value in values:
|
||||
digits = self._digits(value)
|
||||
if digits:
|
||||
return digits
|
||||
return ''
|
||||
|
||||
def _extract_name(self, candidate: dict) -> str:
|
||||
owner = candidate.get('wlasciciel') or candidate.get('owner') or {}
|
||||
values = [
|
||||
candidate.get('firma'),
|
||||
candidate.get('nazwa'),
|
||||
candidate.get('name'),
|
||||
candidate.get('nazwaFirmy'),
|
||||
candidate.get('przedsiebiorca'),
|
||||
candidate.get('entrepreneurName'),
|
||||
owner.get('nazwa') if isinstance(owner, dict) else '',
|
||||
owner.get('nazwaSkrocona') if isinstance(owner, dict) else '',
|
||||
owner.get('imieNazwisko') if isinstance(owner, dict) else '',
|
||||
]
|
||||
for value in values:
|
||||
cleaned = self._clean_text(value)
|
||||
if cleaned:
|
||||
return cleaned
|
||||
return ''
|
||||
|
||||
def _extract_regon(self, candidate: dict) -> str:
|
||||
owner = candidate.get('wlasciciel') or candidate.get('owner') or {}
|
||||
values = [
|
||||
candidate.get('regon'),
|
||||
candidate.get('REGON'),
|
||||
candidate.get('regon9'),
|
||||
owner.get('regon') if isinstance(owner, dict) else '',
|
||||
]
|
||||
for value in values:
|
||||
digits = self._digits(value)
|
||||
if digits:
|
||||
return digits
|
||||
return ''
|
||||
|
||||
def _extract_phone(self, candidate: dict) -> str:
|
||||
owner = candidate.get('wlasciciel') or candidate.get('owner') or {}
|
||||
contact = candidate.get('kontakt') or candidate.get('contact') or {}
|
||||
values = [
|
||||
candidate.get('telefon'),
|
||||
candidate.get('telefonKontaktowy'),
|
||||
candidate.get('phone'),
|
||||
candidate.get('phoneNumber'),
|
||||
contact.get('telefon') if isinstance(contact, dict) else '',
|
||||
contact.get('phone') if isinstance(contact, dict) else '',
|
||||
owner.get('telefon') if isinstance(owner, dict) else '',
|
||||
owner.get('phone') if isinstance(owner, dict) else '',
|
||||
]
|
||||
for value in values:
|
||||
cleaned = self._clean_text(value)
|
||||
if cleaned:
|
||||
return cleaned
|
||||
return ''
|
||||
|
||||
def _extract_email(self, candidate: dict) -> str:
|
||||
owner = candidate.get('wlasciciel') or candidate.get('owner') or {}
|
||||
contact = candidate.get('kontakt') or candidate.get('contact') or {}
|
||||
values = [
|
||||
candidate.get('email'),
|
||||
candidate.get('adresEmail'),
|
||||
candidate.get('emailAddress'),
|
||||
contact.get('email') if isinstance(contact, dict) else '',
|
||||
owner.get('email') if isinstance(owner, dict) else '',
|
||||
]
|
||||
for value in values:
|
||||
cleaned = self._clean_text(value)
|
||||
if cleaned:
|
||||
return cleaned
|
||||
return ''
|
||||
|
||||
def _compose_address(self, candidate: dict) -> str:
|
||||
address = (
|
||||
candidate.get('adresDzialalnosci')
|
||||
or candidate.get('adresGlownegoMiejscaWykonywaniaDzialalnosci')
|
||||
or candidate.get('adres')
|
||||
or candidate.get('address')
|
||||
or {}
|
||||
)
|
||||
if isinstance(address, str):
|
||||
return self._clean_text(address)
|
||||
if isinstance(address, dict):
|
||||
street = self._clean_text(address.get('ulica') or address.get('street'))
|
||||
building = self._clean_text(address.get('budynek') or address.get('numerNieruchomosci') or address.get('buildingNumber'))
|
||||
apartment = self._clean_text(address.get('lokal') or address.get('numerLokalu') or address.get('apartmentNumber'))
|
||||
postal_code = self._clean_text(address.get('kod') or address.get('kodPocztowy') or address.get('postalCode'))
|
||||
city = self._clean_text(address.get('miasto') or address.get('miejscowosc') or address.get('city'))
|
||||
parts = []
|
||||
street_part = ' '.join([part for part in [street, building] if part]).strip()
|
||||
if apartment:
|
||||
street_part = f'{street_part}/{apartment}' if street_part else apartment
|
||||
if street_part:
|
||||
parts.append(street_part)
|
||||
city_part = ' '.join([part for part in [postal_code, city] if part]).strip()
|
||||
if city_part:
|
||||
parts.append(city_part)
|
||||
return ', '.join(parts)
|
||||
values = [
|
||||
candidate.get('ulica'),
|
||||
candidate.get('numerNieruchomosci'),
|
||||
candidate.get('numerLokalu'),
|
||||
candidate.get('kodPocztowy'),
|
||||
candidate.get('miejscowosc'),
|
||||
]
|
||||
return ', '.join([self._clean_text(v) for v in values if self._clean_text(v)])
|
||||
56
app/services/company_service.py
Normal file
56
app/services/company_service.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from flask import session
|
||||
from flask_login import current_user
|
||||
from app.extensions import db
|
||||
from app.models.company import Company, UserCompanyAccess
|
||||
|
||||
|
||||
class CompanyService:
|
||||
@staticmethod
|
||||
def available_for_user(user=None):
|
||||
user = user or current_user
|
||||
if not getattr(user, 'is_authenticated', False):
|
||||
return []
|
||||
return user.companies()
|
||||
|
||||
@staticmethod
|
||||
def get_current_company(user=None):
|
||||
user = user or current_user
|
||||
if not getattr(user, 'is_authenticated', False):
|
||||
return None
|
||||
company_id = session.get('current_company_id')
|
||||
if company_id and user.can_access_company(company_id):
|
||||
return db.session.get(Company, company_id)
|
||||
companies = user.companies()
|
||||
if companies:
|
||||
session['current_company_id'] = companies[0].id
|
||||
return companies[0]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def switch_company(company_id, user=None):
|
||||
user = user or current_user
|
||||
if getattr(user, 'role', None) == 'admin' or user.can_access_company(company_id):
|
||||
session['current_company_id'] = company_id
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def create_company(name, tax_id='', regon='', address='', sync_enabled=False, sync_interval_minutes=60):
|
||||
company = Company(name=name, tax_id=tax_id, regon=regon, address=address, sync_enabled=sync_enabled, sync_interval_minutes=sync_interval_minutes)
|
||||
db.session.add(company)
|
||||
db.session.commit()
|
||||
return company
|
||||
|
||||
@staticmethod
|
||||
def assign_user(user, company, access_level='full', switch_after=False):
|
||||
existing = UserCompanyAccess.query.filter_by(user_id=user.id, company_id=company.id).first()
|
||||
if existing:
|
||||
existing.access_level = access_level
|
||||
else:
|
||||
db.session.add(UserCompanyAccess(user_id=user.id, company_id=company.id, access_level=access_level))
|
||||
db.session.commit()
|
||||
if switch_after:
|
||||
try:
|
||||
session['current_company_id'] = company.id
|
||||
except RuntimeError:
|
||||
pass
|
||||
71
app/services/health_service.py
Normal file
71
app/services/health_service.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
from app.extensions import db
|
||||
from app.services.company_service import CompanyService
|
||||
from app.services.ksef_service import KSeFService
|
||||
from app.services.settings_service import SettingsService
|
||||
from app.services.ceidg_service import CeidgService
|
||||
from app.services.redis_service import RedisService
|
||||
|
||||
|
||||
class HealthService:
|
||||
CACHE_TTL = 300
|
||||
|
||||
def cache_key(self, company_id):
|
||||
return f'health.status.company.{company_id or "global"}'
|
||||
|
||||
def get_cached_status(self, company_id=None):
|
||||
return RedisService.get_json(self.cache_key(company_id))
|
||||
|
||||
def warm_cache(self, company_id=None):
|
||||
status = self.get_status(force_refresh=True, company_id=company_id)
|
||||
RedisService.set_json(self.cache_key(company_id), status, ttl=self.CACHE_TTL)
|
||||
return status
|
||||
|
||||
def get_status(self, force_refresh: bool = False, company_id=None):
|
||||
if not force_refresh:
|
||||
cached = self.get_cached_status(company_id)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
company = CompanyService.get_current_company()
|
||||
if company_id is None and company:
|
||||
company_id = company.id
|
||||
redis_status, redis_details = RedisService.ping()
|
||||
|
||||
status = {
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'db': 'ok',
|
||||
'smtp': 'configured' if SettingsService.get_effective('mail.server', company_id=company_id) else 'not_configured',
|
||||
'redis': redis_status,
|
||||
'redis_details': redis_details,
|
||||
'ksef': 'unknown',
|
||||
'ceidg': 'unknown',
|
||||
'ksef_message': '',
|
||||
'ceidg_message': '',
|
||||
}
|
||||
|
||||
try:
|
||||
db.session.execute(text('SELECT 1'))
|
||||
except Exception:
|
||||
status['db'] = 'error'
|
||||
|
||||
try:
|
||||
ping = KSeFService(company_id=company_id).ping()
|
||||
status['ksef'] = ping.get('status', 'unknown')
|
||||
status['ksef_message'] = ping.get('message', '')
|
||||
except Exception as exc:
|
||||
status['ksef'] = 'error'
|
||||
status['ksef_message'] = str(exc)
|
||||
|
||||
try:
|
||||
ping = CeidgService().diagnostics()
|
||||
status['ceidg'] = ping.get('status', 'unknown')
|
||||
status['ceidg_message'] = ping.get('message', '')
|
||||
except Exception as exc:
|
||||
status['ceidg'] = 'error'
|
||||
status['ceidg_message'] = str(exc)
|
||||
|
||||
status['critical'] = status['db'] != 'ok' or status['ksef'] not in ['ok', 'mock'] or status['ceidg'] != 'ok'
|
||||
RedisService.set_json(self.cache_key(company_id), status, ttl=self.CACHE_TTL)
|
||||
return status
|
||||
73
app/services/invoice_party_service.py
Normal file
73
app/services/invoice_party_service.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
class InvoicePartyService:
|
||||
|
||||
@staticmethod
|
||||
def extract_address_from_ksef_xml(xml_content: str):
|
||||
result = {
|
||||
'street': None,
|
||||
'city': None,
|
||||
'postal_code': None,
|
||||
'country': None,
|
||||
'address': None,
|
||||
}
|
||||
|
||||
if not xml_content:
|
||||
return result
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_content)
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
def local_name(tag: str) -> str:
|
||||
return tag.split('}', 1)[1] if '}' in tag else tag
|
||||
|
||||
def clean(value):
|
||||
return (value or '').strip()
|
||||
|
||||
def find_text(names: list[str]):
|
||||
wanted = {name.lower() for name in names}
|
||||
for node in root.iter():
|
||||
if local_name(node.tag).lower() in wanted:
|
||||
text = clean(node.text)
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
street = find_text(['Ulica', 'AdresL1'])
|
||||
house_no = find_text(['NrDomu'])
|
||||
apartment_no = find_text(['NrLokalu'])
|
||||
city = find_text(['Miejscowosc'])
|
||||
postal_code = find_text(['KodPocztowy'])
|
||||
country = find_text(['Kraj', 'KodKraju'])
|
||||
adres_l2 = find_text(['AdresL2'])
|
||||
|
||||
if adres_l2 and (not postal_code or not city):
|
||||
match = re.match(r'^(\d{2}-\d{3})\s+(.+)$', clean(adres_l2))
|
||||
if match:
|
||||
postal_code = postal_code or match.group(1)
|
||||
city = city or clean(match.group(2))
|
||||
elif not city:
|
||||
city = clean(adres_l2)
|
||||
|
||||
street_line = clean(street)
|
||||
if not street_line:
|
||||
street_parts = [part for part in [street, house_no] if part]
|
||||
street_line = ' '.join([p for p in street_parts if p]).strip()
|
||||
if apartment_no:
|
||||
street_line = f'{street_line}/{apartment_no}' if street_line else apartment_no
|
||||
|
||||
address_parts = [part for part in [street_line, postal_code, city, country] if part]
|
||||
|
||||
result.update({
|
||||
'street': street_line or None,
|
||||
'city': city,
|
||||
'postal_code': postal_code,
|
||||
'country': country,
|
||||
'address': ', '.join(address_parts) if address_parts else None,
|
||||
})
|
||||
|
||||
return result
|
||||
949
app/services/invoice_service.py
Normal file
949
app/services/invoice_service.py
Normal file
@@ -0,0 +1,949 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.catalog import InvoiceLine
|
||||
from app.models.invoice import Invoice, InvoiceStatus, InvoiceType, Tag, SyncEvent
|
||||
from app.repositories.invoice_repository import InvoiceRepository
|
||||
from app.services.company_service import CompanyService
|
||||
from app.services.ksef_service import KSeFService
|
||||
from app.services.settings_service import SettingsService
|
||||
|
||||
|
||||
class InvoiceService:
|
||||
PERIOD_LABELS = {
|
||||
"month": "miesięczne",
|
||||
"quarter": "kwartalne",
|
||||
"year": "roczne",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.repo = InvoiceRepository()
|
||||
|
||||
def upsert_from_ksef(self, document, company):
|
||||
invoice = self.repo.get_by_ksef_number(document.ksef_number, company_id=company.id)
|
||||
created = False
|
||||
if not invoice:
|
||||
invoice = Invoice(ksef_number=document.ksef_number, company_id=company.id)
|
||||
db.session.add(invoice)
|
||||
created = True
|
||||
|
||||
invoice.invoice_number = document.invoice_number
|
||||
invoice.contractor_name = document.contractor_name
|
||||
invoice.contractor_nip = document.contractor_nip
|
||||
invoice.issue_date = document.issue_date
|
||||
invoice.received_date = document.received_date
|
||||
invoice.fetched_at = document.fetched_at
|
||||
invoice.net_amount = document.net_amount
|
||||
invoice.vat_amount = document.vat_amount
|
||||
invoice.gross_amount = document.gross_amount
|
||||
invoice.invoice_type = InvoiceType(document.invoice_type)
|
||||
invoice.last_synced_at = datetime.utcnow()
|
||||
invoice.external_metadata = document.metadata
|
||||
invoice.source_hash = KSeFService.calc_hash(document.xml_content)
|
||||
invoice.xml_path = self._save_xml(company.id, invoice.ksef_number, document.xml_content)
|
||||
metadata = dict(document.metadata or {})
|
||||
payment_details = self.extract_payment_details_from_xml(document.xml_content)
|
||||
if payment_details.get('payment_form_code') and not metadata.get('payment_form_code'):
|
||||
metadata['payment_form_code'] = payment_details['payment_form_code']
|
||||
if payment_details.get('payment_form_label') and not metadata.get('payment_form_label'):
|
||||
metadata['payment_form_label'] = payment_details['payment_form_label']
|
||||
if payment_details.get('bank_account') and not metadata.get('seller_bank_account'):
|
||||
metadata['seller_bank_account'] = payment_details['bank_account']
|
||||
if payment_details.get('bank_name') and not metadata.get('seller_bank_name'):
|
||||
metadata['seller_bank_name'] = payment_details['bank_name']
|
||||
if payment_details.get('payment_due_date') and not metadata.get('payment_due_date'):
|
||||
metadata['payment_due_date'] = payment_details['payment_due_date']
|
||||
invoice.external_metadata = metadata
|
||||
invoice.seller_bank_account = payment_details.get('bank_account') or metadata.get('seller_bank_account') or invoice.seller_bank_account
|
||||
invoice.contractor_address = (
|
||||
metadata.get("contractor_address")
|
||||
or ", ".join([
|
||||
part for part in [
|
||||
metadata.get("contractor_street"),
|
||||
metadata.get("contractor_postal_code"),
|
||||
metadata.get("contractor_city"),
|
||||
metadata.get("contractor_country"),
|
||||
] if part
|
||||
])
|
||||
or ""
|
||||
)
|
||||
|
||||
db.session.flush()
|
||||
|
||||
existing_lines = invoice.lines.count() if hasattr(invoice.lines, "count") else len(list(invoice.lines))
|
||||
if existing_lines == 0:
|
||||
for line in self.extract_lines_from_xml(document.xml_content):
|
||||
db.session.add(
|
||||
InvoiceLine(
|
||||
invoice_id=invoice.id,
|
||||
description=line["description"],
|
||||
quantity=line["quantity"],
|
||||
unit=line["unit"],
|
||||
unit_net=line["unit_net"],
|
||||
vat_rate=line["vat_rate"],
|
||||
net_amount=line["net_amount"],
|
||||
vat_amount=line["vat_amount"],
|
||||
gross_amount=line["gross_amount"],
|
||||
)
|
||||
)
|
||||
|
||||
if not invoice.html_preview:
|
||||
invoice.html_preview = self.render_invoice_html(invoice)
|
||||
|
||||
db.session.flush()
|
||||
db.session.add(
|
||||
SyncEvent(
|
||||
invoice_id=invoice.id,
|
||||
status="created" if created else "updated",
|
||||
message="Pobrano z KSeF",
|
||||
)
|
||||
)
|
||||
SettingsService.set_many({"ksef.last_sync_at": datetime.utcnow().isoformat()}, company_id=company.id)
|
||||
return invoice, created
|
||||
|
||||
def _save_xml(self, company_id, ksef_number, xml_content):
|
||||
base_path = SettingsService.storage_path("app.archive_path", current_app.config["ARCHIVE_PATH"]) / f"company_{company_id}"
|
||||
base_path.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = ksef_number.replace("/", "_") + ".xml"
|
||||
path = Path(base_path) / safe_name
|
||||
path.write_text(xml_content, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
def extract_lines_from_xml(self, xml_content):
|
||||
def to_decimal(value, default='0'):
|
||||
raw = str(value or '').strip()
|
||||
if not raw:
|
||||
return Decimal(default)
|
||||
raw = raw.replace(' ', '').replace(',', '.')
|
||||
try:
|
||||
return Decimal(raw)
|
||||
except Exception:
|
||||
return Decimal(default)
|
||||
|
||||
def to_vat_rate(value):
|
||||
raw = str(value or '').strip().lower()
|
||||
if not raw:
|
||||
return Decimal('0')
|
||||
raw = raw.replace('%', '').replace(' ', '').replace(',', '.')
|
||||
try:
|
||||
return Decimal(raw)
|
||||
except Exception:
|
||||
return Decimal('0')
|
||||
|
||||
def looks_numeric(value):
|
||||
raw = str(value or '').strip().replace(' ', '').replace(',', '.')
|
||||
if not raw:
|
||||
return False
|
||||
try:
|
||||
Decimal(raw)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
lines = []
|
||||
try:
|
||||
root = ET.fromstring(xml_content)
|
||||
namespace_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else ''
|
||||
ns = {'fa': namespace_uri} if namespace_uri else {}
|
||||
|
||||
row_path = './/fa:FaWiersz' if ns else './/FaWiersz'
|
||||
text_path = lambda name: f'fa:{name}' if ns else name
|
||||
|
||||
for row in root.findall(row_path, ns):
|
||||
description = (row.findtext(text_path('P_7'), default='', namespaces=ns) or '').strip()
|
||||
|
||||
p_8a = row.findtext(text_path('P_8A'), default='', namespaces=ns)
|
||||
p_8b = row.findtext(text_path('P_8B'), default='', namespaces=ns)
|
||||
|
||||
if looks_numeric(p_8a):
|
||||
qty_raw = p_8a
|
||||
unit_raw = p_8b or 'szt.'
|
||||
else:
|
||||
unit_raw = p_8a or 'szt.'
|
||||
qty_raw = p_8b or '1'
|
||||
|
||||
unit_net = (
|
||||
row.findtext(text_path('P_9A'), default='', namespaces=ns)
|
||||
or row.findtext(text_path('P_9B'), default='', namespaces=ns)
|
||||
or '0'
|
||||
)
|
||||
|
||||
net = (
|
||||
row.findtext(text_path('P_11'), default='', namespaces=ns)
|
||||
or row.findtext(text_path('P_11A'), default='', namespaces=ns)
|
||||
or '0'
|
||||
)
|
||||
|
||||
vat_rate = row.findtext(text_path('P_12'), default='0', namespaces=ns)
|
||||
|
||||
vat = (
|
||||
row.findtext(text_path('P_12Z'), default='', namespaces=ns)
|
||||
or row.findtext(text_path('P_11Vat'), default='', namespaces=ns)
|
||||
or '0'
|
||||
)
|
||||
|
||||
net_dec = to_decimal(net)
|
||||
vat_dec = to_decimal(vat)
|
||||
|
||||
lines.append({
|
||||
'description': description,
|
||||
'quantity': to_decimal(qty_raw, '1'),
|
||||
'unit': (unit_raw or 'szt.').strip(),
|
||||
'unit_net': to_decimal(unit_net),
|
||||
'vat_rate': to_vat_rate(vat_rate),
|
||||
'net_amount': net_dec,
|
||||
'vat_amount': vat_dec,
|
||||
'gross_amount': net_dec + vat_dec,
|
||||
})
|
||||
|
||||
except Exception as exc:
|
||||
current_app.logger.warning(f'KSeF XML line parse error: {exc}')
|
||||
|
||||
return lines
|
||||
|
||||
def extract_payment_details_from_xml(self, xml_content):
|
||||
details = {
|
||||
"payment_form_code": "",
|
||||
"payment_form_label": "",
|
||||
"bank_account": "",
|
||||
"bank_name": "",
|
||||
"payment_due_date": "",
|
||||
}
|
||||
if not xml_content:
|
||||
return details
|
||||
try:
|
||||
root = ET.fromstring(xml_content)
|
||||
namespace_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else ''
|
||||
ns = {'fa': namespace_uri} if namespace_uri else {}
|
||||
|
||||
def find_text(path):
|
||||
return (root.findtext(path, default='', namespaces=ns) or '').strip()
|
||||
|
||||
form_code = find_text('.//fa:Platnosc/fa:FormaPlatnosci' if ns else './/Platnosc/FormaPlatnosci')
|
||||
bank_account = find_text('.//fa:Platnosc/fa:RachunekBankowy/fa:NrRB' if ns else './/Platnosc/RachunekBankowy/NrRB')
|
||||
bank_name = find_text('.//fa:Platnosc/fa:RachunekBankowy/fa:NazwaBanku' if ns else './/Platnosc/RachunekBankowy/NazwaBanku')
|
||||
payment_due_date = find_text('.//fa:Platnosc/fa:TerminPlatnosci/fa:Termin' if ns else './/Platnosc/TerminPlatnosci/Termin')
|
||||
|
||||
form_labels = {
|
||||
'1': 'gotówka',
|
||||
'2': 'karta',
|
||||
'3': 'bon',
|
||||
'4': 'czek',
|
||||
'5': 'weksel',
|
||||
'6': 'przelew',
|
||||
'7': 'kompensata',
|
||||
'8': 'pobranie',
|
||||
'9': 'akredytywa',
|
||||
'10': 'polecenie zapłaty',
|
||||
'11': 'inny',
|
||||
}
|
||||
details.update({
|
||||
'payment_form_code': form_code,
|
||||
'payment_form_label': form_labels.get(form_code, form_code),
|
||||
'bank_account': self._normalize_bank_account(bank_account),
|
||||
'bank_name': bank_name,
|
||||
'payment_due_date': payment_due_date,
|
||||
})
|
||||
except Exception as exc:
|
||||
current_app.logger.warning(f'KSeF XML payment parse error: {exc}')
|
||||
return details
|
||||
|
||||
@staticmethod
|
||||
def _first_non_empty(*values, default=""):
|
||||
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
if value.strip():
|
||||
return value.strip()
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _normalize_metadata_container(value):
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
def _get_external_metadata(self, invoice):
|
||||
return self._normalize_metadata_container(getattr(invoice, "external_metadata", {}) or {})
|
||||
|
||||
def _get_ksef_metadata(self, invoice):
|
||||
external_metadata = self._get_external_metadata(invoice)
|
||||
return self._normalize_metadata_container(external_metadata.get("ksef", {}) or {})
|
||||
|
||||
def _resolve_purchase_seller_data(self, invoice):
|
||||
external_metadata = self._get_external_metadata(invoice)
|
||||
ksef_meta = self._get_ksef_metadata(invoice)
|
||||
|
||||
seller_name = self._first_non_empty(
|
||||
getattr(invoice, "contractor_name", ""),
|
||||
external_metadata.get("contractor_name"),
|
||||
ksef_meta.get("contractor_name"),
|
||||
)
|
||||
seller_tax_id = self._first_non_empty(
|
||||
getattr(invoice, "contractor_nip", ""),
|
||||
external_metadata.get("contractor_nip"),
|
||||
ksef_meta.get("contractor_nip"),
|
||||
)
|
||||
|
||||
seller_street = self._first_non_empty(
|
||||
external_metadata.get("contractor_street"),
|
||||
ksef_meta.get("contractor_street"),
|
||||
external_metadata.get("seller_street"),
|
||||
ksef_meta.get("seller_street"),
|
||||
)
|
||||
seller_city = self._first_non_empty(
|
||||
external_metadata.get("contractor_city"),
|
||||
ksef_meta.get("contractor_city"),
|
||||
external_metadata.get("seller_city"),
|
||||
ksef_meta.get("seller_city"),
|
||||
)
|
||||
seller_postal_code = self._first_non_empty(
|
||||
external_metadata.get("contractor_postal_code"),
|
||||
ksef_meta.get("contractor_postal_code"),
|
||||
external_metadata.get("seller_postal_code"),
|
||||
ksef_meta.get("seller_postal_code"),
|
||||
)
|
||||
seller_country = self._first_non_empty(
|
||||
external_metadata.get("contractor_country"),
|
||||
ksef_meta.get("contractor_country"),
|
||||
external_metadata.get("seller_country"),
|
||||
ksef_meta.get("seller_country"),
|
||||
)
|
||||
|
||||
seller_address = self._first_non_empty(
|
||||
external_metadata.get("contractor_address"),
|
||||
ksef_meta.get("contractor_address"),
|
||||
external_metadata.get("seller_address"),
|
||||
ksef_meta.get("seller_address"),
|
||||
getattr(invoice, "contractor_address", ""),
|
||||
)
|
||||
|
||||
if not seller_address:
|
||||
address_parts = [part for part in [seller_street, seller_postal_code, seller_city, seller_country] if part]
|
||||
seller_address = ", ".join(address_parts)
|
||||
|
||||
return {
|
||||
"name": seller_name,
|
||||
"tax_id": seller_tax_id,
|
||||
"address": seller_address,
|
||||
}
|
||||
|
||||
def update_metadata(self, invoice, form):
|
||||
invoice.status = InvoiceStatus(form.status.data)
|
||||
invoice.internal_note = form.internal_note.data
|
||||
invoice.pinned = bool(form.pinned.data)
|
||||
invoice.queue_accounting = bool(form.queue_accounting.data)
|
||||
invoice.is_unread = invoice.status == InvoiceStatus.NEW
|
||||
requested_tags = [t.strip() for t in (form.tags.data or "").split(",") if t.strip()]
|
||||
invoice.tags.clear()
|
||||
for name in requested_tags:
|
||||
tag = Tag.query.filter_by(name=name).first()
|
||||
if not tag:
|
||||
tag = Tag(name=name, color="primary")
|
||||
db.session.add(tag)
|
||||
invoice.tags.append(tag)
|
||||
db.session.commit()
|
||||
return invoice
|
||||
|
||||
def mark_read(self, invoice):
|
||||
if invoice.is_unread:
|
||||
invoice.is_unread = False
|
||||
invoice.status = InvoiceStatus.READ if invoice.status == InvoiceStatus.NEW else invoice.status
|
||||
invoice.read_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def render_invoice_html(self, invoice):
|
||||
def esc(value):
|
||||
return str(value or "—").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
def money(value):
|
||||
return f"{Decimal(value):,.2f} {invoice.currency or 'PLN'}".replace(",", " ").replace(".", ",")
|
||||
|
||||
company_name_raw = invoice.company.name if invoice.company else "Twoja firma"
|
||||
company_name = esc(company_name_raw)
|
||||
company_tax_id = esc(getattr(invoice.company, "tax_id", "") if invoice.company else "")
|
||||
company_address = esc(getattr(invoice.company, "address", "") if invoice.company else "")
|
||||
|
||||
customer = getattr(invoice, "customer", None)
|
||||
customer_name = esc(getattr(customer, "name", invoice.contractor_name))
|
||||
customer_tax_id = esc(getattr(customer, "tax_id", invoice.contractor_nip))
|
||||
customer_address = esc(getattr(customer, "address", ""))
|
||||
customer_email = esc(getattr(customer, "email", ""))
|
||||
|
||||
if invoice.invoice_type == InvoiceType.PURCHASE:
|
||||
purchase_seller = self._resolve_purchase_seller_data(invoice)
|
||||
|
||||
seller_name = esc(purchase_seller["name"])
|
||||
seller_tax_id = esc(purchase_seller["tax_id"])
|
||||
seller_address = esc(purchase_seller["address"])
|
||||
|
||||
buyer_name = company_name
|
||||
buyer_tax_id = company_tax_id
|
||||
buyer_address = company_address
|
||||
buyer_email = ""
|
||||
else:
|
||||
seller_name = company_name
|
||||
seller_tax_id = company_tax_id
|
||||
seller_address = company_address
|
||||
|
||||
buyer_name = customer_name
|
||||
buyer_tax_id = customer_tax_id
|
||||
buyer_address = customer_address
|
||||
buyer_email = customer_email
|
||||
|
||||
nfz_meta = (invoice.external_metadata or {}).get("nfz", {})
|
||||
invoice_kind = "FAKTURA NFZ" if invoice.source == "nfz" else "FAKTURA VAT"
|
||||
|
||||
page_title = self._first_non_empty(
|
||||
getattr(invoice, "invoice_number", ""),
|
||||
getattr(invoice, "ksef_number", ""),
|
||||
company_name_raw,
|
||||
"Faktura",
|
||||
)
|
||||
|
||||
lines = invoice.lines.order_by("id").all() if hasattr(invoice.lines, "order_by") else list(invoice.lines)
|
||||
if not lines and invoice.xml_path:
|
||||
try:
|
||||
xml_content = Path(invoice.xml_path).read_text(encoding="utf-8")
|
||||
extracted = self.extract_lines_from_xml(xml_content)
|
||||
lines = [type("TmpLine", (), line) for line in extracted]
|
||||
except Exception:
|
||||
lines = []
|
||||
|
||||
lines_html = "".join(
|
||||
[
|
||||
(
|
||||
f"<tr>"
|
||||
f"<td style='padding:8px;border:1px solid #000'>{esc(line.description)}</td>"
|
||||
f"<td style='padding:8px;border:1px solid #000;text-align:right'>{esc(line.quantity)}</td>"
|
||||
f"<td style='padding:8px;border:1px solid #000;text-align:right'>{esc(line.unit)}</td>"
|
||||
f"<td style='padding:8px;border:1px solid #000;text-align:right'>{esc(line.vat_rate)}%</td>"
|
||||
f"<td style='padding:8px;border:1px solid #000;text-align:right'>{money(line.net_amount)}</td>"
|
||||
f"<td style='padding:8px;border:1px solid #000;text-align:right'>{money(line.gross_amount)}</td>"
|
||||
f"</tr>"
|
||||
)
|
||||
for line in lines
|
||||
]
|
||||
) or (
|
||||
"<tr>"
|
||||
"<td colspan='6' style='padding:12px;border:1px solid #000;text-align:center'>Brak pozycji na fakturze.</td>"
|
||||
"</tr>"
|
||||
)
|
||||
|
||||
nfz_rows = ""
|
||||
if nfz_meta:
|
||||
nfz_pairs = [
|
||||
("Oddział NFZ (IDWew)", nfz_meta.get("recipient_branch_id")),
|
||||
("Nazwa oddziału", nfz_meta.get("recipient_branch_name")),
|
||||
("Okres rozliczeniowy od", nfz_meta.get("settlement_from")),
|
||||
("Okres rozliczeniowy do", nfz_meta.get("settlement_to")),
|
||||
("Identyfikator świadczeniodawcy", nfz_meta.get("provider_identifier")),
|
||||
("Kod zakresu / świadczenia", nfz_meta.get("service_code")),
|
||||
("Numer umowy / aneksu", nfz_meta.get("contract_number")),
|
||||
("Identyfikator szablonu", nfz_meta.get("template_identifier")),
|
||||
("Schemat", nfz_meta.get("nfz_schema", "FA(3)")),
|
||||
]
|
||||
nfz_rows = "".join(
|
||||
[
|
||||
f"<tr><td style='padding:8px;border:1px solid #000;width:38%'>{esc(label)}</td><td style='padding:8px;border:1px solid #000'>{esc(value)}</td></tr>"
|
||||
for label, value in nfz_pairs
|
||||
]
|
||||
)
|
||||
nfz_rows = (
|
||||
"<div style='margin:18px 0 10px;font-weight:700'>Dane NFZ</div>"
|
||||
"<table style='width:100%;border-collapse:collapse;margin-bottom:18px'>"
|
||||
"<thead><tr><th style='padding:8px;border:1px solid #000;text-align:left'>Pole NFZ</th><th style='padding:8px;border:1px solid #000;text-align:left'>Wartość</th></tr></thead>"
|
||||
f"<tbody>{nfz_rows}</tbody></table>"
|
||||
)
|
||||
|
||||
buyer_email_html = f"<br>E-mail: {buyer_email}" if buyer_email not in {"", "—"} else ""
|
||||
split_payment_html = "<div style='margin:12px 0;padding:10px;border:1px solid #000;font-weight:700'>Mechanizm podzielonej płatności</div>" if getattr(invoice, 'split_payment', False) else ""
|
||||
payment_details = self.resolve_payment_details(invoice)
|
||||
seller_bank_account = esc(self._resolve_seller_bank_account(invoice))
|
||||
payment_form_html = f"<br>Forma płatności: {esc(payment_details.get('payment_form_label'))}" if payment_details.get('payment_form_label') else ''
|
||||
seller_bank_account_html = f"<br>Rachunek bankowy: {seller_bank_account}" if seller_bank_account not in {'', '—'} else ''
|
||||
seller_bank_name_html = f"<br>Bank: {esc(payment_details.get('bank_name'))}" if payment_details.get('bank_name') else ''
|
||||
|
||||
return (
|
||||
f"<div style='font-family:Helvetica,Arial,sans-serif;color:#000;background:#fff;padding:8px'>"
|
||||
f"<div style='font-size:24px;font-weight:700;margin-bottom:10px'>{invoice_kind}</div>"
|
||||
f"<table style='width:100%;border-collapse:collapse;margin-bottom:16px'><tr>"
|
||||
f"<td style='width:50%;padding:8px;border:1px solid #000;vertical-align:top'><strong>Numer faktury:</strong> {esc(invoice.invoice_number)}<br><strong>Data wystawienia:</strong> {esc(invoice.issue_date)}<br><strong>Waluta:</strong> {esc(invoice.currency or 'PLN')}</td>"
|
||||
f"<td style='width:50%;padding:8px;border:1px solid #000;vertical-align:top'><strong>Numer KSeF:</strong> {esc(invoice.ksef_number)}<br><strong>Status:</strong> {esc(invoice.issued_status)}<br><strong>Typ źródła:</strong> {esc(invoice.source)}</td>"
|
||||
f"</tr></table>"
|
||||
f"<table style='width:100%;border-collapse:collapse;margin-bottom:16px'><tr>"
|
||||
f"<td style='width:50%;padding:8px;border:1px solid #000;vertical-align:top'><strong>Sprzedawca</strong><br>{seller_name}<br>NIP: {seller_tax_id}<br>Adres: {seller_address}{payment_form_html}{seller_bank_account_html}{seller_bank_name_html}</td>"
|
||||
f"<td style='width:50%;padding:8px;border:1px solid #000;vertical-align:top'><strong>Nabywca</strong><br>{buyer_name}<br>NIP: {buyer_tax_id}<br>Adres: {buyer_address}{buyer_email_html}</td>"
|
||||
f"</tr></table>"
|
||||
f"{nfz_rows}"
|
||||
f"{split_payment_html}"
|
||||
"<div style='margin:18px 0 10px;font-weight:700'>Pozycje faktury</div>"
|
||||
"<table style='width:100%;border-collapse:collapse'>"
|
||||
"<thead>"
|
||||
"<tr>"
|
||||
"<th style='padding:8px;border:1px solid #000;text-align:left'>Pozycja</th>"
|
||||
"<th style='padding:8px;border:1px solid #000;text-align:right'>Ilość</th>"
|
||||
"<th style='padding:8px;border:1px solid #000;text-align:right'>JM</th>"
|
||||
"<th style='padding:8px;border:1px solid #000;text-align:right'>VAT</th>"
|
||||
"<th style='padding:8px;border:1px solid #000;text-align:right'>Netto</th>"
|
||||
"<th style='padding:8px;border:1px solid #000;text-align:right'>Brutto</th>"
|
||||
"</tr>"
|
||||
"</thead>"
|
||||
f"<tbody>{lines_html}</tbody></table>"
|
||||
"<table style='width:52%;border-collapse:collapse;margin-left:auto;margin-top:16px'>"
|
||||
f"<tr><td style='padding:8px;border:1px solid #000'>Netto</td><td style='padding:8px;border:1px solid #000;text-align:right'>{money(invoice.net_amount)}</td></tr>"
|
||||
f"<tr><td style='padding:8px;border:1px solid #000'>VAT</td><td style='padding:8px;border:1px solid #000;text-align:right'>{money(invoice.vat_amount)}</td></tr>"
|
||||
f"<tr><td style='padding:8px;border:1px solid #000'><strong>Razem brutto</strong></td><td style='padding:8px;border:1px solid #000;text-align:right'><strong>{money(invoice.gross_amount)}</strong></td></tr>"
|
||||
"</table>"
|
||||
f"<div style='margin-top:16px;font-size:11px'>{'Dokument zawiera pola wymagane dla rozliczeń NFZ i został przygotowany do wysyłki w schemacie FA(3).' if nfz_meta else 'Dokument wygenerowany przez KSeF Manager.'}</div>"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
def monthly_groups(self, company_id=None):
|
||||
groups = []
|
||||
for row in self.repo.monthly_summary(company_id):
|
||||
year = int(row.year)
|
||||
month = int(row.month)
|
||||
entries = self.repo.base_query(company_id).filter(Invoice.issue_date >= datetime(year, month, 1).date())
|
||||
if month == 12:
|
||||
entries = entries.filter(Invoice.issue_date < datetime(year + 1, 1, 1).date())
|
||||
else:
|
||||
entries = entries.filter(Invoice.issue_date < datetime(year, month + 1, 1).date())
|
||||
groups.append(
|
||||
{
|
||||
"key": f"{year}-{month:02d}",
|
||||
"year": year,
|
||||
"month": month,
|
||||
"count": int(row.count or 0),
|
||||
"net": row.net or Decimal("0"),
|
||||
"vat": row.vat or Decimal("0"),
|
||||
"gross": row.gross or Decimal("0"),
|
||||
"entries": entries.order_by(Invoice.issue_date.desc(), Invoice.id.desc()).all(),
|
||||
}
|
||||
)
|
||||
return groups
|
||||
|
||||
def grouped_summary(self, company_id=None, *, period="month", search=None):
|
||||
rows = self.repo.summary_query(company_id, period=period, search=search)
|
||||
grouped_entries = defaultdict(list)
|
||||
entry_query = self.repo.base_query(company_id)
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
entry_query = entry_query.filter(
|
||||
or_(
|
||||
Invoice.invoice_number.ilike(like),
|
||||
Invoice.ksef_number.ilike(like),
|
||||
Invoice.contractor_name.ilike(like),
|
||||
Invoice.contractor_nip.ilike(like),
|
||||
)
|
||||
)
|
||||
for invoice in entry_query.order_by(Invoice.issue_date.desc(), Invoice.id.desc()).all():
|
||||
if period == "year":
|
||||
key = f"{invoice.issue_date.year}"
|
||||
elif period == "quarter":
|
||||
quarter = ((invoice.issue_date.month - 1) // 3) + 1
|
||||
key = f"{invoice.issue_date.year}-Q{quarter}"
|
||||
else:
|
||||
key = f"{invoice.issue_date.year}-{invoice.issue_date.month:02d}"
|
||||
grouped_entries[key].append(invoice)
|
||||
groups = []
|
||||
for row in rows:
|
||||
year = int(row.year)
|
||||
month = int(getattr(row, "month", 0) or 0)
|
||||
quarter = int(getattr(row, "quarter", 0) or 0)
|
||||
if period == "year":
|
||||
key = f"{year}"
|
||||
label = f"Rok {year}"
|
||||
elif period == "quarter":
|
||||
key = f"{year}-Q{quarter}"
|
||||
label = f"Q{quarter} {year}"
|
||||
else:
|
||||
key = f"{year}-{month:02d}"
|
||||
label = f"{year}-{month:02d}"
|
||||
groups.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"year": year,
|
||||
"month": month,
|
||||
"quarter": quarter,
|
||||
"count": int(row.count or 0),
|
||||
"net": row.net or Decimal("0"),
|
||||
"vat": row.vat or Decimal("0"),
|
||||
"gross": row.gross or Decimal("0"),
|
||||
"entries": grouped_entries.get(key, []),
|
||||
}
|
||||
)
|
||||
return groups
|
||||
|
||||
def comparative_stats(self, company_id=None, *, search=None):
|
||||
rows = self.repo.summary_query(company_id, period="year", search=search)
|
||||
stats = []
|
||||
previous = None
|
||||
for row in rows:
|
||||
year = int(row.year)
|
||||
gross = row.gross or Decimal("0")
|
||||
net = row.net or Decimal("0")
|
||||
count = int(row.count or 0)
|
||||
delta = None
|
||||
if previous is not None:
|
||||
delta = gross - previous["gross"]
|
||||
stats.append({"year": year, "gross": gross, "net": net, "count": count, "delta": delta})
|
||||
previous = {"gross": gross}
|
||||
return stats
|
||||
|
||||
def next_sale_number(self, company_id, template="monthly"):
|
||||
today = self.today_date()
|
||||
query = Invoice.query.filter(Invoice.company_id == company_id, Invoice.invoice_type == InvoiceType.SALE)
|
||||
if template == "yearly":
|
||||
query = query.filter(Invoice.issue_date >= date(today.year, 1, 1))
|
||||
prefix = f"FV/{today.year}/"
|
||||
elif template == "custom":
|
||||
prefix = f"FV/{today.year}/{today.month:02d}/"
|
||||
query = query.filter(Invoice.issue_date >= date(today.year, today.month, 1))
|
||||
if today.month == 12:
|
||||
query = query.filter(Invoice.issue_date < date(today.year + 1, 1, 1))
|
||||
else:
|
||||
query = query.filter(Invoice.issue_date < date(today.year, today.month + 1, 1))
|
||||
else:
|
||||
prefix = f"FV/{today.year}/{today.month:02d}/"
|
||||
query = query.filter(Invoice.issue_date >= date(today.year, today.month, 1))
|
||||
if today.month == 12:
|
||||
query = query.filter(Invoice.issue_date < date(today.year + 1, 1, 1))
|
||||
else:
|
||||
query = query.filter(Invoice.issue_date < date(today.year, today.month + 1, 1))
|
||||
next_no = query.count() + 1
|
||||
return f"{prefix}{next_no:04d}"
|
||||
|
||||
def build_ksef_payload(self, invoice):
|
||||
lines = invoice.lines.order_by("id").all() if hasattr(invoice.lines, "order_by") else list(invoice.lines)
|
||||
nfz_meta = (invoice.external_metadata or {}).get("nfz", {})
|
||||
xml_content = self.render_structured_xml(invoice, lines=lines, nfz_meta=nfz_meta)
|
||||
payload = {
|
||||
"invoiceNumber": invoice.invoice_number,
|
||||
"invoiceType": invoice.invoice_type.value if hasattr(invoice.invoice_type, "value") else str(invoice.invoice_type),
|
||||
"schemaVersion": nfz_meta.get("nfz_schema", "FA(3)"),
|
||||
"customer": {
|
||||
"name": invoice.contractor_name,
|
||||
"taxId": invoice.contractor_nip,
|
||||
},
|
||||
"lines": [
|
||||
{
|
||||
"name": line.description,
|
||||
"qty": float(line.quantity),
|
||||
"unit": line.unit,
|
||||
"unitNet": float(line.unit_net),
|
||||
"vatRate": float(line.vat_rate),
|
||||
"netAmount": float(line.net_amount),
|
||||
"grossAmount": float(line.gross_amount),
|
||||
}
|
||||
for line in lines
|
||||
],
|
||||
"metadata": {
|
||||
"source": invoice.source,
|
||||
"companyId": invoice.company_id,
|
||||
"issueDate": invoice.issue_date.isoformat() if invoice.issue_date else "",
|
||||
"currency": invoice.currency,
|
||||
"nfz": nfz_meta,
|
||||
"split_payment": bool(getattr(invoice, 'split_payment', False)),
|
||||
},
|
||||
"xml_content": xml_content,
|
||||
}
|
||||
if nfz_meta:
|
||||
payload["customer"]["internalBranchId"] = nfz_meta.get("recipient_branch_id", "")
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _xml_decimal(value, places=2):
|
||||
quant = Decimal('1').scaleb(-places)
|
||||
return format(Decimal(value or 0).quantize(quant), 'f')
|
||||
|
||||
@staticmethod
|
||||
def _split_address_lines(address):
|
||||
raw = (address or '').strip()
|
||||
if not raw:
|
||||
return '', ''
|
||||
parts = [part.strip() for part in raw.replace('\n', ',').split(',') if part.strip()]
|
||||
if len(parts) <= 1:
|
||||
return raw[:512], ''
|
||||
line1 = ', '.join(parts[:2])[:512]
|
||||
line2 = ', '.join(parts[2:])[:512]
|
||||
return line1, line2
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _normalize_bank_account(value):
|
||||
raw = ''.join(str(value or '').split())
|
||||
return raw
|
||||
|
||||
def resolve_payment_details(self, invoice):
|
||||
external_metadata = self._normalize_metadata_container(getattr(invoice, 'external_metadata', {}) or {})
|
||||
details = {
|
||||
'payment_form_code': self._first_non_empty(external_metadata.get('payment_form_code')),
|
||||
'payment_form_label': self._first_non_empty(external_metadata.get('payment_form_label')),
|
||||
'bank_account': self._normalize_bank_account(
|
||||
self._first_non_empty(
|
||||
getattr(invoice, 'seller_bank_account', ''),
|
||||
external_metadata.get('seller_bank_account'),
|
||||
)
|
||||
),
|
||||
'bank_name': self._first_non_empty(external_metadata.get('seller_bank_name')),
|
||||
'payment_due_date': self._first_non_empty(external_metadata.get('payment_due_date')),
|
||||
}
|
||||
if getattr(invoice, 'xml_path', None) and (not details['bank_account'] or not details['payment_form_code'] or not details['bank_name'] or not details['payment_due_date']):
|
||||
try:
|
||||
xml_content = Path(invoice.xml_path).read_text(encoding='utf-8')
|
||||
parsed = self.extract_payment_details_from_xml(xml_content)
|
||||
for key, value in parsed.items():
|
||||
if value and not details.get(key):
|
||||
details[key] = value
|
||||
except Exception:
|
||||
pass
|
||||
return details
|
||||
|
||||
def _resolve_seller_bank_account(self, invoice):
|
||||
details = self.resolve_payment_details(invoice)
|
||||
account = self._normalize_bank_account(details.get('bank_account', ''))
|
||||
if account:
|
||||
return account
|
||||
if getattr(invoice, 'invoice_type', None) == InvoiceType.PURCHASE:
|
||||
return ''
|
||||
company = getattr(invoice, 'company', None)
|
||||
return self._normalize_bank_account(getattr(company, 'bank_account', '') if company else '')
|
||||
|
||||
@staticmethod
|
||||
def _safe_tax_id(value):
|
||||
digits = ''.join(ch for ch in str(value or '') if ch.isdigit())
|
||||
return digits
|
||||
|
||||
def _append_xml_text(self, parent, tag, value):
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
node = ET.SubElement(parent, tag)
|
||||
node.text = text
|
||||
return node
|
||||
|
||||
def _append_address_node(self, parent, address, *, country_code='PL'):
|
||||
line1, line2 = self._split_address_lines(address)
|
||||
if not line1 and not line2:
|
||||
return None
|
||||
node = ET.SubElement(parent, 'Adres')
|
||||
ET.SubElement(node, 'KodKraju').text = country_code or 'PL'
|
||||
if line1:
|
||||
ET.SubElement(node, 'AdresL1').text = line1
|
||||
if line2:
|
||||
ET.SubElement(node, 'AdresL2').text = line2
|
||||
return node
|
||||
|
||||
|
||||
def _append_party_section(self, root, tag_name, *, name, tax_id, address, country_code='PL'):
|
||||
party = ET.SubElement(root, tag_name)
|
||||
ident = ET.SubElement(party, 'DaneIdentyfikacyjne')
|
||||
tax_digits = self._safe_tax_id(tax_id)
|
||||
if tax_digits:
|
||||
ET.SubElement(ident, 'NIP').text = tax_digits
|
||||
else:
|
||||
ET.SubElement(ident, 'BrakID').text = '1'
|
||||
ET.SubElement(ident, 'Nazwa').text = (name or 'Brak nazwy').strip()
|
||||
self._append_address_node(party, address, country_code=country_code)
|
||||
return party
|
||||
|
||||
def _append_tax_summary(self, fa_node, lines):
|
||||
vat_groups = {
|
||||
'23': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_1', 'p14': 'P_14_1'},
|
||||
'22': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_1', 'p14': 'P_14_1'},
|
||||
'8': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_2', 'p14': 'P_14_2'},
|
||||
'7': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_2', 'p14': 'P_14_2'},
|
||||
'5': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_3', 'p14': 'P_14_3'},
|
||||
}
|
||||
for line in lines:
|
||||
rate = Decimal(line.vat_rate or 0).normalize()
|
||||
rate_key = format(rate, 'f').rstrip('0').rstrip('.') if '.' in format(rate, 'f') else format(rate, 'f')
|
||||
group = vat_groups.get(rate_key)
|
||||
if not group:
|
||||
group = vat_groups.get('23') if Decimal(line.vat_amount or 0) > 0 else None
|
||||
if not group:
|
||||
continue
|
||||
group['net'] += Decimal(line.net_amount or 0)
|
||||
group['vat'] += Decimal(line.vat_amount or 0)
|
||||
|
||||
for cfg in vat_groups.values():
|
||||
if cfg['net']:
|
||||
ET.SubElement(fa_node, cfg['p13']).text = self._xml_decimal(cfg['net'])
|
||||
ET.SubElement(fa_node, cfg['p14']).text = self._xml_decimal(cfg['vat'])
|
||||
|
||||
def _append_adnotations(self, fa_node, *, split_payment=False):
|
||||
adnotacje = ET.SubElement(fa_node, 'Adnotacje')
|
||||
ET.SubElement(adnotacje, 'P_16').text = '2'
|
||||
ET.SubElement(adnotacje, 'P_17').text = '2'
|
||||
ET.SubElement(adnotacje, 'P_18').text = '2'
|
||||
ET.SubElement(adnotacje, 'P_18A').text = '1' if split_payment else '2'
|
||||
zw = ET.SubElement(adnotacje, 'Zwolnienie')
|
||||
ET.SubElement(zw, 'P_19N').text = '1'
|
||||
ET.SubElement(adnotacje, 'P_23').text = '2'
|
||||
return adnotacje
|
||||
|
||||
|
||||
def _append_payment_details(self, fa_node, invoice):
|
||||
bank_account = self._resolve_seller_bank_account(invoice)
|
||||
if not bank_account:
|
||||
return None
|
||||
external_metadata = self._normalize_metadata_container(getattr(invoice, 'external_metadata', {}) or {})
|
||||
bank_name = self._first_non_empty(external_metadata.get('seller_bank_name'))
|
||||
platnosc = ET.SubElement(fa_node, 'Platnosc')
|
||||
ET.SubElement(platnosc, 'FormaPlatnosci').text = '6'
|
||||
rachunek = ET.SubElement(platnosc, 'RachunekBankowy')
|
||||
ET.SubElement(rachunek, 'NrRB').text = bank_account
|
||||
if bank_name:
|
||||
ET.SubElement(rachunek, 'NazwaBanku').text = bank_name
|
||||
return platnosc
|
||||
|
||||
def _append_nfz_extra_description(self, fa_node, nfz_meta):
|
||||
mapping = [
|
||||
('IDWew', nfz_meta.get('recipient_branch_id', '')),
|
||||
('P_6_Od', nfz_meta.get('settlement_from', '')),
|
||||
('P_6_Do', nfz_meta.get('settlement_to', '')),
|
||||
('identyfikator-swiadczeniodawcy', nfz_meta.get('provider_identifier', '')),
|
||||
('Indeks', nfz_meta.get('service_code', '')),
|
||||
('NrUmowy', nfz_meta.get('contract_number', '')),
|
||||
('identyfikator-szablonu', nfz_meta.get('template_identifier', '')),
|
||||
]
|
||||
for key, value in mapping:
|
||||
if not str(value or '').strip():
|
||||
continue
|
||||
node = ET.SubElement(fa_node, 'DodatkowyOpis')
|
||||
ET.SubElement(node, 'Klucz').text = str(key)
|
||||
ET.SubElement(node, 'Wartosc').text = str(value)
|
||||
|
||||
def render_structured_xml(self, invoice, *, lines=None, nfz_meta=None):
|
||||
lines = lines if lines is not None else (invoice.lines.order_by('id').all() if hasattr(invoice.lines, 'order_by') else list(invoice.lines))
|
||||
nfz_meta = nfz_meta or (invoice.external_metadata or {}).get('nfz', {})
|
||||
seller = invoice.company or CompanyService.get_current_company()
|
||||
issue_date = invoice.issue_date.isoformat() if invoice.issue_date else self.today_date().isoformat()
|
||||
created_at = self.utcnow().replace(microsecond=0).isoformat() + 'Z'
|
||||
currency = (invoice.currency or 'PLN').strip() or 'PLN'
|
||||
schema_code = str(nfz_meta.get('nfz_schema', 'FA(3)') or 'FA(3)')
|
||||
schema_ns = 'http://crd.gov.pl/wzor/2025/06/25/13775/' if schema_code == 'FA(3)' else f'https://ksef.mf.gov.pl/schemat/faktura/{schema_code}'
|
||||
|
||||
ET.register_namespace('', schema_ns)
|
||||
root = ET.Element('Faktura', {'xmlns': schema_ns})
|
||||
|
||||
naglowek = ET.SubElement(root, 'Naglowek')
|
||||
kod_formularza = ET.SubElement(naglowek, 'KodFormularza', {'kodSystemowy': 'FA (3)', 'wersjaSchemy': '1-0E'})
|
||||
kod_formularza.text = 'FA'
|
||||
ET.SubElement(naglowek, 'WariantFormularza').text = '3'
|
||||
ET.SubElement(naglowek, 'DataWytworzeniaFa').text = created_at
|
||||
ET.SubElement(naglowek, 'SystemInfo').text = 'KSeF Manager'
|
||||
|
||||
self._append_party_section(
|
||||
root,
|
||||
'Podmiot1',
|
||||
name=getattr(seller, 'name', '') or '',
|
||||
tax_id=getattr(seller, 'tax_id', '') or '',
|
||||
address=getattr(seller, 'address', '') or '',
|
||||
)
|
||||
self._append_party_section(
|
||||
root,
|
||||
'Podmiot2',
|
||||
name=invoice.contractor_name or '',
|
||||
tax_id=invoice.contractor_nip or '',
|
||||
address=getattr(invoice, 'contractor_address', '') or '',
|
||||
)
|
||||
|
||||
if nfz_meta.get('recipient_branch_id'):
|
||||
podmiot3 = ET.SubElement(root, 'Podmiot3')
|
||||
ident = ET.SubElement(podmiot3, 'DaneIdentyfikacyjne')
|
||||
ET.SubElement(ident, 'IDWew').text = str(nfz_meta.get('recipient_branch_id'))
|
||||
ET.SubElement(podmiot3, 'Rola').text = '7'
|
||||
|
||||
fa = ET.SubElement(root, 'Fa')
|
||||
ET.SubElement(fa, 'KodWaluty').text = currency
|
||||
ET.SubElement(fa, 'P_1').text = issue_date
|
||||
self._append_xml_text(fa, 'P_1M', nfz_meta.get('issue_place'))
|
||||
ET.SubElement(fa, 'P_2').text = (invoice.invoice_number or '').strip()
|
||||
ET.SubElement(fa, 'P_6').text = issue_date
|
||||
self._append_xml_text(fa, 'P_6_Od', nfz_meta.get('settlement_from'))
|
||||
self._append_xml_text(fa, 'P_6_Do', nfz_meta.get('settlement_to'))
|
||||
|
||||
self._append_tax_summary(fa, lines)
|
||||
ET.SubElement(fa, 'P_15').text = self._xml_decimal(invoice.gross_amount)
|
||||
self._append_adnotations(fa, split_payment=bool(getattr(invoice, 'split_payment', False)))
|
||||
self._append_payment_details(fa, invoice)
|
||||
self._append_nfz_extra_description(fa, nfz_meta)
|
||||
|
||||
for idx, line in enumerate(lines, start=1):
|
||||
row = ET.SubElement(fa, 'FaWiersz')
|
||||
ET.SubElement(row, 'NrWierszaFa').text = str(idx)
|
||||
if nfz_meta.get('service_date'):
|
||||
ET.SubElement(row, 'P_6A').text = str(nfz_meta.get('service_date'))
|
||||
ET.SubElement(row, 'P_7').text = str(line.description or '')
|
||||
ET.SubElement(row, 'P_8A').text = str(line.unit or 'szt.')
|
||||
ET.SubElement(row, 'P_8B').text = self._xml_decimal(line.quantity, places=6).rstrip('0').rstrip('.') or '0'
|
||||
ET.SubElement(row, 'P_9A').text = self._xml_decimal(line.unit_net, places=8).rstrip('0').rstrip('.') or '0'
|
||||
ET.SubElement(row, 'P_11').text = self._xml_decimal(line.net_amount)
|
||||
ET.SubElement(row, 'P_12').text = self._xml_decimal(line.vat_rate, places=2).rstrip('0').rstrip('.') or '0'
|
||||
if Decimal(line.vat_amount or 0):
|
||||
ET.SubElement(row, 'P_12Z').text = self._xml_decimal(line.vat_amount)
|
||||
|
||||
stopka_parts = []
|
||||
if getattr(invoice, 'split_payment', False):
|
||||
stopka_parts.append('Mechanizm podzielonej płatności.')
|
||||
seller_bank_account = self._resolve_seller_bank_account(invoice)
|
||||
if seller_bank_account:
|
||||
stopka_parts.append(f'Rachunek bankowy: {seller_bank_account}')
|
||||
if nfz_meta.get('contract_number'):
|
||||
stopka_parts.append(f"NFZ umowa: {nfz_meta.get('contract_number')}")
|
||||
if stopka_parts:
|
||||
stopka = ET.SubElement(root, 'Stopka')
|
||||
info = ET.SubElement(stopka, 'Informacje')
|
||||
ET.SubElement(info, 'StopkaFaktury').text = ' '.join(stopka_parts)
|
||||
|
||||
return ET.tostring(root, encoding='utf-8', xml_declaration=True).decode('utf-8')
|
||||
|
||||
def persist_issued_assets(self, invoice, xml_content=None):
|
||||
db.session.flush()
|
||||
invoice.html_preview = self.render_invoice_html(invoice)
|
||||
xml_content = xml_content or self.build_ksef_payload(invoice).get("xml_content")
|
||||
if xml_content:
|
||||
invoice.source_hash = KSeFService.calc_hash(xml_content)
|
||||
invoice.xml_path = self._save_xml(invoice.company_id, invoice.ksef_number or invoice.invoice_number, xml_content)
|
||||
PdfService = __import__("app.services.pdf_service", fromlist=["PdfService"]).PdfService
|
||||
PdfService().render_invoice_pdf(invoice)
|
||||
return xml_content
|
||||
|
||||
@staticmethod
|
||||
def invoice_locked(invoice):
|
||||
return bool(
|
||||
getattr(invoice, "issued_to_ksef_at", None)
|
||||
or getattr(invoice, "issued_status", "") in {"issued", "issued_mock"}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def today_date():
|
||||
return date.today()
|
||||
|
||||
@staticmethod
|
||||
def utcnow():
|
||||
return datetime.utcnow()
|
||||
|
||||
@staticmethod
|
||||
def period_title(period):
|
||||
return InvoiceService.PERIOD_LABELS.get(period, InvoiceService.PERIOD_LABELS["month"])
|
||||
1279
app/services/ksef_service.py
Normal file
1279
app/services/ksef_service.py
Normal file
File diff suppressed because it is too large
Load Diff
104
app/services/mail_service.py
Normal file
104
app/services/mail_service.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from email.message import EmailMessage
|
||||
import smtplib
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.invoice import Invoice, MailDelivery
|
||||
from app.services.pdf_service import PdfService
|
||||
from app.services.settings_service import SettingsService
|
||||
|
||||
|
||||
class MailService:
|
||||
def __init__(self, company_id=None):
|
||||
self.company_id = company_id
|
||||
|
||||
def _smtp_config(self):
|
||||
security_mode = (SettingsService.get_effective('mail.security_mode', '', company_id=self.company_id) or '').strip().lower()
|
||||
if security_mode not in {'tls', 'ssl', 'none'}:
|
||||
security_mode = 'tls' if str(SettingsService.get_effective('mail.tls', 'true', company_id=self.company_id)).lower() == 'true' else 'none'
|
||||
return {
|
||||
'server': (SettingsService.get_effective('mail.server', '', company_id=self.company_id) or '').strip(),
|
||||
'port': int(SettingsService.get_effective('mail.port', '587', company_id=self.company_id) or 587),
|
||||
'username': (SettingsService.get_effective('mail.username', '', company_id=self.company_id) or '').strip(),
|
||||
'password': (SettingsService.get_effective_secret('mail.password', '', company_id=self.company_id) or '').strip(),
|
||||
'sender': (SettingsService.get_effective('mail.sender', '', company_id=self.company_id) or '').strip(),
|
||||
'security_mode': security_mode,
|
||||
}
|
||||
|
||||
def send_invoice(self, invoice: Invoice, recipient: str, subject: str | None = None, body: str | None = None):
|
||||
subject = subject or f'Faktura {invoice.invoice_number}'
|
||||
body = body or f'W załączeniu faktura {invoice.invoice_number}.'
|
||||
|
||||
delivery = MailDelivery(invoice_id=invoice.id, recipient=recipient, subject=subject, status='queued')
|
||||
db.session.add(delivery)
|
||||
db.session.flush()
|
||||
|
||||
pdf_bytes, path = PdfService().render_invoice_pdf(invoice)
|
||||
|
||||
try:
|
||||
self.send_mail(
|
||||
recipient,
|
||||
subject,
|
||||
body,
|
||||
[(path.name, 'application/pdf', pdf_bytes)]
|
||||
)
|
||||
delivery.status = 'sent'
|
||||
delivery.sent_at = datetime.utcnow()
|
||||
except Exception as exc:
|
||||
delivery.status = 'error'
|
||||
delivery.error_message = str(exc)
|
||||
|
||||
db.session.commit()
|
||||
return delivery
|
||||
|
||||
def send_mail(self, recipient: str, subject: str, body: str, attachments=None):
|
||||
cfg = self._smtp_config()
|
||||
attachments = attachments or []
|
||||
|
||||
if not cfg['server']:
|
||||
raise RuntimeError('SMTP server not configured')
|
||||
|
||||
sender = cfg['sender'] or cfg['username']
|
||||
if not sender:
|
||||
raise RuntimeError('SMTP sender not configured')
|
||||
|
||||
message = EmailMessage()
|
||||
message['Subject'] = subject
|
||||
message['From'] = sender
|
||||
message['To'] = recipient
|
||||
message.set_content(body)
|
||||
|
||||
for filename, mime, data in attachments:
|
||||
maintype, subtype = mime.split('/', 1)
|
||||
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
||||
|
||||
if cfg['security_mode'] == 'ssl':
|
||||
smtp = smtplib.SMTP_SSL(cfg['server'], cfg['port'])
|
||||
else:
|
||||
smtp = smtplib.SMTP(cfg['server'], cfg['port'])
|
||||
|
||||
with smtp:
|
||||
smtp.ehlo()
|
||||
|
||||
if cfg['security_mode'] == 'tls':
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
|
||||
if cfg['username']:
|
||||
smtp.login(cfg['username'], cfg['password'])
|
||||
|
||||
smtp.send_message(message)
|
||||
|
||||
return {'status': 'sent'}
|
||||
|
||||
def send_test_mail(self, recipient: str):
|
||||
return self.send_mail(
|
||||
recipient,
|
||||
'KSeF Manager - test SMTP',
|
||||
'To jest testowa wiadomość z aplikacji KSeF Manager.'
|
||||
)
|
||||
|
||||
def retry_delivery(self, delivery_id):
|
||||
delivery = db.session.get(MailDelivery, delivery_id)
|
||||
return self.send_invoice(delivery.invoice, delivery.recipient, delivery.subject)
|
||||
104
app/services/notification_service.py
Normal file
104
app/services/notification_service.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
import requests
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.company import UserCompanyAccess
|
||||
from app.models.invoice import NotificationLog
|
||||
from app.models.user import User
|
||||
from app.services.mail_service import MailService
|
||||
from app.services.settings_service import SettingsService
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, company_id=None):
|
||||
self.company_id = company_id
|
||||
|
||||
def should_notify(self, invoice):
|
||||
if SettingsService.get_effective('notify.enabled', 'false', company_id=self.company_id) != 'true':
|
||||
return False
|
||||
min_amount = Decimal(SettingsService.get_effective('notify.min_amount', '0', company_id=self.company_id) or '0')
|
||||
return Decimal(invoice.gross_amount) >= min_amount
|
||||
|
||||
def _pushover_credentials(self):
|
||||
return {
|
||||
'token': SettingsService.get_effective_secret('notify.pushover_api_token', '', company_id=self.company_id),
|
||||
'user': SettingsService.get_effective('notify.pushover_user_key', '', company_id=self.company_id),
|
||||
}
|
||||
|
||||
def _email_recipients(self):
|
||||
if not self.company_id:
|
||||
return []
|
||||
rows = (
|
||||
db.session.query(User.email)
|
||||
.join(UserCompanyAccess, UserCompanyAccess.user_id == User.id)
|
||||
.filter(UserCompanyAccess.company_id == self.company_id, User.is_blocked.is_(False))
|
||||
.order_by(User.email.asc())
|
||||
.all()
|
||||
)
|
||||
recipients = []
|
||||
seen = set()
|
||||
for (email,) in rows:
|
||||
email = (email or '').strip().lower()
|
||||
if not email or email in seen:
|
||||
continue
|
||||
seen.add(email)
|
||||
recipients.append(email)
|
||||
return recipients
|
||||
|
||||
def notify_new_invoice(self, invoice):
|
||||
if not self.should_notify(invoice):
|
||||
return []
|
||||
message = f'Nowa faktura {invoice.invoice_number} / {invoice.contractor_name} / {invoice.gross_amount} PLN'
|
||||
logs = [self._send_email_notification(invoice, message)]
|
||||
logs.append(self._send_pushover(invoice.id, message))
|
||||
return logs
|
||||
|
||||
def log_channel(self, invoice_id, channel, status, message):
|
||||
log = NotificationLog(invoice_id=invoice_id, channel=channel, status=status, message=message, sent_at=datetime.utcnow())
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
return log
|
||||
|
||||
def _send_email_notification(self, invoice, message):
|
||||
recipients = self._email_recipients()
|
||||
if not recipients:
|
||||
return self.log_channel(invoice.id, 'email', 'skipped', 'Brak odbiorców e-mail przypisanych do aktywnej firmy')
|
||||
|
||||
mailer = MailService(company_id=self.company_id)
|
||||
subject = f'Nowa faktura: {invoice.invoice_number}'
|
||||
body = (
|
||||
'W systemie KSeF Manager pojawiła się nowa faktura.\n\n'
|
||||
f'Numer: {invoice.invoice_number}\n'
|
||||
f'Kontrahent: {invoice.contractor_name}\n'
|
||||
f'Kwota brutto: {invoice.gross_amount} PLN\n'
|
||||
)
|
||||
|
||||
sent = 0
|
||||
errors = []
|
||||
for recipient in recipients:
|
||||
try:
|
||||
mailer.send_mail(recipient, subject, body)
|
||||
sent += 1
|
||||
except Exception as exc:
|
||||
errors.append(f'{recipient}: {exc}')
|
||||
|
||||
if sent and not errors:
|
||||
return self.log_channel(invoice.id, 'email', 'sent', f'{message} · odbiorców: {sent}')
|
||||
if sent and errors:
|
||||
return self.log_channel(invoice.id, 'email', 'error', f"Wysłano do {sent}/{len(recipients)} odbiorców. Błędy: {'; '.join(errors[:3])}")
|
||||
return self.log_channel(invoice.id, 'email', 'error', 'Nie udało się wysłać powiadomień e-mail: ' + '; '.join(errors[:3]))
|
||||
|
||||
def _send_pushover(self, invoice_id, message):
|
||||
creds = self._pushover_credentials()
|
||||
if not creds['token'] or not creds['user']:
|
||||
return self.log_channel(invoice_id, 'pushover', 'skipped', 'Brak konfiguracji Pushover')
|
||||
try:
|
||||
response = requests.post('https://api.pushover.net/1/messages.json', data={'token': creds['token'], 'user': creds['user'], 'message': message}, timeout=10)
|
||||
response.raise_for_status()
|
||||
return self.log_channel(invoice_id, 'pushover', 'sent', message)
|
||||
except Exception as exc:
|
||||
return self.log_channel(invoice_id, 'pushover', 'error', str(exc))
|
||||
|
||||
def send_test_pushover(self):
|
||||
return self._send_pushover(None, 'KSeF Manager - test Pushover')
|
||||
609
app/services/pdf_service.py
Normal file
609
app/services/pdf_service.py
Normal file
@@ -0,0 +1,609 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from flask import current_app
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
from app.models.invoice import InvoiceType
|
||||
|
||||
|
||||
class PdfService:
|
||||
def __init__(self):
|
||||
self.font_name = self._register_font()
|
||||
|
||||
def _register_font(self):
|
||||
candidates = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||
]
|
||||
for path in candidates:
|
||||
if Path(path).exists():
|
||||
pdfmetrics.registerFont(TTFont("AppUnicode", path))
|
||||
return "AppUnicode"
|
||||
return "Helvetica"
|
||||
|
||||
def _styles(self):
|
||||
styles = getSampleStyleSheet()
|
||||
base = self.font_name
|
||||
styles["Normal"].fontName = base
|
||||
styles["Normal"].fontSize = 9.5
|
||||
styles["Normal"].leading = 12
|
||||
styles.add(ParagraphStyle(name="DocTitle", fontName=base, fontSize=18, leading=22, spaceAfter=4))
|
||||
styles.add(ParagraphStyle(name="SectionTitle", fontName=base, fontSize=10, leading=12, spaceAfter=4))
|
||||
styles.add(ParagraphStyle(name="Small", fontName=base, fontSize=8, leading=10))
|
||||
styles.add(ParagraphStyle(name="Right", fontName=base, fontSize=9.5, leading=12, alignment=2))
|
||||
return styles
|
||||
|
||||
@staticmethod
|
||||
def _money(value, currency="PLN") -> str:
|
||||
return f"{Decimal(value):,.2f} {currency or 'PLN'}".replace(",", " ").replace(".", ",")
|
||||
|
||||
def _safe(self, value) -> str:
|
||||
return str(value or "-").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
def _table_base_style(self, extra=None):
|
||||
styles = [
|
||||
("FONTNAME", (0, 0), (-1, -1), self.font_name),
|
||||
("BOX", (0, 0), (-1, -1), 0.7, colors.black),
|
||||
("INNERGRID", (0, 0), (-1, -1), 0.5, colors.black),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
]
|
||||
if extra:
|
||||
styles.extend(extra)
|
||||
return TableStyle(styles)
|
||||
|
||||
def _extract_lines_from_xml(self, xml_path):
|
||||
def to_decimal(value, default='0'):
|
||||
raw = str(value or '').strip()
|
||||
if not raw:
|
||||
return Decimal(default)
|
||||
raw = raw.replace(' ', '').replace(',', '.')
|
||||
try:
|
||||
return Decimal(raw)
|
||||
except Exception:
|
||||
return Decimal(default)
|
||||
|
||||
def to_vat_rate(value):
|
||||
raw = str(value or '').strip().lower()
|
||||
if not raw:
|
||||
return Decimal('0')
|
||||
raw = raw.replace('%', '').replace(' ', '').replace(',', '.')
|
||||
try:
|
||||
return Decimal(raw)
|
||||
except Exception:
|
||||
return Decimal('0')
|
||||
|
||||
def looks_numeric(value):
|
||||
raw = str(value or '').strip().replace(' ', '').replace(',', '.')
|
||||
if not raw:
|
||||
return False
|
||||
try:
|
||||
Decimal(raw)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not xml_path:
|
||||
return []
|
||||
|
||||
try:
|
||||
xml_content = Path(xml_path).read_text(encoding='utf-8')
|
||||
root = ET.fromstring(xml_content)
|
||||
namespace_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else ''
|
||||
ns = {'fa': namespace_uri} if namespace_uri else {}
|
||||
|
||||
row_path = './/fa:FaWiersz' if ns else './/FaWiersz'
|
||||
text_path = lambda name: f'fa:{name}' if ns else name
|
||||
|
||||
lines = []
|
||||
for row in root.findall(row_path, ns):
|
||||
p_8a = row.findtext(text_path('P_8A'), default='', namespaces=ns)
|
||||
p_8b = row.findtext(text_path('P_8B'), default='', namespaces=ns)
|
||||
|
||||
if looks_numeric(p_8a):
|
||||
qty_raw = p_8a
|
||||
unit_raw = p_8b or 'szt.'
|
||||
else:
|
||||
unit_raw = p_8a or 'szt.'
|
||||
qty_raw = p_8b or '1'
|
||||
|
||||
net = to_decimal(
|
||||
row.findtext(text_path('P_11'), default='', namespaces=ns)
|
||||
or row.findtext(text_path('P_11A'), default='', namespaces=ns)
|
||||
or '0'
|
||||
)
|
||||
vat = to_decimal(
|
||||
row.findtext(text_path('P_12Z'), default='', namespaces=ns)
|
||||
or row.findtext(text_path('P_11Vat'), default='', namespaces=ns)
|
||||
or '0'
|
||||
)
|
||||
|
||||
lines.append({
|
||||
'description': (row.findtext(text_path('P_7'), default='', namespaces=ns) or '').strip(),
|
||||
'quantity': to_decimal(qty_raw, '1'),
|
||||
'unit': (unit_raw or 'szt.').strip(),
|
||||
'unit_net': to_decimal(
|
||||
row.findtext(text_path('P_9A'), default='', namespaces=ns)
|
||||
or row.findtext(text_path('P_9B'), default='', namespaces=ns)
|
||||
or '0'
|
||||
),
|
||||
'vat_rate': to_vat_rate(row.findtext(text_path('P_12'), default='0', namespaces=ns)),
|
||||
'net_amount': net,
|
||||
'vat_amount': vat,
|
||||
'gross_amount': net + vat,
|
||||
})
|
||||
return lines
|
||||
except Exception as exc:
|
||||
current_app.logger.warning(f'PDF XML line parse error: {exc}')
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _first_non_empty(*values, default=""):
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
if value.strip():
|
||||
return value.strip()
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _normalize_metadata_container(value):
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
def _get_external_metadata(self, invoice):
|
||||
return self._normalize_metadata_container(getattr(invoice, "external_metadata", {}) or {})
|
||||
|
||||
def _get_ksef_metadata(self, invoice):
|
||||
external_metadata = self._get_external_metadata(invoice)
|
||||
ksef_meta = self._normalize_metadata_container(external_metadata.get("ksef", {}) or {})
|
||||
return ksef_meta
|
||||
|
||||
def _resolve_purchase_seller_data(self, invoice, fallback_name, fallback_tax):
|
||||
external_metadata = self._get_external_metadata(invoice)
|
||||
ksef_meta = self._get_ksef_metadata(invoice)
|
||||
|
||||
seller_name = self._first_non_empty(
|
||||
getattr(invoice, "contractor_name", ""),
|
||||
external_metadata.get("contractor_name"),
|
||||
ksef_meta.get("contractor_name"),
|
||||
fallback_name,
|
||||
)
|
||||
|
||||
seller_tax = self._first_non_empty(
|
||||
getattr(invoice, "contractor_nip", ""),
|
||||
external_metadata.get("contractor_nip"),
|
||||
ksef_meta.get("contractor_nip"),
|
||||
fallback_tax,
|
||||
)
|
||||
|
||||
seller_street = self._first_non_empty(
|
||||
external_metadata.get("contractor_street"),
|
||||
ksef_meta.get("contractor_street"),
|
||||
external_metadata.get("seller_street"),
|
||||
ksef_meta.get("seller_street"),
|
||||
)
|
||||
|
||||
seller_city = self._first_non_empty(
|
||||
external_metadata.get("contractor_city"),
|
||||
ksef_meta.get("contractor_city"),
|
||||
external_metadata.get("seller_city"),
|
||||
ksef_meta.get("seller_city"),
|
||||
)
|
||||
|
||||
seller_postal_code = self._first_non_empty(
|
||||
external_metadata.get("contractor_postal_code"),
|
||||
ksef_meta.get("contractor_postal_code"),
|
||||
external_metadata.get("seller_postal_code"),
|
||||
ksef_meta.get("seller_postal_code"),
|
||||
)
|
||||
|
||||
seller_country = self._first_non_empty(
|
||||
external_metadata.get("contractor_country"),
|
||||
ksef_meta.get("contractor_country"),
|
||||
external_metadata.get("seller_country"),
|
||||
ksef_meta.get("seller_country"),
|
||||
)
|
||||
|
||||
seller_address = self._first_non_empty(
|
||||
external_metadata.get("contractor_address"),
|
||||
ksef_meta.get("contractor_address"),
|
||||
external_metadata.get("seller_address"),
|
||||
ksef_meta.get("seller_address"),
|
||||
getattr(invoice, "contractor_address", ""),
|
||||
)
|
||||
|
||||
if not seller_address:
|
||||
address_parts = [part for part in [seller_street, seller_postal_code, seller_city, seller_country] if part]
|
||||
seller_address = ", ".join(address_parts)
|
||||
|
||||
return {
|
||||
"name": seller_name,
|
||||
"tax": seller_tax,
|
||||
"address": seller_address,
|
||||
}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _normalize_bank_account(value):
|
||||
return ''.join(str(value or '').split())
|
||||
|
||||
def _extract_payment_details_from_xml(self, xml_content):
|
||||
details = {'payment_form_code': '', 'payment_form_label': '', 'bank_account': '', 'bank_name': '', 'payment_due_date': ''}
|
||||
if not xml_content:
|
||||
return details
|
||||
try:
|
||||
root = ET.fromstring(xml_content)
|
||||
namespace_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else ''
|
||||
ns = {'fa': namespace_uri} if namespace_uri else {}
|
||||
def find_text(path):
|
||||
return (root.findtext(path, default='', namespaces=ns) or '').strip()
|
||||
form_code = find_text('.//fa:Platnosc/fa:FormaPlatnosci' if ns else './/Platnosc/FormaPlatnosci')
|
||||
bank_account = find_text('.//fa:Platnosc/fa:RachunekBankowy/fa:NrRB' if ns else './/Platnosc/RachunekBankowy/NrRB')
|
||||
bank_name = find_text('.//fa:Platnosc/fa:RachunekBankowy/fa:NazwaBanku' if ns else './/Platnosc/RachunekBankowy/NazwaBanku')
|
||||
payment_due_date = find_text('.//fa:Platnosc/fa:TerminPlatnosci/fa:Termin' if ns else './/Platnosc/TerminPlatnosci/Termin')
|
||||
labels = {'1': 'gotówka', '2': 'karta', '3': 'bon', '4': 'czek', '5': 'weksel', '6': 'przelew', '7': 'kompensata', '8': 'pobranie', '9': 'akredytywa', '10': 'polecenie zapłaty', '11': 'inny'}
|
||||
details.update({'payment_form_code': form_code, 'payment_form_label': labels.get(form_code, form_code), 'bank_account': self._normalize_bank_account(bank_account), 'bank_name': bank_name, 'payment_due_date': payment_due_date})
|
||||
except Exception:
|
||||
pass
|
||||
return details
|
||||
|
||||
def _resolve_payment_details(self, invoice):
|
||||
external_metadata = self._get_external_metadata(invoice)
|
||||
details = {
|
||||
'payment_form_code': self._first_non_empty(external_metadata.get('payment_form_code')),
|
||||
'payment_form_label': self._first_non_empty(external_metadata.get('payment_form_label')),
|
||||
'bank_account': self._normalize_bank_account(self._first_non_empty(getattr(invoice, 'seller_bank_account', ''), external_metadata.get('seller_bank_account'))),
|
||||
'bank_name': self._first_non_empty(external_metadata.get('seller_bank_name')),
|
||||
'payment_due_date': self._first_non_empty(external_metadata.get('payment_due_date')),
|
||||
}
|
||||
if getattr(invoice, 'xml_path', None) and (not details['bank_account'] or not details['payment_form_code']):
|
||||
try:
|
||||
xml_content = Path(invoice.xml_path).read_text(encoding='utf-8')
|
||||
parsed = self._extract_payment_details_from_xml(xml_content)
|
||||
for key, value in parsed.items():
|
||||
if value and not details.get(key):
|
||||
details[key] = value
|
||||
except Exception:
|
||||
pass
|
||||
return details
|
||||
|
||||
def _resolve_seller_bank_account(self, invoice):
|
||||
details = self._resolve_payment_details(invoice)
|
||||
account = self._normalize_bank_account(details.get('bank_account', ''))
|
||||
if account:
|
||||
return account
|
||||
if getattr(invoice, 'invoice_type', None) == InvoiceType.PURCHASE:
|
||||
return ''
|
||||
company = getattr(invoice, 'company', None)
|
||||
return self._normalize_bank_account(getattr(company, 'bank_account', '') if company else '')
|
||||
|
||||
def _build_pdf_filename_stem(self, invoice):
|
||||
raw = self._first_non_empty(
|
||||
getattr(invoice, "invoice_number", ""),
|
||||
getattr(invoice, "ksef_number", ""),
|
||||
"Faktura",
|
||||
)
|
||||
return raw.replace("/", "_")
|
||||
|
||||
def _build_pdf_title(self, invoice, invoice_kind, company_name):
|
||||
return self._first_non_empty(
|
||||
getattr(invoice, "invoice_number", ""),
|
||||
getattr(invoice, "ksef_number", ""),
|
||||
company_name,
|
||||
invoice_kind,
|
||||
"Faktura",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _set_pdf_metadata(canvas, doc, title, author, subject, creator="KSeF Manager"):
|
||||
try:
|
||||
canvas.setTitle(title)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
canvas.setAuthor(author)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
canvas.setSubject(subject)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
canvas.setCreator(creator)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def render_invoice_pdf(self, invoice):
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=A4,
|
||||
leftMargin=14 * mm,
|
||||
rightMargin=14 * mm,
|
||||
topMargin=14 * mm,
|
||||
bottomMargin=14 * mm,
|
||||
)
|
||||
styles = self._styles()
|
||||
story = []
|
||||
|
||||
company_name = self._safe(invoice.company.name if invoice.company else "Twoja firma")
|
||||
company_tax = self._safe(getattr(invoice.company, "tax_id", "") if invoice.company else "")
|
||||
company_address = self._safe(getattr(invoice.company, "address", "") if invoice.company else "")
|
||||
|
||||
customer = getattr(invoice, "customer", None)
|
||||
customer_name = self._safe(getattr(customer, "name", invoice.contractor_name))
|
||||
customer_tax = self._safe(getattr(customer, "tax_id", invoice.contractor_nip))
|
||||
customer_address = self._safe(getattr(customer, "address", ""))
|
||||
customer_email = self._safe(getattr(customer, "email", ""))
|
||||
|
||||
if invoice.invoice_type == InvoiceType.PURCHASE:
|
||||
purchase_seller = self._resolve_purchase_seller_data(
|
||||
invoice=invoice,
|
||||
fallback_name=invoice.contractor_name,
|
||||
fallback_tax=invoice.contractor_nip,
|
||||
)
|
||||
|
||||
seller_name = self._safe(purchase_seller["name"])
|
||||
seller_tax = self._safe(purchase_seller["tax"])
|
||||
seller_address = self._safe(purchase_seller["address"])
|
||||
|
||||
buyer_name = company_name
|
||||
buyer_tax = company_tax
|
||||
buyer_address = company_address
|
||||
buyer_email = ""
|
||||
else:
|
||||
seller_name = company_name
|
||||
seller_tax = company_tax
|
||||
seller_address = company_address
|
||||
|
||||
buyer_name = customer_name
|
||||
buyer_tax = customer_tax
|
||||
buyer_address = customer_address
|
||||
buyer_email = customer_email
|
||||
|
||||
nfz_meta = (invoice.external_metadata or {}).get("nfz", {})
|
||||
invoice_kind = "FAKTURA NFZ" if invoice.source == "nfz" else "FAKTURA VAT"
|
||||
currency = invoice.currency or "PLN"
|
||||
|
||||
pdf_title = self._build_pdf_title(invoice, invoice_kind, company_name)
|
||||
pdf_author = self._first_non_empty(
|
||||
invoice.company.name if invoice.company else "",
|
||||
"KSeF Manager",
|
||||
)
|
||||
pdf_subject = self._first_non_empty(
|
||||
invoice_kind,
|
||||
getattr(invoice, "source", ""),
|
||||
"Faktura",
|
||||
)
|
||||
|
||||
story.append(Paragraph(invoice_kind, styles["DocTitle"]))
|
||||
header = Table(
|
||||
[
|
||||
[
|
||||
Paragraph(
|
||||
f"<b>Numer faktury:</b> {self._safe(invoice.invoice_number)}<br/>"
|
||||
f"<b>Data wystawienia:</b> {self._safe(invoice.issue_date)}<br/>"
|
||||
f"<b>Waluta:</b> {self._safe(currency)}",
|
||||
styles["Normal"],
|
||||
),
|
||||
Paragraph(
|
||||
f"<b>Numer KSeF:</b> {self._safe(invoice.ksef_number)}<br/>"
|
||||
f"<b>Status:</b> {self._safe(invoice.issued_status)}<br/>"
|
||||
f"<b>Typ źródła:</b> {self._safe(invoice.source)}",
|
||||
styles["Normal"],
|
||||
),
|
||||
]
|
||||
],
|
||||
colWidths=[88 * mm, 88 * mm],
|
||||
)
|
||||
header.setStyle(self._table_base_style())
|
||||
story.extend([header, Spacer(1, 5 * mm)])
|
||||
|
||||
buyer_email_html = f"<br/>E-mail: {buyer_email}" if buyer_email not in {"", "-"} else ""
|
||||
|
||||
payment_details = self._resolve_payment_details(invoice)
|
||||
seller_bank_account = self._safe(self._resolve_seller_bank_account(invoice))
|
||||
payment_form_html = (
|
||||
f"<br/>Forma płatności: {self._safe(payment_details.get('payment_form_label'))}"
|
||||
if payment_details.get('payment_form_label') else ''
|
||||
)
|
||||
seller_bank_account_html = (
|
||||
f"<br/>Rachunek: {seller_bank_account}"
|
||||
if seller_bank_account not in {"", "-"}
|
||||
else ""
|
||||
)
|
||||
seller_bank_name_html = (
|
||||
f"<br/>Bank: {self._safe(payment_details.get('bank_name'))}"
|
||||
if payment_details.get('bank_name') else ''
|
||||
)
|
||||
|
||||
parties = Table(
|
||||
[
|
||||
[
|
||||
Paragraph(
|
||||
f"<b>Sprzedawca</b><br/>{seller_name}<br/>"
|
||||
f"NIP: {seller_tax}<br/>"
|
||||
f"Adres: {seller_address}"
|
||||
f"{payment_form_html}"
|
||||
f"{seller_bank_account_html}"
|
||||
f"{seller_bank_name_html}",
|
||||
styles["Normal"],
|
||||
),
|
||||
Paragraph(
|
||||
f"<b>Nabywca</b><br/>{buyer_name}<br/>"
|
||||
f"NIP: {buyer_tax}<br/>"
|
||||
f"Adres: {buyer_address}"
|
||||
f"{buyer_email_html}",
|
||||
styles["Normal"],
|
||||
),
|
||||
]
|
||||
],
|
||||
colWidths=[88 * mm, 88 * mm],
|
||||
)
|
||||
|
||||
parties.setStyle(self._table_base_style())
|
||||
story.extend([parties, Spacer(1, 5 * mm)])
|
||||
|
||||
if getattr(invoice, 'split_payment', False):
|
||||
story.extend([Paragraph('Mechanizm podzielonej płatności', styles['SectionTitle']), Spacer(1, 2 * mm)])
|
||||
|
||||
if nfz_meta:
|
||||
nfz_rows = [
|
||||
[Paragraph("<b>Pole NFZ</b>", styles["Normal"]), Paragraph("<b>Wartość</b>", styles["Normal"])],
|
||||
[Paragraph("Oddział NFZ (IDWew)", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("recipient_branch_id")), styles["Normal"])],
|
||||
[Paragraph("Nazwa oddziału", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("recipient_branch_name")), styles["Normal"])],
|
||||
[Paragraph("Okres rozliczeniowy od", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("settlement_from")), styles["Normal"])],
|
||||
[Paragraph("Okres rozliczeniowy do", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("settlement_to")), styles["Normal"])],
|
||||
[Paragraph("Identyfikator świadczeniodawcy", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("provider_identifier")), styles["Normal"])],
|
||||
[Paragraph("Kod zakresu / świadczenia", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("service_code")), styles["Normal"])],
|
||||
[Paragraph("Numer umowy / aneksu", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("contract_number")), styles["Normal"])],
|
||||
[Paragraph("Identyfikator szablonu", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("template_identifier")), styles["Normal"])],
|
||||
[Paragraph("Schemat", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("nfz_schema", "FA(3)")), styles["Normal"])],
|
||||
]
|
||||
nfz_table = Table(nfz_rows, colWidths=[62 * mm, 114 * mm], repeatRows=1)
|
||||
nfz_table.setStyle(self._table_base_style([("ALIGN", (0, 0), (-1, 0), "CENTER")]))
|
||||
story.extend([Paragraph("Dane NFZ", styles["SectionTitle"]), nfz_table, Spacer(1, 5 * mm)])
|
||||
|
||||
invoice_lines = invoice.lines.order_by("id").all() if hasattr(invoice.lines, "order_by") else list(invoice.lines)
|
||||
if not invoice_lines:
|
||||
invoice_lines = [
|
||||
type("TmpLine", (), line)
|
||||
for line in self._extract_lines_from_xml(getattr(invoice, "xml_path", None))
|
||||
]
|
||||
|
||||
lines = [[
|
||||
Paragraph("<b>Pozycja</b>", styles["Normal"]),
|
||||
Paragraph("<b>Ilość</b>", styles["Normal"]),
|
||||
Paragraph("<b>JM</b>", styles["Normal"]),
|
||||
Paragraph("<b>VAT</b>", styles["Normal"]),
|
||||
Paragraph("<b>Netto</b>", styles["Normal"]),
|
||||
Paragraph("<b>Brutto</b>", styles["Normal"]),
|
||||
]]
|
||||
|
||||
if invoice_lines:
|
||||
for line in invoice_lines:
|
||||
lines.append([
|
||||
Paragraph(self._safe(line.description), styles["Normal"]),
|
||||
Paragraph(self._safe(line.quantity), styles["Right"]),
|
||||
Paragraph(self._safe(line.unit), styles["Right"]),
|
||||
Paragraph(f"{Decimal(line.vat_rate):.0f}%", styles["Right"]),
|
||||
Paragraph(self._money(line.net_amount, currency), styles["Right"]),
|
||||
Paragraph(self._money(line.gross_amount, currency), styles["Right"]),
|
||||
])
|
||||
else:
|
||||
lines.append([
|
||||
Paragraph("Brak pozycji na fakturze.", styles["Normal"]),
|
||||
Paragraph("-", styles["Right"]),
|
||||
Paragraph("-", styles["Right"]),
|
||||
Paragraph("-", styles["Right"]),
|
||||
Paragraph("-", styles["Right"]),
|
||||
Paragraph("-", styles["Right"]),
|
||||
])
|
||||
|
||||
items = Table(
|
||||
lines,
|
||||
colWidths=[82 * mm, 18 * mm, 16 * mm, 16 * mm, 28 * mm, 30 * mm],
|
||||
repeatRows=1,
|
||||
)
|
||||
items.setStyle(self._table_base_style([
|
||||
("ALIGN", (1, 0), (-1, -1), "RIGHT"),
|
||||
("ALIGN", (0, 0), (0, -1), "LEFT"),
|
||||
]))
|
||||
story.extend([Paragraph("Pozycje faktury", styles["SectionTitle"]), items, Spacer(1, 5 * mm)])
|
||||
|
||||
summary = Table(
|
||||
[
|
||||
[Paragraph("Netto", styles["Normal"]), Paragraph(self._money(invoice.net_amount, currency), styles["Right"])],
|
||||
[Paragraph("VAT", styles["Normal"]), Paragraph(self._money(invoice.vat_amount, currency), styles["Right"])],
|
||||
[Paragraph("<b>Razem brutto</b>", styles["Normal"]), Paragraph(f"<b>{self._money(invoice.gross_amount, currency)}</b>", styles["Right"])],
|
||||
],
|
||||
colWidths=[48 * mm, 42 * mm],
|
||||
)
|
||||
summary.setStyle(self._table_base_style([
|
||||
("ALIGN", (0, 0), (-1, -1), "RIGHT"),
|
||||
("ALIGN", (0, 0), (0, -1), "LEFT"),
|
||||
]))
|
||||
summary_wrap = Table([["", summary]], colWidths=[86 * mm, 90 * mm])
|
||||
summary_wrap.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP")]))
|
||||
story.extend([summary_wrap, Spacer(1, 5 * mm)])
|
||||
|
||||
note = (
|
||||
"Dokument zawiera pola wymagane dla rozliczeń NFZ i został przygotowany do wysyłki w schemacie FA(3)."
|
||||
if nfz_meta
|
||||
else "Dokument wygenerowany przez KSeF Manager."
|
||||
)
|
||||
story.append(Paragraph(note, styles["Small"]))
|
||||
|
||||
def _apply_metadata(canvas, pdf_doc):
|
||||
self._set_pdf_metadata(
|
||||
canvas=canvas,
|
||||
doc=pdf_doc,
|
||||
title=pdf_title,
|
||||
author=pdf_author,
|
||||
subject=pdf_subject,
|
||||
)
|
||||
|
||||
doc.build(story, onFirstPage=_apply_metadata, onLaterPages=_apply_metadata)
|
||||
|
||||
pdf_bytes = buffer.getvalue()
|
||||
path = Path(current_app.config["PDF_PATH"]) / f"{self._build_pdf_filename_stem(invoice)}.pdf"
|
||||
path.write_bytes(pdf_bytes)
|
||||
invoice.pdf_path = str(path)
|
||||
return pdf_bytes, path
|
||||
|
||||
def month_pdf(self, entries, title):
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=A4,
|
||||
leftMargin=16 * mm,
|
||||
rightMargin=16 * mm,
|
||||
topMargin=16 * mm,
|
||||
bottomMargin=16 * mm,
|
||||
)
|
||||
styles = self._styles()
|
||||
rows = [[Paragraph("<b>Numer</b>", styles["Normal"]), Paragraph("<b>Kontrahent</b>", styles["Normal"]), Paragraph("<b>Brutto</b>", styles["Normal"])]]
|
||||
for invoice in entries:
|
||||
rows.append([
|
||||
Paragraph(str(invoice.invoice_number), styles["Normal"]),
|
||||
Paragraph(str(invoice.contractor_name), styles["Normal"]),
|
||||
Paragraph(self._money(invoice.gross_amount, getattr(invoice, "currency", "PLN")), styles["Right"]),
|
||||
])
|
||||
table = Table(rows, colWidths=[45 * mm, 95 * mm, 35 * mm], repeatRows=1)
|
||||
table.setStyle(self._table_base_style([("ALIGN", (2, 0), (2, -1), "RIGHT")]))
|
||||
|
||||
pdf_title = self._first_non_empty(title, "Zestawienie faktur")
|
||||
pdf_author = "KSeF Manager"
|
||||
pdf_subject = "Miesięczne zestawienie faktur"
|
||||
|
||||
def _apply_metadata(canvas, pdf_doc):
|
||||
self._set_pdf_metadata(
|
||||
canvas=canvas,
|
||||
doc=pdf_doc,
|
||||
title=pdf_title,
|
||||
author=pdf_author,
|
||||
subject=pdf_subject,
|
||||
)
|
||||
|
||||
doc.build([Paragraph(title, styles["DocTitle"]), Spacer(1, 4 * mm), table], onFirstPage=_apply_metadata, onLaterPages=_apply_metadata)
|
||||
return buffer.getvalue()
|
||||
170
app/services/redis_service.py
Normal file
170
app/services/redis_service.py
Normal file
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import current_app
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
|
||||
class RedisService:
|
||||
_memory_store: dict[str, tuple[float | None, str]] = {}
|
||||
_lock = Lock()
|
||||
_failure_logged_at = 0.0
|
||||
_availability_cache: tuple[bool, float] = (False, 0.0)
|
||||
|
||||
@classmethod
|
||||
def _config(cls, app=None):
|
||||
if app is not None:
|
||||
return app.config
|
||||
return current_app.config
|
||||
|
||||
@classmethod
|
||||
def enabled(cls, app=None) -> bool:
|
||||
cfg = cls._config(app)
|
||||
return str(cfg.get('REDIS_URL', 'memory://')).strip().lower().startswith('redis://')
|
||||
|
||||
@classmethod
|
||||
def url(cls, app=None) -> str:
|
||||
cfg = cls._config(app)
|
||||
return str(cfg.get('REDIS_URL', 'memory://')).strip() or 'memory://'
|
||||
|
||||
@classmethod
|
||||
def _logger(cls, app=None):
|
||||
if app is not None:
|
||||
return app.logger
|
||||
return current_app.logger
|
||||
|
||||
@classmethod
|
||||
def _log_failure_once(cls, action: str, exc: Exception, app=None) -> None:
|
||||
now = time.time()
|
||||
if now - cls._failure_logged_at < 60:
|
||||
return
|
||||
cls._failure_logged_at = now
|
||||
cls._logger(app).warning(
|
||||
'Redis %s niedostępny, przełączam na cache pamięciowy: %s', action, exc
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def client(cls, app=None) -> Redis | None:
|
||||
if not cls.enabled(app):
|
||||
return None
|
||||
try:
|
||||
return Redis.from_url(
|
||||
cls.url(app),
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=1,
|
||||
socket_timeout=1,
|
||||
)
|
||||
except Exception as exc:
|
||||
cls._log_failure_once('client', exc, app)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def available(cls, app=None) -> bool:
|
||||
if not cls.enabled(app):
|
||||
return False
|
||||
cached_ok, checked_at = cls._availability_cache
|
||||
if time.time() - checked_at < 15:
|
||||
return cached_ok
|
||||
client = cls.client(app)
|
||||
if client is None:
|
||||
cls._availability_cache = (False, time.time())
|
||||
return False
|
||||
try:
|
||||
client.ping()
|
||||
cls._availability_cache = (True, time.time())
|
||||
return True
|
||||
except RedisError as exc:
|
||||
cls._log_failure_once('ping', exc, app)
|
||||
cls._availability_cache = (False, time.time())
|
||||
return False
|
||||
except Exception as exc:
|
||||
cls._log_failure_once('ping', exc, app)
|
||||
cls._availability_cache = (False, time.time())
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def ping(cls, app=None) -> tuple[str, str]:
|
||||
url = cls.url(app)
|
||||
if not cls.enabled(app):
|
||||
return 'disabled', 'Cache pamięciowy aktywny (Redis wyłączony).'
|
||||
if cls.available(app):
|
||||
return 'ok', f'{url} · połączenie aktywne'
|
||||
parsed = urlparse(url)
|
||||
hint = ''
|
||||
if parsed.hostname in {'localhost', '127.0.0.1'}:
|
||||
hint = ' · w Dockerze użyj nazwy usługi redis zamiast localhost'
|
||||
return 'fallback', f'{url} · brak połączenia, aktywny fallback pamięciowy{hint}'
|
||||
|
||||
@classmethod
|
||||
def _memory_get(cls, key: str) -> Any | None:
|
||||
now = time.time()
|
||||
with cls._lock:
|
||||
payload = cls._memory_store.get(key)
|
||||
if not payload:
|
||||
return None
|
||||
expires_at, raw = payload
|
||||
if expires_at is not None and expires_at <= now:
|
||||
cls._memory_store.pop(key, None)
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _memory_set(cls, key: str, value: Any, ttl: int = 60) -> bool:
|
||||
expires_at = None if ttl <= 0 else time.time() + ttl
|
||||
with cls._lock:
|
||||
cls._memory_store[key] = (expires_at, json.dumps(value, default=str))
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_json(cls, key: str, app=None) -> Any | None:
|
||||
if not cls.enabled(app) or not cls.available(app):
|
||||
return cls._memory_get(key)
|
||||
client = cls.client(app)
|
||||
if client is None:
|
||||
return cls._memory_get(key)
|
||||
try:
|
||||
raw = client.get(key)
|
||||
if raw:
|
||||
return json.loads(raw)
|
||||
return cls._memory_get(key)
|
||||
except Exception as exc:
|
||||
cls._log_failure_once(f'get_json({key})', exc, app)
|
||||
return cls._memory_get(key)
|
||||
|
||||
@classmethod
|
||||
def set_json(cls, key: str, value: Any, ttl: int = 60, app=None) -> bool:
|
||||
cls._memory_set(key, value, ttl=ttl)
|
||||
if not cls.enabled(app) or not cls.available(app):
|
||||
return False
|
||||
client = cls.client(app)
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
client.setex(key, ttl, json.dumps(value, default=str))
|
||||
return True
|
||||
except Exception as exc:
|
||||
cls._log_failure_once(f'set_json({key})', exc, app)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def delete(cls, key: str, app=None) -> None:
|
||||
with cls._lock:
|
||||
cls._memory_store.pop(key, None)
|
||||
if not cls.enabled(app) or not cls.available(app):
|
||||
return
|
||||
client = cls.client(app)
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.delete(key)
|
||||
except Exception as exc:
|
||||
cls._log_failure_once(f'delete({key})', exc, app)
|
||||
115
app/services/settings_service.py
Normal file
115
app/services/settings_service.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from flask import current_app
|
||||
from app.extensions import db
|
||||
from app.models.setting import AppSetting
|
||||
from app.services.company_service import CompanyService
|
||||
|
||||
|
||||
class SettingsService:
|
||||
@staticmethod
|
||||
def _user_scope_key(key: str, user_id=None):
|
||||
from flask_login import current_user
|
||||
if user_id is None and getattr(current_user, 'is_authenticated', False):
|
||||
user_id = current_user.id
|
||||
return f'user.{user_id}.{key}' if user_id else key
|
||||
|
||||
@staticmethod
|
||||
def _scope_key(key: str, company_id=None):
|
||||
if company_id is None:
|
||||
company = CompanyService.get_current_company()
|
||||
company_id = company.id if company else None
|
||||
return f'company.{company_id}.{key}' if company_id else key
|
||||
|
||||
@staticmethod
|
||||
def get(key, default=None, company_id=None):
|
||||
scoped = AppSetting.get(SettingsService._scope_key(key, company_id), default=None)
|
||||
if scoped is not None:
|
||||
return scoped
|
||||
return AppSetting.get(key, default=default)
|
||||
|
||||
@staticmethod
|
||||
def get_secret(key, default=None, company_id=None):
|
||||
scoped = AppSetting.get(SettingsService._scope_key(key, company_id), default=None, decrypt=True)
|
||||
if scoped is not None:
|
||||
return scoped
|
||||
return AppSetting.get(key, default=default, decrypt=True)
|
||||
|
||||
@staticmethod
|
||||
def set_many(mapping: dict[str, tuple[object, bool] | object], company_id=None):
|
||||
for key, value in mapping.items():
|
||||
if isinstance(value, tuple):
|
||||
raw, encrypt = value
|
||||
else:
|
||||
raw, encrypt = value, False
|
||||
AppSetting.set(SettingsService._scope_key(key, company_id), raw, encrypt=encrypt)
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def storage_path(key: str, fallback: Path):
|
||||
value = SettingsService.get(key)
|
||||
if value:
|
||||
path = Path(value)
|
||||
if not path.is_absolute():
|
||||
path = Path(current_app.root_path).parent / path
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
fallback.mkdir(parents=True, exist_ok=True)
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def read_only_enabled(company_id=None) -> bool:
|
||||
from flask_login import current_user
|
||||
company = CompanyService.get_current_company()
|
||||
cid = company_id or (company.id if company else None)
|
||||
truly_global_ro = AppSetting.get('app.read_only_mode', 'false') == 'true'
|
||||
company_ro = AppSetting.get(f'company.{cid}.app.read_only_mode', 'false') == 'true' if cid else False
|
||||
user_ro = getattr(current_user, 'is_authenticated', False) and cid and current_user.is_company_readonly(cid)
|
||||
return truly_global_ro or company_ro or bool(user_ro)
|
||||
|
||||
@staticmethod
|
||||
def get_user(key, default=None, user_id=None):
|
||||
return AppSetting.get(SettingsService._user_scope_key(key, user_id), default=default)
|
||||
|
||||
@staticmethod
|
||||
def get_user_secret(key, default=None, user_id=None):
|
||||
return AppSetting.get(SettingsService._user_scope_key(key, user_id), default=default, decrypt=True)
|
||||
|
||||
@staticmethod
|
||||
def set_many_user(mapping: dict[str, tuple[object, bool] | object], user_id=None):
|
||||
for key, value in mapping.items():
|
||||
if isinstance(value, tuple):
|
||||
raw, encrypt = value
|
||||
else:
|
||||
raw, encrypt = value, False
|
||||
AppSetting.set(SettingsService._user_scope_key(key, user_id), raw, encrypt=encrypt)
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_preference(scope_name: str, default='global', user_id=None):
|
||||
return AppSetting.get(SettingsService._user_scope_key(f'pref.{scope_name}.mode', user_id), default=default)
|
||||
|
||||
@staticmethod
|
||||
def set_preference(scope_name: str, mode: str, user_id=None):
|
||||
AppSetting.set(SettingsService._user_scope_key(f'pref.{scope_name}.mode', user_id), mode)
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_effective(key, default=None, company_id=None, user_id=None, scope_name=None, user_default='global'):
|
||||
scope_name = scope_name or key.split('.', 1)[0]
|
||||
mode = SettingsService.get_preference(scope_name, default=user_default, user_id=user_id)
|
||||
if mode == 'user':
|
||||
value = SettingsService.get_user(key, default=None, user_id=user_id)
|
||||
if value not in [None, '']:
|
||||
return value
|
||||
return SettingsService.get(key, default=default, company_id=company_id)
|
||||
|
||||
@staticmethod
|
||||
def get_effective_secret(key, default=None, company_id=None, user_id=None, scope_name=None, user_default='global'):
|
||||
scope_name = scope_name or key.split('.', 1)[0]
|
||||
mode = SettingsService.get_preference(scope_name, default=user_default, user_id=user_id)
|
||||
if mode == 'user':
|
||||
value = SettingsService.get_user_secret(key, default=None, user_id=user_id)
|
||||
if value not in [None, '']:
|
||||
return value
|
||||
return SettingsService.get_secret(key, default=default, company_id=company_id)
|
||||
129
app/services/sync_service.py
Normal file
129
app/services/sync_service.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from datetime import datetime
|
||||
from threading import Thread
|
||||
from flask import current_app
|
||||
from app.extensions import db
|
||||
from app.models.company import Company
|
||||
from app.models.setting import AppSetting
|
||||
from app.models.sync_log import SyncLog
|
||||
from app.services.company_service import CompanyService
|
||||
from app.services.invoice_service import InvoiceService
|
||||
from app.services.ksef_service import KSeFService
|
||||
from app.services.notification_service import NotificationService
|
||||
from app.services.settings_service import SettingsService
|
||||
from app.services.redis_service import RedisService
|
||||
|
||||
|
||||
class SyncService:
|
||||
def __init__(self, company=None):
|
||||
self.company = company or CompanyService.get_current_company()
|
||||
self.ksef = KSeFService(company_id=self.company.id if self.company else None)
|
||||
self.invoice_service = InvoiceService()
|
||||
self.notification_service = NotificationService(company_id=self.company.id if self.company else None)
|
||||
|
||||
def _run(self, sync_type='manual', existing_log=None):
|
||||
log = existing_log or SyncLog(
|
||||
company_id=self.company.id if self.company else None,
|
||||
sync_type=sync_type,
|
||||
status='started',
|
||||
started_at=datetime.utcnow(),
|
||||
message='Rozpoczęto synchronizację',
|
||||
)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
|
||||
since_raw = SettingsService.get('ksef.last_sync_at', company_id=self.company.id if self.company else None)
|
||||
since = datetime.fromisoformat(since_raw) if since_raw else None
|
||||
created = updated = errors = 0
|
||||
|
||||
try:
|
||||
documents = self.ksef.list_documents(since=since)
|
||||
log.total = len(documents)
|
||||
db.session.commit()
|
||||
|
||||
for idx, document in enumerate(documents, start=1):
|
||||
invoice, was_created = self.invoice_service.upsert_from_ksef(document, self.company)
|
||||
if was_created:
|
||||
created += 1
|
||||
self.notification_service.notify_new_invoice(invoice)
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
log.processed = idx
|
||||
log.created = created
|
||||
log.updated = updated
|
||||
log.message = f'Przetworzono {idx}/{len(documents)}'
|
||||
db.session.commit()
|
||||
|
||||
log.status = 'finished'
|
||||
log.message = 'Synchronizacja zakończona'
|
||||
SettingsService.set_many(
|
||||
{
|
||||
'ksef.status': 'ready',
|
||||
'ksef.last_sync_at': datetime.utcnow().isoformat(),
|
||||
},
|
||||
company_id=self.company.id if self.company else None,
|
||||
)
|
||||
|
||||
except RuntimeError as exc:
|
||||
message = str(exc)
|
||||
if 'HTTP 429' in message or 'ogranicza liczbę zapytań' in message:
|
||||
current_app.logger.warning('Synchronizacja KSeF wstrzymana przez limit API: %s', message)
|
||||
else:
|
||||
current_app.logger.error('Sync failed: %s', message)
|
||||
log.status = 'error'
|
||||
log.message = message
|
||||
errors += 1
|
||||
SettingsService.set_many(
|
||||
{'ksef.status': 'error'},
|
||||
company_id=self.company.id if self.company else None,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
current_app.logger.exception('Sync failed: %s', exc)
|
||||
log.status = 'error'
|
||||
log.message = str(exc)
|
||||
errors += 1
|
||||
SettingsService.set_many(
|
||||
{'ksef.status': 'error'},
|
||||
company_id=self.company.id if self.company else None,
|
||||
)
|
||||
|
||||
RedisService.delete(f'dashboard.summary.company.{self.company.id if self.company else "global"}')
|
||||
RedisService.delete(f'health.status.company.{self.company.id if self.company else "global"}')
|
||||
log.errors = errors
|
||||
log.finished_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
return log
|
||||
|
||||
def run_manual_sync(self):
|
||||
return self._run('manual')
|
||||
|
||||
def run_scheduled_sync(self):
|
||||
return self._run('scheduled')
|
||||
|
||||
@staticmethod
|
||||
def start_manual_sync_async(app, company_id):
|
||||
company = db.session.get(Company, company_id)
|
||||
log = SyncLog(
|
||||
company_id=company_id,
|
||||
sync_type='manual',
|
||||
status='queued',
|
||||
started_at=datetime.utcnow(),
|
||||
message='Zadanie zakolejkowane',
|
||||
total=1,
|
||||
)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
log_id = log.id
|
||||
|
||||
def worker():
|
||||
with app.app_context():
|
||||
company_local = db.session.get(Company, company_id)
|
||||
log_local = db.session.get(SyncLog, log_id)
|
||||
log_local.status = 'started'
|
||||
log_local.message = 'Start pobierania'
|
||||
db.session.commit()
|
||||
SyncService(company_local)._run('manual', existing_log=log_local)
|
||||
|
||||
Thread(target=worker, daemon=True).start()
|
||||
return log_id
|
||||
277
app/services/system_data_service.py
Normal file
277
app/services/system_data_service.py
Normal file
@@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
from flask import current_app
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.catalog import Customer, InvoiceLine, Product
|
||||
from app.models.company import Company, UserCompanyAccess
|
||||
from app.models.invoice import Invoice, MailDelivery, NotificationLog, SyncEvent, Tag
|
||||
from app.models.setting import AppSetting
|
||||
from app.models.sync_log import SyncLog
|
||||
from app.models.user import User
|
||||
from app.services.ceidg_service import CeidgService
|
||||
from app.services.company_service import CompanyService
|
||||
from app.services.health_service import HealthService
|
||||
from app.services.ksef_service import KSeFService
|
||||
from app.services.settings_service import SettingsService
|
||||
|
||||
|
||||
class SystemDataService:
|
||||
APP_MODELS = [
|
||||
('Użytkownicy', User),
|
||||
('Firmy', Company),
|
||||
('Dostępy do firm', UserCompanyAccess),
|
||||
('Faktury', Invoice),
|
||||
('Pozycje faktur', InvoiceLine),
|
||||
('Klienci', Customer),
|
||||
('Produkty', Product),
|
||||
('Tagi', Tag),
|
||||
('Logi synchronizacji', SyncLog),
|
||||
('Zdarzenia sync', SyncEvent),
|
||||
('Wysyłki maili', MailDelivery),
|
||||
('Notyfikacje', NotificationLog),
|
||||
('Logi audytu', AuditLog),
|
||||
('Ustawienia', AppSetting),
|
||||
]
|
||||
|
||||
def collect(self) -> dict:
|
||||
company = CompanyService.get_current_company()
|
||||
company_id = company.id if company else None
|
||||
process = self._process_stats()
|
||||
storage = self._storage_stats()
|
||||
app = self._app_stats(company)
|
||||
database = self._database_stats()
|
||||
health = HealthService().get_status()
|
||||
ksef = KSeFService(company_id=company_id).diagnostics()
|
||||
ceidg = CeidgService().diagnostics()
|
||||
return {
|
||||
'overview': self._overview_cards(process, storage, app, database, health, ksef, ceidg),
|
||||
'process': process,
|
||||
'storage': storage,
|
||||
'app': app,
|
||||
'database': database,
|
||||
'health': health,
|
||||
'integrations': {
|
||||
'ksef': ksef,
|
||||
'ceidg': ceidg,
|
||||
},
|
||||
}
|
||||
|
||||
def _overview_cards(self, process: dict, storage: list[dict], app: dict, database: dict, health: dict, ksef: dict, ceidg: dict) -> list[dict]:
|
||||
storage_total = sum(item['size_bytes'] for item in storage)
|
||||
total_records = sum(item['rows'] for item in database['table_rows'])
|
||||
return [
|
||||
{
|
||||
'label': 'CPU procesu',
|
||||
'value': f"{process['cpu_percent']:.2f}%",
|
||||
'subvalue': f"PID {process['pid']} · {process['threads']} wątków",
|
||||
'icon': 'fa-microchip',
|
||||
'tone': 'primary',
|
||||
},
|
||||
{
|
||||
'label': 'RAM procesu',
|
||||
'value': process['rss_human'],
|
||||
'subvalue': f"System zajęty: {process['system_memory_percent']:.2f}% z {process['system_memory_total']}",
|
||||
'icon': 'fa-memory',
|
||||
'tone': 'info',
|
||||
},
|
||||
{
|
||||
'label': 'Katalogi robocze',
|
||||
'value': self._human_size(storage_total),
|
||||
'subvalue': f'{len(storage)} lokalizacji monitorowanych',
|
||||
'icon': 'fa-hard-drive',
|
||||
'tone': 'warning',
|
||||
},
|
||||
{
|
||||
'label': 'Użytkownicy / firmy',
|
||||
'value': f"{app['users_count']} / {app['companies_count']}",
|
||||
'subvalue': f"R/O: {'ON' if app['read_only_global'] else 'OFF'}",
|
||||
'icon': 'fa-users',
|
||||
'tone': 'secondary',
|
||||
},
|
||||
{
|
||||
'label': 'Rekordy bazy',
|
||||
'value': str(total_records),
|
||||
'subvalue': f"{database['tables_count']} tabel · {database['engine']}",
|
||||
'icon': 'fa-database',
|
||||
'tone': 'secondary',
|
||||
},
|
||||
{
|
||||
'label': 'Health',
|
||||
'value': self._health_summary(health),
|
||||
'subvalue': f"DB {health.get('db')} · SMTP {health.get('smtp')} · Redis {health.get('redis')}",
|
||||
'icon': 'fa-heart-pulse',
|
||||
'tone': 'success' if health.get('db') == 'ok' and health.get('ksef') in ['ok', 'mock'] else 'warning',
|
||||
},
|
||||
{
|
||||
'label': 'KSeF',
|
||||
'value': ksef.get('status', 'unknown').upper(),
|
||||
'subvalue': ksef.get('message', 'Brak danych'),
|
||||
'icon': 'fa-file-invoice',
|
||||
'tone': 'success' if ksef.get('status') in ['ok', 'mock'] else 'danger',
|
||||
},
|
||||
{
|
||||
'label': 'CEIDG',
|
||||
'value': ceidg.get('status', 'unknown').upper(),
|
||||
'subvalue': ceidg.get('message', 'Brak danych'),
|
||||
'icon': 'fa-building-circle-check',
|
||||
'tone': 'success' if ceidg.get('status') == 'ok' else 'danger',
|
||||
},
|
||||
]
|
||||
|
||||
def _process_stats(self) -> dict:
|
||||
process = psutil.Process(os.getpid())
|
||||
cpu_percent = process.cpu_percent(interval=0.1)
|
||||
mem = process.memory_info()
|
||||
system_mem = psutil.virtual_memory()
|
||||
try:
|
||||
open_files = len(process.open_files())
|
||||
except Exception:
|
||||
open_files = 0
|
||||
return {
|
||||
'pid': process.pid,
|
||||
'cpu_percent': round(cpu_percent, 2),
|
||||
'rss_bytes': int(mem.rss),
|
||||
'rss_human': self._human_size(mem.rss),
|
||||
'system_memory_total': self._human_size(system_mem.total),
|
||||
'system_memory_percent': round(system_mem.percent, 2),
|
||||
'threads': process.num_threads(),
|
||||
'open_files': open_files,
|
||||
'platform': platform.platform(),
|
||||
'python': platform.python_version(),
|
||||
}
|
||||
|
||||
def _storage_stats(self) -> list[dict]:
|
||||
locations = [
|
||||
('Instancja', Path(current_app.instance_path)),
|
||||
('Archiwum XML', SettingsService.storage_path('app.archive_path', current_app.config['ARCHIVE_PATH'])),
|
||||
('PDF', SettingsService.storage_path('app.pdf_path', current_app.config['PDF_PATH'])),
|
||||
('Backupy', SettingsService.storage_path('app.backup_path', current_app.config['BACKUP_PATH'])),
|
||||
('Certyfikaty', SettingsService.storage_path('app.certs_path', current_app.config['CERTS_PATH'])),
|
||||
]
|
||||
rows = []
|
||||
for label, path in locations:
|
||||
size_bytes = self._dir_size(path)
|
||||
usage = psutil.disk_usage(str(path if path.exists() else path.parent))
|
||||
rows.append({
|
||||
'label': label,
|
||||
'path': str(path),
|
||||
'size_bytes': size_bytes,
|
||||
'size_human': self._human_size(size_bytes),
|
||||
'disk_total': self._human_size(usage.total),
|
||||
'disk_free': self._human_size(usage.free),
|
||||
'disk_percent': round(usage.percent, 2),
|
||||
})
|
||||
return rows
|
||||
|
||||
def _app_stats(self, company) -> dict:
|
||||
users_count = User.query.count()
|
||||
companies_count = Company.query.count()
|
||||
counts = [{'label': label, 'count': model.query.count()} for label, model in self.APP_MODELS]
|
||||
counts_sorted = sorted(counts, key=lambda item: item['count'], reverse=True)
|
||||
return {
|
||||
'current_company': company.name if company else 'Brak wybranej firmy',
|
||||
'current_company_id': company.id if company else None,
|
||||
'read_only_global': AppSetting.get('app.read_only_mode', 'false') == 'true',
|
||||
'app_timezone': current_app.config.get('APP_TIMEZONE'),
|
||||
'counts': counts_sorted,
|
||||
'counts_top': counts_sorted[:6],
|
||||
'users_count': int(users_count),
|
||||
'companies_count': int(companies_count),
|
||||
}
|
||||
|
||||
def _database_stats(self) -> dict:
|
||||
engine = db.engine
|
||||
inspector = inspect(engine)
|
||||
table_names = inspector.get_table_names()
|
||||
table_rows = []
|
||||
for table_name in table_names:
|
||||
table = db.metadata.tables.get(table_name)
|
||||
if table is None:
|
||||
continue
|
||||
count = db.session.execute(db.select(db.func.count()).select_from(table)).scalar() or 0
|
||||
table_rows.append({'table': table_name, 'rows': int(count)})
|
||||
uri = current_app.config.get('SQLALCHEMY_DATABASE_URI', '')
|
||||
sqlite_path = None
|
||||
sqlite_size = None
|
||||
if uri.startswith('sqlite:///') and not uri.endswith(':memory:'):
|
||||
sqlite_path = uri.replace('sqlite:///', '', 1)
|
||||
try:
|
||||
sqlite_size = self._human_size(Path(sqlite_path).stat().st_size)
|
||||
except FileNotFoundError:
|
||||
sqlite_size = 'brak pliku'
|
||||
table_rows_sorted = sorted(table_rows, key=lambda item: (-item['rows'], item['table']))
|
||||
return {
|
||||
'engine': engine.name,
|
||||
'uri': self._mask_uri(uri),
|
||||
'tables_count': len(table_rows),
|
||||
'sqlite_path': sqlite_path,
|
||||
'sqlite_size': sqlite_size,
|
||||
'table_rows': table_rows_sorted,
|
||||
'largest_tables': table_rows_sorted[:6],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def json_preview(payload, max_len: int = 1200) -> str:
|
||||
if payload is None:
|
||||
return 'Brak danych.'
|
||||
if isinstance(payload, str):
|
||||
text = payload
|
||||
else:
|
||||
text = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
||||
return text if len(text) <= max_len else text[:max_len] + '\n...'
|
||||
|
||||
@staticmethod
|
||||
def _dir_size(path: Path) -> int:
|
||||
total = 0
|
||||
if not path.exists():
|
||||
return total
|
||||
if path.is_file():
|
||||
return path.stat().st_size
|
||||
for root, _, files in os.walk(path):
|
||||
for filename in files:
|
||||
try:
|
||||
total += (Path(root) / filename).stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
return total
|
||||
|
||||
@staticmethod
|
||||
def _human_size(size: int | float) -> str:
|
||||
value = float(size or 0)
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if value < 1024 or unit == 'TB':
|
||||
return f'{value:.2f} {unit}'
|
||||
value /= 1024
|
||||
return f'{value:.2f} TB'
|
||||
|
||||
@staticmethod
|
||||
def _mask_uri(uri: str) -> str:
|
||||
if '@' in uri and '://' in uri:
|
||||
prefix, suffix = uri.split('://', 1)
|
||||
credentials, rest = suffix.split('@', 1)
|
||||
if ':' in credentials:
|
||||
user, _ = credentials.split(':', 1)
|
||||
return f'{prefix}://{user}:***@{rest}'
|
||||
return uri
|
||||
|
||||
@staticmethod
|
||||
def _health_summary(health: dict) -> str:
|
||||
tracked = {
|
||||
'Baza': health.get('db'),
|
||||
'SMTP': health.get('smtp'),
|
||||
'Redis': health.get('redis'),
|
||||
'KSeF': health.get('ksef'),
|
||||
'CEIDG': health.get('ceidg'),
|
||||
}
|
||||
ok = sum(1 for value in tracked.values() if value in ['ok', 'mock', 'configured', 'fallback'])
|
||||
total = len(tracked)
|
||||
return f'{ok}/{total} OK'
|
||||
0
app/settings/__init__.py
Normal file
0
app/settings/__init__.py
Normal file
262
app/settings/routes.py
Normal file
262
app/settings/routes.py
Normal 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
71
app/static/css/app.css
Normal 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; } }
|
||||
21
app/templates/admin/_nav.html
Normal file
21
app/templates/admin/_nav.html
Normal 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>
|
||||
5
app/templates/admin/admin_base.html
Normal file
5
app/templates/admin/admin_base.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
{% include 'admin/_nav.html' %}
|
||||
{% block admin_content %}{% endblock %}
|
||||
{% endblock %}
|
||||
5
app/templates/admin/audit.html
Normal file
5
app/templates/admin/audit.html
Normal 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 %}
|
||||
6
app/templates/admin/companies.html
Normal file
6
app/templates/admin/companies.html
Normal 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 %}
|
||||
48
app/templates/admin/company_form.html
Normal file
48
app/templates/admin/company_form.html
Normal 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 %}
|
||||
15
app/templates/admin/global_settings.html
Normal file
15
app/templates/admin/global_settings.html
Normal 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 %}
|
||||
11
app/templates/admin/health.html
Normal file
11
app/templates/admin/health.html
Normal 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 %}
|
||||
229
app/templates/admin/index.html
Normal file
229
app/templates/admin/index.html
Normal 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 %}
|
||||
79
app/templates/admin/maintenance.html
Normal file
79
app/templates/admin/maintenance.html
Normal 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 %}
|
||||
5
app/templates/admin/reset_password.html
Normal file
5
app/templates/admin/reset_password.html
Normal 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 %}
|
||||
215
app/templates/admin/system_data.html
Normal file
215
app/templates/admin/system_data.html
Normal 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 %}
|
||||
8
app/templates/admin/user_access.html
Normal file
8
app/templates/admin/user_access.html
Normal 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 %}
|
||||
38
app/templates/admin/user_form.html
Normal file
38
app/templates/admin/user_form.html
Normal 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 %}
|
||||
9
app/templates/admin/users.html
Normal file
9
app/templates/admin/users.html
Normal 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 %}
|
||||
41
app/templates/auth/login.html
Normal file
41
app/templates/auth/login.html
Normal 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
96
app/templates/base.html
Normal 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>
|
||||
135
app/templates/dashboard/index.html
Normal file
135
app/templates/dashboard/index.html
Normal 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 %}
|
||||
1
app/templates/errors/403.html
Normal file
1
app/templates/errors/403.html
Normal 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 %}
|
||||
1
app/templates/errors/404.html
Normal file
1
app/templates/errors/404.html
Normal 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 %}
|
||||
1
app/templates/errors/500.html
Normal file
1
app/templates/errors/500.html
Normal 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 %}
|
||||
1
app/templates/errors/503.html
Normal file
1
app/templates/errors/503.html
Normal 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 %}
|
||||
62
app/templates/invoices/customers.html
Normal file
62
app/templates/invoices/customers.html
Normal 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 %}
|
||||
103
app/templates/invoices/detail.html
Normal file
103
app/templates/invoices/detail.html
Normal 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 %}
|
||||
149
app/templates/invoices/index.html
Normal file
149
app/templates/invoices/index.html
Normal 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 %}
|
||||
120
app/templates/invoices/issued_form.html
Normal file
120
app/templates/invoices/issued_form.html
Normal 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 %}
|
||||
12
app/templates/invoices/issued_list.html
Normal file
12
app/templates/invoices/issued_list.html
Normal 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 %}
|
||||
1
app/templates/invoices/month_pdf.html
Normal file
1
app/templates/invoices/month_pdf.html
Normal 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>
|
||||
9
app/templates/invoices/monthly.html
Normal file
9
app/templates/invoices/monthly.html
Normal 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 %}
|
||||
1
app/templates/invoices/pdf.html
Normal file
1
app/templates/invoices/pdf.html
Normal 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>
|
||||
65
app/templates/invoices/products.html
Normal file
65
app/templates/invoices/products.html
Normal 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 %}
|
||||
78
app/templates/nfz/index.html
Normal file
78
app/templates/nfz/index.html
Normal 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 %}
|
||||
31
app/templates/notifications/index.html
Normal file
31
app/templates/notifications/index.html
Normal 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 %}
|
||||
60
app/templates/partials/invoice_quick_add_modals.html
Normal file
60
app/templates/partials/invoice_quick_add_modals.html
Normal 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>
|
||||
8
app/templates/partials/page_header.html
Normal file
8
app/templates/partials/page_header.html
Normal 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>
|
||||
26
app/templates/partials/payment_modal.html
Normal file
26
app/templates/partials/payment_modal.html
Normal 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>
|
||||
69
app/templates/settings/index.html
Normal file
69
app/templates/settings/index.html
Normal 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
Reference in New Issue
Block a user