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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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