Initial commit
This commit is contained in:
27
backend/README.md
Normal file
27
backend/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Django Port
|
||||
|
||||
This is the replacement backend for the legacy FastAPI app.
|
||||
|
||||
## Shape
|
||||
|
||||
- `config/`: Django project settings and URL root
|
||||
- `apps/accounts`: users, roles, business access, device registration
|
||||
- `apps/core`: businesses, vendors, products, categories
|
||||
- `apps/operations`: invoices, inventory, events, schedules
|
||||
- `apps/reporting`: dashboard-facing revenue aggregates
|
||||
- `apps/notifications`: reminders and inbox items
|
||||
- `apps/api`: JSON endpoints for the Svelte frontend
|
||||
|
||||
## Expected setup
|
||||
|
||||
1. Install dependencies from [`pyproject.toml`](/home/sandy/HUB-master/django-port/backend/pyproject.toml).
|
||||
2. Run `python manage.py makemigrations`.
|
||||
3. Run `python manage.py migrate`.
|
||||
4. Run `python manage.py createsuperuser`.
|
||||
5. Run `python manage.py import_legacy_data`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Auth uses Django sessions instead of custom JWT cookies.
|
||||
- The import command consolidates both legacy SQLite files into one Django schema.
|
||||
- Password hashes are not carried over directly; imported users get a forced reset placeholder.
|
||||
1
backend/apps/__init__.py
Normal file
1
backend/apps/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/apps/accounts/__init__.py
Normal file
1
backend/apps/accounts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
30
backend/apps/accounts/admin.py
Normal file
30
backend/apps/accounts/admin.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
|
||||
from .models import (
|
||||
AllowedDevice,
|
||||
DeviceRegistrationToken,
|
||||
DomainPermission,
|
||||
Role,
|
||||
RolePermission,
|
||||
User,
|
||||
UserBusinessAccess,
|
||||
UserDeviceLogin,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class HubUserAdmin(UserAdmin):
|
||||
fieldsets = UserAdmin.fieldsets + (
|
||||
("Hub", {"fields": ("display_name", "role", "last_login_ip")}),
|
||||
)
|
||||
list_display = ("username", "display_name", "role", "is_active", "last_login")
|
||||
|
||||
|
||||
admin.site.register(Role)
|
||||
admin.site.register(DomainPermission)
|
||||
admin.site.register(RolePermission)
|
||||
admin.site.register(UserBusinessAccess)
|
||||
admin.site.register(UserDeviceLogin)
|
||||
admin.site.register(AllowedDevice)
|
||||
admin.site.register(DeviceRegistrationToken)
|
||||
7
backend/apps/accounts/apps.py
Normal file
7
backend/apps/accounts/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.accounts"
|
||||
label = "accounts"
|
||||
1
backend/apps/accounts/management/__init__.py
Normal file
1
backend/apps/accounts/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/apps/accounts/management/commands/__init__.py
Normal file
1
backend/apps/accounts/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
541
backend/apps/accounts/management/commands/import_legacy_data.py
Normal file
541
backend/apps/accounts/management/commands/import_legacy_data.py
Normal file
@@ -0,0 +1,541 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.utils.dateparse import parse_date, parse_datetime
|
||||
from django.utils.timezone import make_aware
|
||||
|
||||
from apps.accounts.models import (
|
||||
AllowedDevice,
|
||||
DeviceRegistrationToken,
|
||||
DomainPermission,
|
||||
Role,
|
||||
RolePermission,
|
||||
User,
|
||||
UserBusinessAccess,
|
||||
UserDeviceLogin,
|
||||
)
|
||||
from apps.core.models import Business, Category, Product, ProductCategory, Vendor, VendorBusiness, VendorCategory
|
||||
from apps.notifications.models import Notification
|
||||
from apps.operations.models import (
|
||||
Event,
|
||||
InventoryBalance,
|
||||
InventoryBulkMapping,
|
||||
InventoryMovement,
|
||||
Invoice,
|
||||
InvoiceCategory,
|
||||
InvoiceLineItem,
|
||||
ShiftAssignment,
|
||||
ShiftRole,
|
||||
ShiftTemplate,
|
||||
StockCount,
|
||||
StockCountLine,
|
||||
WorkerAvailability,
|
||||
)
|
||||
from apps.reporting.models import DailyRevenueSummary
|
||||
|
||||
|
||||
def _dt(value):
|
||||
if not value:
|
||||
return None
|
||||
parsed = parse_datetime(value)
|
||||
if parsed is None:
|
||||
return None
|
||||
return make_aware(parsed) if parsed.tzinfo is None else parsed
|
||||
|
||||
|
||||
def _date(value):
|
||||
return parse_date(value) if value else None
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Import the legacy FastAPI SQLite databases into the Django schema."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--cincin", type=Path, default=settings.LEGACY_CINCIN_DB)
|
||||
parser.add_argument("--dalcorso", type=Path, default=settings.LEGACY_DALCORSO_DB)
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
cincin = options["cincin"]
|
||||
dalcorso = options["dalcorso"]
|
||||
if not cincin.exists():
|
||||
raise CommandError(f"CinCin DB not found: {cincin}")
|
||||
if not dalcorso.exists():
|
||||
raise CommandError(f"Dal Corso DB not found: {dalcorso}")
|
||||
|
||||
self._import_db(cincin, include_operational_data=True)
|
||||
self._import_db(dalcorso, include_operational_data=False)
|
||||
self.stdout.write(self.style.SUCCESS("Legacy import complete."))
|
||||
|
||||
def _connect(self, path: Path):
|
||||
conn = sqlite3.connect(path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _rows(self, conn, query: str):
|
||||
return conn.execute(query).fetchall()
|
||||
|
||||
def _table_exists(self, conn, table_name: str) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
|
||||
(table_name,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def _import_db(self, path: Path, include_operational_data: bool):
|
||||
self.business_map: dict[int, Business] = {}
|
||||
self.category_map: dict[int, Category] = {}
|
||||
self.vendor_map: dict[int, Vendor] = {}
|
||||
self.product_map: dict[int, Product] = {}
|
||||
self.user_map: dict[int, User] = {}
|
||||
self.role_map: dict[int, Role] = {}
|
||||
self.shift_role_map: dict[int, ShiftRole] = {}
|
||||
self.shift_template_map: dict[int, ShiftTemplate] = {}
|
||||
with self._connect(path) as conn:
|
||||
self._import_businesses(conn)
|
||||
self._import_categories(conn)
|
||||
self._import_vendors(conn)
|
||||
self._import_products(conn)
|
||||
self._import_invoices(conn)
|
||||
if include_operational_data:
|
||||
self._import_accounts(conn)
|
||||
self._import_devices(conn)
|
||||
self._import_notifications(conn)
|
||||
self._import_inventory(conn)
|
||||
self._import_events_and_shifts(conn)
|
||||
self._import_reporting(conn)
|
||||
|
||||
def _import_businesses(self, conn):
|
||||
for row in self._rows(conn, "SELECT * FROM dim_business"):
|
||||
business, _ = Business.objects.update_or_create(
|
||||
short_code=row["short_code"],
|
||||
defaults={
|
||||
"legacy_id": row["business_id"],
|
||||
"name": row["name"],
|
||||
"currency": row["currency"],
|
||||
"is_active": bool(row["is_active"]),
|
||||
},
|
||||
)
|
||||
self.business_map[row["business_id"]] = business
|
||||
|
||||
def _import_categories(self, conn):
|
||||
for row in self._rows(conn, "SELECT * FROM dim_category"):
|
||||
category, _ = Category.objects.update_or_create(
|
||||
name=row["name"],
|
||||
defaults={"legacy_id": row["category_id"], "is_active": bool(row["is_active"])},
|
||||
)
|
||||
self.category_map[row["category_id"]] = category
|
||||
|
||||
def _import_vendors(self, conn):
|
||||
for row in self._rows(conn, "SELECT * FROM dim_vendor"):
|
||||
vendor, _ = Vendor.objects.update_or_create(
|
||||
name=row["name"],
|
||||
defaults={
|
||||
"legacy_id": row["vendor_id"],
|
||||
"name": row["name"],
|
||||
"vat_number": row["vat_number"] or "",
|
||||
"registration_id": row["registration_id"] or "",
|
||||
"contact_email": row["contact_email"] or "",
|
||||
"contact_phone": row["contact_phone"] or "",
|
||||
"address": row["address"] or "",
|
||||
"notes": row["notes"] or "",
|
||||
"is_active": bool(row["is_active"]),
|
||||
},
|
||||
)
|
||||
self.vendor_map[row["vendor_id"]] = vendor
|
||||
if self._table_exists(conn, "vendor_business"):
|
||||
for link in self._rows(conn, f"SELECT business_id FROM vendor_business WHERE vendor_id = {row['vendor_id']}"):
|
||||
business = self.business_map.get(link["business_id"])
|
||||
if business:
|
||||
VendorBusiness.objects.get_or_create(vendor=vendor, business=business)
|
||||
if self._table_exists(conn, "vendor_category"):
|
||||
for link in self._rows(conn, f"SELECT category_id FROM vendor_category WHERE vendor_id = {row['vendor_id']}"):
|
||||
category = self.category_map.get(link["category_id"])
|
||||
if category:
|
||||
VendorCategory.objects.get_or_create(vendor=vendor, category=category)
|
||||
|
||||
def _import_products(self, conn):
|
||||
for row in self._rows(conn, "SELECT * FROM dim_product"):
|
||||
lookup = {"gtin": row["gtin"] or "", "name": row["name"]}
|
||||
product, _ = Product.objects.update_or_create(
|
||||
**lookup,
|
||||
defaults={
|
||||
"legacy_id": row["product_id"],
|
||||
"name": row["name"],
|
||||
"ledger": row["ledger"] or "",
|
||||
"product_status": row["product_status"] or "",
|
||||
"is_active": bool(row["is_active"]),
|
||||
"tax_type": row["tax_type"] or "",
|
||||
"vat_rate": row["vat_rate"] or 0,
|
||||
"net_purchase_price": row["net_purchase_price"] or 0,
|
||||
"display_sales_price": row["display_sales_price"] or 0,
|
||||
"uom": row["uom"] or "pcs",
|
||||
"amount": row["amount"] or 0,
|
||||
"is_placeholder": bool(row["is_placeholder"]),
|
||||
"short_name": row["short_name"] or "",
|
||||
"currency_code": row["currency_code"] or "CZK",
|
||||
},
|
||||
)
|
||||
self.product_map[row["product_id"]] = product
|
||||
if self._table_exists(conn, "product_category"):
|
||||
for link in self._rows(conn, f"SELECT category_id FROM product_category WHERE product_id = {row['product_id']}"):
|
||||
category = self.category_map.get(link["category_id"])
|
||||
if category:
|
||||
ProductCategory.objects.get_or_create(product=product, category=category)
|
||||
|
||||
def _import_invoices(self, conn):
|
||||
for row in self._rows(conn, "SELECT * FROM invoice"):
|
||||
business = self.business_map.get(row["business_id"]) if row["business_id"] else None
|
||||
vendor = self.vendor_map.get(row["vendor_id"])
|
||||
if vendor is None:
|
||||
continue
|
||||
invoice, _ = Invoice.objects.update_or_create(
|
||||
legacy_id=row["invoice_id"],
|
||||
defaults={
|
||||
"business": business,
|
||||
"vendor": vendor,
|
||||
"vendor_not_configured": bool(row["vendor_not_configured"]),
|
||||
"vendor_config_note": row["vendor_config_note"] or "",
|
||||
"invoice_number": row["invoice_number"] or "",
|
||||
"invoice_date": _date(row["invoice_date"]),
|
||||
"order_date": _date(row["order_date"]),
|
||||
"entered_at": _dt(row["entered_at"]),
|
||||
"payment_status": row["payment_status"],
|
||||
"paid_date": _date(row["paid_date"]),
|
||||
"due_date": _date(row["due_date"]),
|
||||
"subtotal": row["subtotal"] or 0,
|
||||
"discount_pct": row["discount_pct"] or 0,
|
||||
"discount_amount": row["discount_amount"] or 0,
|
||||
"total_after_discount": row["total_after_discount"] or 0,
|
||||
"vat_amount": row["vat_amount"] or 0,
|
||||
"gross_total": row["gross_total"] or 0,
|
||||
"currency": row["currency"] or "CZK",
|
||||
"goods_received_status": row["goods_received_status"],
|
||||
"goods_date": _date(row["goods_date"]),
|
||||
"notes": row["notes"] or "",
|
||||
"is_editable": bool(row["is_editable"]),
|
||||
"inventory_updated": bool(row["inventory_updated"]),
|
||||
"rate_czk_eur": row["rate_czk_eur"] or 0,
|
||||
"rate_czk_usd": row["rate_czk_usd"] or 0,
|
||||
"vat_exempt": bool(row["vat_exempt"]),
|
||||
"created_at": _dt(row["created_at"]),
|
||||
"updated_at": _dt(row["updated_at"]),
|
||||
},
|
||||
)
|
||||
for line in self._rows(conn, f"SELECT * FROM invoice_line_item WHERE invoice_id = {row['invoice_id']}"):
|
||||
product = self.product_map.get(line["product_id"])
|
||||
if product is None:
|
||||
continue
|
||||
InvoiceLineItem.objects.update_or_create(
|
||||
legacy_id=line["line_item_id"],
|
||||
defaults={
|
||||
"invoice": invoice,
|
||||
"product": product,
|
||||
"quantity": line["quantity"],
|
||||
"unit_price": line["unit_price"],
|
||||
"total_price": line["total_price"],
|
||||
"vat_rate": line["vat_rate"] or 0,
|
||||
"vat_amount": line["vat_amount"] or 0,
|
||||
"line_order": line["line_order"],
|
||||
"created_at": _dt(line["created_at"]),
|
||||
},
|
||||
)
|
||||
if self._table_exists(conn, "invoice_category"):
|
||||
for link in self._rows(conn, f"SELECT category_id FROM invoice_category WHERE invoice_id = {row['invoice_id']}"):
|
||||
category = self.category_map.get(link["category_id"])
|
||||
if category:
|
||||
InvoiceCategory.objects.get_or_create(invoice=invoice, category=category)
|
||||
|
||||
def _import_accounts(self, conn):
|
||||
if not self._table_exists(conn, "app_role"):
|
||||
return
|
||||
for row in self._rows(conn, "SELECT * FROM app_role"):
|
||||
role, _ = Role.objects.update_or_create(
|
||||
name=row["name"],
|
||||
defaults={
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"is_system": bool(row["is_system"]),
|
||||
},
|
||||
)
|
||||
self.role_map[row["role_id"]] = role
|
||||
for row in self._rows(conn, "SELECT * FROM role_permission"):
|
||||
permission, _ = DomainPermission.objects.get_or_create(
|
||||
key=row["permission_key"],
|
||||
defaults={"label": row["permission_key"], "group": row["permission_key"].split(".")[0].title()},
|
||||
)
|
||||
role = self.role_map.get(row["role_id"])
|
||||
if role:
|
||||
RolePermission.objects.get_or_create(role=role, permission=permission)
|
||||
for row in self._rows(conn, "SELECT * FROM app_user"):
|
||||
user, _ = User.objects.update_or_create(
|
||||
username=row["username"],
|
||||
defaults={
|
||||
"password": make_password("imported-password-reset-required"),
|
||||
"display_name": row["display_name"] or "",
|
||||
"is_active": bool(row["is_active"]),
|
||||
"role": self.role_map.get(row["role_id"]),
|
||||
"last_login_ip": row["last_login_ip"],
|
||||
"last_login": _dt(row["last_login_at"]),
|
||||
},
|
||||
)
|
||||
self.user_map[row["user_id"]] = user
|
||||
if self._table_exists(conn, "user_business"):
|
||||
for link in self._rows(conn, f"SELECT business_id FROM user_business WHERE user_id = {row['user_id']}"):
|
||||
business = self.business_map.get(link["business_id"])
|
||||
if business:
|
||||
UserBusinessAccess.objects.get_or_create(user=user, business=business)
|
||||
if not self._table_exists(conn, "user_device_login"):
|
||||
return
|
||||
for row in self._rows(conn, "SELECT * FROM user_device_login"):
|
||||
user = self.user_map.get(row["user_id"])
|
||||
if user:
|
||||
UserDeviceLogin.objects.update_or_create(
|
||||
user=user,
|
||||
ip_address=row["ip_address"],
|
||||
defaults={"last_login_at": _dt(row["last_login_at"])},
|
||||
)
|
||||
|
||||
def _import_devices(self, conn):
|
||||
if not self._table_exists(conn, "allowed_device"):
|
||||
return
|
||||
for row in self._rows(conn, "SELECT * FROM allowed_device"):
|
||||
AllowedDevice.objects.update_or_create(
|
||||
id=row["device_id"],
|
||||
defaults={
|
||||
"ip_address": row["ip_address"],
|
||||
"label": row["label"] or "",
|
||||
"user_agent": row["user_agent"] or "",
|
||||
"last_seen_at": _dt(row["last_seen_at"]),
|
||||
"is_active": bool(row["is_active"]),
|
||||
"ipv6_prefix": row["ipv6_prefix"] or "",
|
||||
"device_token": row["device_token"] or "",
|
||||
"known_ips": row["known_ips"] or "",
|
||||
},
|
||||
)
|
||||
if not self._table_exists(conn, "device_registration_token"):
|
||||
return
|
||||
for row in self._rows(conn, "SELECT * FROM device_registration_token"):
|
||||
DeviceRegistrationToken.objects.update_or_create(
|
||||
id=row["token_id"],
|
||||
defaults={
|
||||
"token": row["token"],
|
||||
"label": row["label"] or "",
|
||||
"expires_at": _dt(row["expires_at"]),
|
||||
"used_at": _dt(row["used_at"]),
|
||||
"used_by_ip": row["used_by_ip"],
|
||||
"created_by": self.user_map.get(row["created_by"]),
|
||||
},
|
||||
)
|
||||
|
||||
def _import_notifications(self, conn):
|
||||
if not self._table_exists(conn, "notification"):
|
||||
return
|
||||
for row in self._rows(conn, "SELECT * FROM notification"):
|
||||
Notification.objects.update_or_create(
|
||||
legacy_id=row["notification_id"],
|
||||
defaults={
|
||||
"type": row["type"],
|
||||
"title": row["title"],
|
||||
"message": row["message"],
|
||||
"reference_type": row["reference_type"] or "",
|
||||
"reference_id": row["reference_id"],
|
||||
"is_read": bool(row["is_read"]),
|
||||
"is_dismissed": bool(row["is_dismissed"]),
|
||||
"remind_at": _dt(row["remind_at"]),
|
||||
"created_at": _dt(row["created_at"]),
|
||||
},
|
||||
)
|
||||
|
||||
def _import_inventory(self, conn):
|
||||
if not self._table_exists(conn, "inventory_balance"):
|
||||
return
|
||||
for row in self._rows(conn, "SELECT * FROM inventory_balance"):
|
||||
product = self.product_map.get(row["product_id"])
|
||||
if product:
|
||||
InventoryBalance.objects.update_or_create(
|
||||
product=product,
|
||||
defaults={
|
||||
"quantity_on_hand": row["quantity_on_hand"],
|
||||
"uom": row["uom"],
|
||||
"last_updated_at": _dt(row["last_updated_at"]),
|
||||
},
|
||||
)
|
||||
for row in self._rows(conn, "SELECT * FROM fact_inventory_movement"):
|
||||
product = self.product_map.get(row["product_id"])
|
||||
sellable = self.product_map.get(row["sellable_product_id"])
|
||||
if product:
|
||||
InventoryMovement.objects.update_or_create(
|
||||
legacy_id=row["inventory_movement_id"],
|
||||
defaults={
|
||||
"product": product,
|
||||
"sellable_product": sellable,
|
||||
"movement_ts": _dt(row["movement_ts"]),
|
||||
"movement_date": _date(row["movement_date"]),
|
||||
"movement_type": row["movement_type"],
|
||||
"quantity_delta": row["quantity_delta"],
|
||||
"uom": row["uom"],
|
||||
"source_type": row["source_type"],
|
||||
"source_ref": row["source_ref"] or "",
|
||||
},
|
||||
)
|
||||
for row in self._rows(conn, "SELECT * FROM inventory_bulk_mapping"):
|
||||
bulk = self.product_map.get(row["bulk_product_id"])
|
||||
sellable = self.product_map.get(row["sellable_product_id"])
|
||||
if bulk and sellable:
|
||||
InventoryBulkMapping.objects.update_or_create(
|
||||
legacy_id=row["mapping_id"],
|
||||
defaults={
|
||||
"bulk_product": bulk,
|
||||
"sellable_product": sellable,
|
||||
"decrement_amount": row["decrement_amount"],
|
||||
"decrement_uom": row["decrement_uom"] or "",
|
||||
"chip_name": row["chip_name"] or "",
|
||||
},
|
||||
)
|
||||
for row in self._rows(conn, "SELECT * FROM stock_count"):
|
||||
business = self.business_map.get(row["business_id"]) if row["business_id"] else None
|
||||
stock_count, _ = StockCount.objects.update_or_create(
|
||||
legacy_id=row["stock_count_id"],
|
||||
defaults={
|
||||
"business": business,
|
||||
"counted_by": row["counted_by"] or "",
|
||||
"count_date": _date(row["count_date"]),
|
||||
"notes": row["notes"] or "",
|
||||
"status": row["status"],
|
||||
"created_at": _dt(row["created_at"]),
|
||||
"completed_at": _dt(row["completed_at"]),
|
||||
},
|
||||
)
|
||||
for line in self._rows(conn, f"SELECT * FROM stock_count_line WHERE stock_count_id = {row['stock_count_id']}"):
|
||||
product = self.product_map.get(line["product_id"])
|
||||
if product:
|
||||
StockCountLine.objects.update_or_create(
|
||||
legacy_id=line["stock_count_line_id"],
|
||||
defaults={
|
||||
"stock_count": stock_count,
|
||||
"product": product,
|
||||
"full_units": line["full_units"],
|
||||
"partial_units": line["partial_units"],
|
||||
"total_quantity": line["total_quantity"],
|
||||
"previous_quantity": line["previous_quantity"] or 0,
|
||||
"created_at": _dt(line["created_at"]),
|
||||
},
|
||||
)
|
||||
|
||||
def _import_events_and_shifts(self, conn):
|
||||
if not self._table_exists(conn, "event_schedule"):
|
||||
return
|
||||
for row in self._rows(conn, "SELECT * FROM event_schedule"):
|
||||
business = self.business_map.get(row["business_id"])
|
||||
if business:
|
||||
Event.objects.update_or_create(
|
||||
legacy_id=row["event_id"],
|
||||
defaults={
|
||||
"business": business,
|
||||
"title": row["title"],
|
||||
"description": row["description"] or "",
|
||||
"event_type": row["event_type"] or "other",
|
||||
"start_datetime": _dt(row["start_datetime"]),
|
||||
"end_datetime": _dt(row["end_datetime"]),
|
||||
"all_day": bool(row["all_day"]),
|
||||
"location": row["location"] or "",
|
||||
"color": row["color"] or "",
|
||||
"recurrence_type": row["recurrence_type"] or "none",
|
||||
"recurrence_end_date": _date(row["recurrence_end_date"]),
|
||||
"created_by": self.user_map.get(row["created_by"]),
|
||||
"is_active": bool(row["is_active"]),
|
||||
},
|
||||
)
|
||||
for row in self._rows(conn, "SELECT * FROM shift_role"):
|
||||
business = self.business_map.get(row["business_id"])
|
||||
if business:
|
||||
shift_role, _ = ShiftRole.objects.update_or_create(
|
||||
legacy_id=row["shift_role_id"],
|
||||
defaults={
|
||||
"business": business,
|
||||
"name": row["name"],
|
||||
"color": row["color"] or "",
|
||||
"sort_order": row["sort_order"],
|
||||
"is_active": bool(row["is_active"]),
|
||||
},
|
||||
)
|
||||
self.shift_role_map[row["shift_role_id"]] = shift_role
|
||||
for row in self._rows(conn, "SELECT * FROM shift_template"):
|
||||
business = self.business_map.get(row["business_id"])
|
||||
if business:
|
||||
shift_template, _ = ShiftTemplate.objects.update_or_create(
|
||||
legacy_id=row["shift_id"],
|
||||
defaults={
|
||||
"business": business,
|
||||
"name": row["name"],
|
||||
"start_datetime": _dt(row["start_datetime"]),
|
||||
"end_datetime": _dt(row["end_datetime"]),
|
||||
"min_staff": row["min_staff"],
|
||||
"max_staff": row["max_staff"],
|
||||
"color": row["color"] or "",
|
||||
"recurrence_type": row["recurrence_type"] or "none",
|
||||
"recurrence_end_date": _date(row["recurrence_end_date"]),
|
||||
"created_by": self.user_map.get(row["created_by"]),
|
||||
"shift_role": self.shift_role_map.get(row["shift_role_id"]),
|
||||
"is_active": bool(row["is_active"]),
|
||||
},
|
||||
)
|
||||
self.shift_template_map[row["shift_id"]] = shift_template
|
||||
for row in self._rows(conn, "SELECT * FROM shift_assignment"):
|
||||
ShiftAssignment.objects.update_or_create(
|
||||
legacy_id=row["assignment_id"],
|
||||
defaults={
|
||||
"shift_template": self.shift_template_map.get(row["shift_id"]),
|
||||
"user": self.user_map.get(row["user_id"]),
|
||||
"occurrence_date": _date(row["occurrence_date"]),
|
||||
"start_override": _dt(row["start_override"]),
|
||||
"end_override": _dt(row["end_override"]),
|
||||
"status": row["status"],
|
||||
"notes": row["notes"] or "",
|
||||
"notification_sent_at": _dt(row["notification_sent_at"]),
|
||||
},
|
||||
)
|
||||
for row in self._rows(conn, "SELECT * FROM worker_availability"):
|
||||
WorkerAvailability.objects.update_or_create(
|
||||
legacy_id=row["availability_id"],
|
||||
defaults={
|
||||
"user": self.user_map.get(row["user_id"]),
|
||||
"business": self.business_map.get(row["business_id"]),
|
||||
"date": _date(row["date"]),
|
||||
"status": row["status"],
|
||||
"note": row["note"] or "",
|
||||
},
|
||||
)
|
||||
|
||||
def _import_reporting(self, conn):
|
||||
if not self._table_exists(conn, "daily_revenue_summary"):
|
||||
return
|
||||
default_business = (
|
||||
Business.objects.filter(short_code__iexact="CINCIN").first()
|
||||
or next(iter(self.business_map.values()), None)
|
||||
)
|
||||
for row in self._rows(conn, "SELECT * FROM daily_revenue_summary"):
|
||||
business = default_business
|
||||
if business:
|
||||
DailyRevenueSummary.objects.update_or_create(
|
||||
business=business,
|
||||
business_date=_date(row["business_date"]),
|
||||
defaults={
|
||||
"sales_revenue": row["sales_revenue"] or 0,
|
||||
"food_revenue": row["food_revenue"] or 0,
|
||||
"alcohol_revenue": row["alcohol_revenue"] or 0,
|
||||
"tips_payable": row["tips_payable"] or 0,
|
||||
"card_receivable": row["card_receivable"] or 0,
|
||||
"cash": row["cash"] or 0,
|
||||
"vat_total": row["vat_total"] or 0,
|
||||
"vat_reduced_12": row["vat_reduced_12"] or 0,
|
||||
"vat_standard_21": row["vat_standard_21"] or 0,
|
||||
"accounts_receivable_pending": row["accounts_receivable_pending"] or 0,
|
||||
"ecom_payment_receivable": row["ecom_payment_receivable"] or 0,
|
||||
},
|
||||
)
|
||||
131
backend/apps/accounts/migrations/0001_initial.py
Normal file
131
backend/apps/accounts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-01 00:09
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AllowedDevice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ip_address', models.GenericIPAddressField(unique=True)),
|
||||
('label', models.CharField(blank=True, max_length=255)),
|
||||
('user_agent', models.TextField(blank=True)),
|
||||
('registered_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_seen_at', models.DateTimeField(blank=True, null=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('ipv6_prefix', models.CharField(blank=True, max_length=120)),
|
||||
('device_token', models.CharField(blank=True, max_length=255, unique=True)),
|
||||
('known_ips', models.TextField(blank=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DomainPermission',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=120, unique=True)),
|
||||
('label', models.CharField(max_length=200)),
|
||||
('group', models.CharField(max_length=120)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Role',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('is_system', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('display_name', models.CharField(blank=True, max_length=255)),
|
||||
('last_login_ip', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='accounts.role')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeviceRegistrationToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token', models.CharField(max_length=255, unique=True)),
|
||||
('label', models.CharField(blank=True, max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('used_at', models.DateTimeField(blank=True, null=True)),
|
||||
('used_by_ip', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RolePermission',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_links', to='accounts.domainpermission')),
|
||||
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permission_links', to='accounts.role')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('role', 'permission')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserBusinessAccess',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_links', to='core.business')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='business_links', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'business')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserDeviceLogin',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ip_address', models.GenericIPAddressField()),
|
||||
('last_login_at', models.DateTimeField()),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_logins', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'ip_address')},
|
||||
},
|
||||
),
|
||||
]
|
||||
1
backend/apps/accounts/migrations/__init__.py
Normal file
1
backend/apps/accounts/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
86
backend/apps/accounts/models.py
Normal file
86
backend/apps/accounts/models.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Role(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
is_system = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class DomainPermission(models.Model):
|
||||
key = models.CharField(max_length=120, unique=True)
|
||||
label = models.CharField(max_length=200)
|
||||
group = models.CharField(max_length=120)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.key
|
||||
|
||||
|
||||
class RolePermission(models.Model):
|
||||
role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="permission_links")
|
||||
permission = models.ForeignKey(DomainPermission, on_delete=models.CASCADE, related_name="role_links")
|
||||
|
||||
class Meta:
|
||||
unique_together = ("role", "permission")
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
display_name = models.CharField(max_length=255, blank=True)
|
||||
role = models.ForeignKey(Role, null=True, blank=True, on_delete=models.SET_NULL, related_name="users")
|
||||
last_login_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
|
||||
def permission_keys(self) -> list[str]:
|
||||
if not self.role_id:
|
||||
return []
|
||||
return list(
|
||||
self.role.permission_links.select_related("permission")
|
||||
.order_by("permission__group", "permission__key")
|
||||
.values_list("permission__key", flat=True)
|
||||
)
|
||||
|
||||
def has_domain_permission(self, key: str) -> bool:
|
||||
return key in self.permission_keys()
|
||||
|
||||
|
||||
class UserBusinessAccess(models.Model):
|
||||
user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, related_name="business_links")
|
||||
business = models.ForeignKey("core.Business", on_delete=models.CASCADE, related_name="user_links")
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "business")
|
||||
|
||||
|
||||
class UserDeviceLogin(models.Model):
|
||||
user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, related_name="device_logins")
|
||||
ip_address = models.GenericIPAddressField()
|
||||
last_login_at = models.DateTimeField()
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "ip_address")
|
||||
|
||||
|
||||
class AllowedDevice(models.Model):
|
||||
ip_address = models.GenericIPAddressField(unique=True)
|
||||
label = models.CharField(max_length=255, blank=True)
|
||||
user_agent = models.TextField(blank=True)
|
||||
registered_at = models.DateTimeField(auto_now_add=True)
|
||||
last_seen_at = models.DateTimeField(null=True, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
ipv6_prefix = models.CharField(max_length=120, blank=True)
|
||||
device_token = models.CharField(max_length=255, blank=True, unique=True)
|
||||
known_ips = models.TextField(blank=True)
|
||||
|
||||
|
||||
class DeviceRegistrationToken(models.Model):
|
||||
token = models.CharField(max_length=255, unique=True)
|
||||
label = models.CharField(max_length=255, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField()
|
||||
used_at = models.DateTimeField(null=True, blank=True)
|
||||
used_by_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
created_by = models.ForeignKey("accounts.User", null=True, blank=True, on_delete=models.SET_NULL)
|
||||
1
backend/apps/api/__init__.py
Normal file
1
backend/apps/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
7
backend/apps/api/apps.py
Normal file
7
backend/apps/api/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.api"
|
||||
label = "api"
|
||||
1
backend/apps/api/migrations/__init__.py
Normal file
1
backend/apps/api/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
32
backend/apps/api/urls.py
Normal file
32
backend/apps/api/urls.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("auth/login/", views.login_view),
|
||||
path("auth/logout/", views.logout_view),
|
||||
path("auth/me/", views.me_view),
|
||||
path("auth/csrf/", views.csrf_view),
|
||||
path("businesses/", views.businesses_view),
|
||||
path("businesses/<int:business_id>/summary/", views.business_summary_view),
|
||||
path("products/", views.products_view),
|
||||
path("categories/", views.categories_view),
|
||||
path("dashboard/overview/", views.dashboard_overview_view),
|
||||
path("dashboard/business-summary/", views.dashboard_business_summary_view),
|
||||
path("vendors/", views.vendors_view),
|
||||
path("vendors/<int:vendor_id>/", views.vendor_detail_view),
|
||||
path("invoices/", views.invoices_view),
|
||||
path("invoices/<int:invoice_id>/", views.invoice_detail_view),
|
||||
path("inventory/", views.inventory_view),
|
||||
path("events/", views.events_view),
|
||||
path("events/<int:event_id>/", views.event_detail_view),
|
||||
path("schedule/overview/", views.schedule_overview_view),
|
||||
path("settings/overview/", views.settings_overview_view),
|
||||
path("settings/users/", views.users_view),
|
||||
path("settings/users/<int:user_id>/", views.user_detail_view),
|
||||
path("devices/", views.devices_view),
|
||||
path("devices/<int:device_id>/", views.device_detail_view),
|
||||
path("devices/tokens/", views.device_tokens_view),
|
||||
path("devices/tokens/<int:token_id>/", views.device_token_detail_view),
|
||||
path("notifications/", views.notifications_view),
|
||||
]
|
||||
1081
backend/apps/api/views.py
Normal file
1081
backend/apps/api/views.py
Normal file
File diff suppressed because it is too large
Load Diff
1
backend/apps/core/__init__.py
Normal file
1
backend/apps/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
19
backend/apps/core/admin.py
Normal file
19
backend/apps/core/admin.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import (
|
||||
Business,
|
||||
Category,
|
||||
Product,
|
||||
ProductCategory,
|
||||
Vendor,
|
||||
VendorBusiness,
|
||||
VendorCategory,
|
||||
)
|
||||
|
||||
admin.site.register(Business)
|
||||
admin.site.register(Category)
|
||||
admin.site.register(Vendor)
|
||||
admin.site.register(VendorBusiness)
|
||||
admin.site.register(VendorCategory)
|
||||
admin.site.register(Product)
|
||||
admin.site.register(ProductCategory)
|
||||
7
backend/apps/core/apps.py
Normal file
7
backend/apps/core/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.core"
|
||||
label = "core"
|
||||
124
backend/apps/core/migrations/0001_initial.py
Normal file
124
backend/apps/core/migrations/0001_initial.py
Normal file
@@ -0,0 +1,124 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-01 00:09
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Business',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('short_code', models.CharField(max_length=32, unique=True)),
|
||||
('currency', models.CharField(default='CZK', max_length=8)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Category',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Vendor',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('vat_number', models.CharField(blank=True, max_length=64)),
|
||||
('registration_id', models.CharField(blank=True, max_length=64)),
|
||||
('contact_email', models.EmailField(blank=True, max_length=254)),
|
||||
('contact_phone', models.CharField(blank=True, max_length=64)),
|
||||
('address', models.TextField(blank=True)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Product',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('gtin', models.CharField(blank=True, max_length=64)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('ledger', models.CharField(blank=True, max_length=128)),
|
||||
('product_status', models.CharField(blank=True, max_length=64)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('tax_type', models.CharField(blank=True, max_length=64)),
|
||||
('vat_rate', models.DecimalField(decimal_places=2, default=0, max_digits=8)),
|
||||
('net_purchase_price', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('display_sales_price', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('uom', models.CharField(default='pcs', max_length=32)),
|
||||
('amount', models.DecimalField(decimal_places=3, default=0, max_digits=12)),
|
||||
('is_placeholder', models.BooleanField(default=False)),
|
||||
('short_name', models.CharField(blank=True, max_length=120)),
|
||||
('currency_code', models.CharField(default='CZK', max_length=8)),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('gtin', 'name'), name='uniq_product_gtin_name')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_links', to='core.category')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_links', to='core.product')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('product', 'category')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VendorBusiness',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vendor_links', to='core.business')),
|
||||
('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='business_links', to='core.vendor')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('vendor', 'business')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VendorCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vendor_links', to='core.category')),
|
||||
('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_links', to='core.vendor')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('vendor', 'category')},
|
||||
},
|
||||
),
|
||||
]
|
||||
1
backend/apps/core/migrations/__init__.py
Normal file
1
backend/apps/core/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
91
backend/apps/core/models.py
Normal file
91
backend/apps/core/models.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class LegacyTrackedModel(models.Model):
|
||||
legacy_id = models.IntegerField(null=True, blank=True, db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class Business(LegacyTrackedModel):
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
short_code = models.CharField(max_length=32, unique=True)
|
||||
currency = models.CharField(max_length=8, default="CZK")
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class Category(LegacyTrackedModel):
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class Vendor(LegacyTrackedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
vat_number = models.CharField(max_length=64, blank=True)
|
||||
registration_id = models.CharField(max_length=64, blank=True)
|
||||
contact_email = models.EmailField(blank=True)
|
||||
contact_phone = models.CharField(max_length=64, blank=True)
|
||||
address = models.TextField(blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class VendorBusiness(models.Model):
|
||||
vendor = models.ForeignKey(Vendor, on_delete=models.CASCADE, related_name="business_links")
|
||||
business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name="vendor_links")
|
||||
|
||||
class Meta:
|
||||
unique_together = ("vendor", "business")
|
||||
|
||||
|
||||
class VendorCategory(models.Model):
|
||||
vendor = models.ForeignKey(Vendor, on_delete=models.CASCADE, related_name="category_links")
|
||||
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="vendor_links")
|
||||
|
||||
class Meta:
|
||||
unique_together = ("vendor", "category")
|
||||
|
||||
|
||||
class Product(LegacyTrackedModel):
|
||||
gtin = models.CharField(max_length=64, blank=True)
|
||||
name = models.CharField(max_length=255)
|
||||
ledger = models.CharField(max_length=128, blank=True)
|
||||
product_status = models.CharField(max_length=64, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
tax_type = models.CharField(max_length=64, blank=True)
|
||||
vat_rate = models.DecimalField(max_digits=8, decimal_places=2, default=0)
|
||||
net_purchase_price = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
display_sales_price = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
uom = models.CharField(max_length=32, default="pcs")
|
||||
amount = models.DecimalField(max_digits=12, decimal_places=3, default=0)
|
||||
is_placeholder = models.BooleanField(default=False)
|
||||
short_name = models.CharField(max_length=120, blank=True)
|
||||
currency_code = models.CharField(max_length=8, default="CZK")
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["gtin", "name"], name="uniq_product_gtin_name"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class ProductCategory(models.Model):
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="category_links")
|
||||
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="product_links")
|
||||
|
||||
class Meta:
|
||||
unique_together = ("product", "category")
|
||||
1
backend/apps/notifications/__init__.py
Normal file
1
backend/apps/notifications/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
5
backend/apps/notifications/admin.py
Normal file
5
backend/apps/notifications/admin.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Notification
|
||||
|
||||
admin.site.register(Notification)
|
||||
7
backend/apps/notifications/apps.py
Normal file
7
backend/apps/notifications/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.notifications"
|
||||
label = "notifications"
|
||||
30
backend/apps/notifications/migrations/0001_initial.py
Normal file
30
backend/apps/notifications/migrations/0001_initial.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-01 00:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('type', models.CharField(max_length=80)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('message', models.TextField()),
|
||||
('reference_type', models.CharField(blank=True, max_length=80)),
|
||||
('reference_id', models.IntegerField(blank=True, null=True)),
|
||||
('is_read', models.BooleanField(default=False)),
|
||||
('is_dismissed', models.BooleanField(default=False)),
|
||||
('remind_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
1
backend/apps/notifications/migrations/__init__.py
Normal file
1
backend/apps/notifications/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
14
backend/apps/notifications/models.py
Normal file
14
backend/apps/notifications/models.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
legacy_id = models.IntegerField(null=True, blank=True, db_index=True)
|
||||
type = models.CharField(max_length=80)
|
||||
title = models.CharField(max_length=255)
|
||||
message = models.TextField()
|
||||
reference_type = models.CharField(max_length=80, blank=True)
|
||||
reference_id = models.IntegerField(null=True, blank=True)
|
||||
is_read = models.BooleanField(default=False)
|
||||
is_dismissed = models.BooleanField(default=False)
|
||||
remind_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
1
backend/apps/operations/__init__.py
Normal file
1
backend/apps/operations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
31
backend/apps/operations/admin.py
Normal file
31
backend/apps/operations/admin.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import (
|
||||
Event,
|
||||
InventoryBalance,
|
||||
InventoryBulkMapping,
|
||||
InventoryMovement,
|
||||
Invoice,
|
||||
InvoiceCategory,
|
||||
InvoiceLineItem,
|
||||
ShiftAssignment,
|
||||
ShiftRole,
|
||||
ShiftTemplate,
|
||||
StockCount,
|
||||
StockCountLine,
|
||||
WorkerAvailability,
|
||||
)
|
||||
|
||||
admin.site.register(Invoice)
|
||||
admin.site.register(InvoiceLineItem)
|
||||
admin.site.register(InvoiceCategory)
|
||||
admin.site.register(InventoryBalance)
|
||||
admin.site.register(InventoryMovement)
|
||||
admin.site.register(InventoryBulkMapping)
|
||||
admin.site.register(StockCount)
|
||||
admin.site.register(StockCountLine)
|
||||
admin.site.register(Event)
|
||||
admin.site.register(ShiftRole)
|
||||
admin.site.register(ShiftTemplate)
|
||||
admin.site.register(ShiftAssignment)
|
||||
admin.site.register(WorkerAvailability)
|
||||
7
backend/apps/operations/apps.py
Normal file
7
backend/apps/operations/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OperationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.operations"
|
||||
label = "operations"
|
||||
274
backend/apps/operations/migrations/0001_initial.py
Normal file
274
backend/apps/operations/migrations/0001_initial.py
Normal file
@@ -0,0 +1,274 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-01 00:09
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Event',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('event_type', models.CharField(default='other', max_length=32)),
|
||||
('start_datetime', models.DateTimeField()),
|
||||
('end_datetime', models.DateTimeField()),
|
||||
('all_day', models.BooleanField(default=False)),
|
||||
('location', models.CharField(blank=True, max_length=255)),
|
||||
('color', models.CharField(blank=True, max_length=32)),
|
||||
('recurrence_type', models.CharField(default='none', max_length=32)),
|
||||
('recurrence_end_date', models.DateField(blank=True, null=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='core.business')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InventoryBalance',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity_on_hand', models.DecimalField(decimal_places=3, default=0, max_digits=12)),
|
||||
('uom', models.CharField(default='pcs', max_length=32)),
|
||||
('last_updated_at', models.DateTimeField(blank=True, null=True)),
|
||||
('product', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_balance', to='core.product')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InventoryMovement',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('movement_ts', models.DateTimeField(blank=True, null=True)),
|
||||
('movement_date', models.DateField()),
|
||||
('movement_type', models.CharField(max_length=32)),
|
||||
('quantity_delta', models.DecimalField(decimal_places=3, max_digits=12)),
|
||||
('uom', models.CharField(default='pcs', max_length=32)),
|
||||
('source_type', models.CharField(max_length=32)),
|
||||
('source_ref', models.CharField(blank=True, max_length=255)),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_movements', to='core.product')),
|
||||
('sellable_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sellable_inventory_movements', to='core.product')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invoice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('vendor_not_configured', models.BooleanField(default=False)),
|
||||
('vendor_config_note', models.TextField(blank=True)),
|
||||
('invoice_number', models.CharField(blank=True, max_length=120)),
|
||||
('invoice_date', models.DateField(blank=True, null=True)),
|
||||
('order_date', models.DateField(blank=True, null=True)),
|
||||
('entered_at', models.DateTimeField(blank=True, null=True)),
|
||||
('payment_status', models.CharField(default='unpaid', max_length=32)),
|
||||
('paid_date', models.DateField(blank=True, null=True)),
|
||||
('due_date', models.DateField(blank=True, null=True)),
|
||||
('subtotal', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('discount_pct', models.DecimalField(decimal_places=2, default=0, max_digits=8)),
|
||||
('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('total_after_discount', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('vat_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('gross_total', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('currency', models.CharField(default='CZK', max_length=8)),
|
||||
('goods_received_status', models.CharField(default='not_received', max_length=32)),
|
||||
('goods_date', models.DateField(blank=True, null=True)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('is_editable', models.BooleanField(default=True)),
|
||||
('inventory_updated', models.BooleanField(default=False)),
|
||||
('rate_czk_eur', models.DecimalField(decimal_places=4, default=0, max_digits=12)),
|
||||
('rate_czk_usd', models.DecimalField(decimal_places=4, default=0, max_digits=12)),
|
||||
('vat_exempt', models.BooleanField(default=False)),
|
||||
('business', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.business')),
|
||||
('vendor', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='core.vendor')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InvoiceLineItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('quantity', models.DecimalField(decimal_places=3, max_digits=12)),
|
||||
('unit_price', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||
('total_price', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||
('vat_rate', models.DecimalField(decimal_places=2, default=0, max_digits=8)),
|
||||
('vat_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('line_order', models.PositiveIntegerField(default=0)),
|
||||
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='line_items', to='operations.invoice')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='core.product')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ShiftRole',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('color', models.CharField(blank=True, max_length=32)),
|
||||
('sort_order', models.PositiveIntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shift_roles', to='core.business')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ShiftTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('start_datetime', models.DateTimeField()),
|
||||
('end_datetime', models.DateTimeField()),
|
||||
('min_staff', models.PositiveIntegerField(default=1)),
|
||||
('max_staff', models.PositiveIntegerField(default=3)),
|
||||
('color', models.CharField(blank=True, max_length=32)),
|
||||
('recurrence_type', models.CharField(default='none', max_length=32)),
|
||||
('recurrence_end_date', models.DateField(blank=True, null=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shift_templates', to='core.business')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('shift_role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='operations.shiftrole')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StockCount',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('counted_by', models.CharField(blank=True, max_length=255)),
|
||||
('count_date', models.DateField()),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('status', models.CharField(default='in_progress', max_length=32)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('business', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.business')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StockCountLine',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('full_units', models.DecimalField(decimal_places=3, default=0, max_digits=12)),
|
||||
('partial_units', models.DecimalField(decimal_places=3, default=0, max_digits=12)),
|
||||
('total_quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12)),
|
||||
('previous_quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12)),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='core.product')),
|
||||
('stock_count', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='operations.stockcount')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InventoryBulkMapping',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('decrement_amount', models.DecimalField(decimal_places=3, default=1, max_digits=12)),
|
||||
('decrement_uom', models.CharField(blank=True, max_length=32)),
|
||||
('chip_name', models.CharField(blank=True, max_length=120)),
|
||||
('bulk_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_mappings', to='core.product')),
|
||||
('sellable_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sellable_mappings', to='core.product')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('bulk_product', 'sellable_product')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InvoiceCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.category')),
|
||||
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_links', to='operations.invoice')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('invoice', 'category')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ShiftAssignment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('occurrence_date', models.DateField()),
|
||||
('start_override', models.DateTimeField(blank=True, null=True)),
|
||||
('end_override', models.DateTimeField(blank=True, null=True)),
|
||||
('status', models.CharField(default='assigned', max_length=32)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('notification_sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shift_assignments', to=settings.AUTH_USER_MODEL)),
|
||||
('shift_template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='operations.shifttemplate')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('shift_template', 'user', 'occurrence_date')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WorkerAvailability',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('legacy_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('date', models.DateField()),
|
||||
('status', models.CharField(default='available', max_length=32)),
|
||||
('note', models.TextField(blank=True)),
|
||||
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='availabilities', to='core.business')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='availabilities', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'date')},
|
||||
},
|
||||
),
|
||||
]
|
||||
1
backend/apps/operations/migrations/__init__.py
Normal file
1
backend/apps/operations/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
169
backend/apps/operations/models.py
Normal file
169
backend/apps/operations/models.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import LegacyTrackedModel
|
||||
|
||||
|
||||
class Invoice(LegacyTrackedModel):
|
||||
business = models.ForeignKey("core.Business", null=True, blank=True, on_delete=models.SET_NULL)
|
||||
vendor = models.ForeignKey("core.Vendor", on_delete=models.PROTECT)
|
||||
vendor_not_configured = models.BooleanField(default=False)
|
||||
vendor_config_note = models.TextField(blank=True)
|
||||
invoice_number = models.CharField(max_length=120, blank=True)
|
||||
invoice_date = models.DateField(null=True, blank=True)
|
||||
order_date = models.DateField(null=True, blank=True)
|
||||
entered_at = models.DateTimeField(null=True, blank=True)
|
||||
payment_status = models.CharField(max_length=32, default="unpaid")
|
||||
paid_date = models.DateField(null=True, blank=True)
|
||||
due_date = models.DateField(null=True, blank=True)
|
||||
subtotal = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
discount_pct = models.DecimalField(max_digits=8, decimal_places=2, default=0)
|
||||
discount_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
total_after_discount = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
gross_total = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
currency = models.CharField(max_length=8, default="CZK")
|
||||
goods_received_status = models.CharField(max_length=32, default="not_received")
|
||||
goods_date = models.DateField(null=True, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
is_editable = models.BooleanField(default=True)
|
||||
inventory_updated = models.BooleanField(default=False)
|
||||
rate_czk_eur = models.DecimalField(max_digits=12, decimal_places=4, default=0)
|
||||
rate_czk_usd = models.DecimalField(max_digits=12, decimal_places=4, default=0)
|
||||
vat_exempt = models.BooleanField(default=False)
|
||||
|
||||
|
||||
class InvoiceLineItem(LegacyTrackedModel):
|
||||
invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="line_items")
|
||||
product = models.ForeignKey("core.Product", on_delete=models.PROTECT)
|
||||
quantity = models.DecimalField(max_digits=12, decimal_places=3)
|
||||
unit_price = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
total_price = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
vat_rate = models.DecimalField(max_digits=8, decimal_places=2, default=0)
|
||||
vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
line_order = models.PositiveIntegerField(default=0)
|
||||
|
||||
|
||||
class InvoiceCategory(models.Model):
|
||||
invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="category_links")
|
||||
category = models.ForeignKey("core.Category", on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("invoice", "category")
|
||||
|
||||
|
||||
class InventoryBalance(models.Model):
|
||||
product = models.OneToOneField("core.Product", on_delete=models.CASCADE, related_name="inventory_balance")
|
||||
quantity_on_hand = models.DecimalField(max_digits=12, decimal_places=3, default=0)
|
||||
uom = models.CharField(max_length=32, default="pcs")
|
||||
last_updated_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
class InventoryMovement(LegacyTrackedModel):
|
||||
product = models.ForeignKey("core.Product", on_delete=models.CASCADE, related_name="inventory_movements")
|
||||
sellable_product = models.ForeignKey(
|
||||
"core.Product",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="sellable_inventory_movements",
|
||||
)
|
||||
movement_ts = models.DateTimeField(null=True, blank=True)
|
||||
movement_date = models.DateField()
|
||||
movement_type = models.CharField(max_length=32)
|
||||
quantity_delta = models.DecimalField(max_digits=12, decimal_places=3)
|
||||
uom = models.CharField(max_length=32, default="pcs")
|
||||
source_type = models.CharField(max_length=32)
|
||||
source_ref = models.CharField(max_length=255, blank=True)
|
||||
|
||||
|
||||
class InventoryBulkMapping(LegacyTrackedModel):
|
||||
bulk_product = models.ForeignKey("core.Product", on_delete=models.CASCADE, related_name="bulk_mappings")
|
||||
sellable_product = models.ForeignKey("core.Product", on_delete=models.CASCADE, related_name="sellable_mappings")
|
||||
decrement_amount = models.DecimalField(max_digits=12, decimal_places=3, default=1)
|
||||
decrement_uom = models.CharField(max_length=32, blank=True)
|
||||
chip_name = models.CharField(max_length=120, blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("bulk_product", "sellable_product")
|
||||
|
||||
|
||||
class StockCount(LegacyTrackedModel):
|
||||
business = models.ForeignKey("core.Business", null=True, blank=True, on_delete=models.SET_NULL)
|
||||
counted_by = models.CharField(max_length=255, blank=True)
|
||||
count_date = models.DateField()
|
||||
notes = models.TextField(blank=True)
|
||||
status = models.CharField(max_length=32, default="in_progress")
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
class StockCountLine(LegacyTrackedModel):
|
||||
stock_count = models.ForeignKey(StockCount, on_delete=models.CASCADE, related_name="lines")
|
||||
product = models.ForeignKey("core.Product", on_delete=models.PROTECT)
|
||||
full_units = models.DecimalField(max_digits=12, decimal_places=3, default=0)
|
||||
partial_units = models.DecimalField(max_digits=12, decimal_places=3, default=0)
|
||||
total_quantity = models.DecimalField(max_digits=12, decimal_places=3, default=0)
|
||||
previous_quantity = models.DecimalField(max_digits=12, decimal_places=3, default=0)
|
||||
|
||||
|
||||
class Event(LegacyTrackedModel):
|
||||
business = models.ForeignKey("core.Business", on_delete=models.CASCADE, related_name="events")
|
||||
title = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
event_type = models.CharField(max_length=32, default="other")
|
||||
start_datetime = models.DateTimeField()
|
||||
end_datetime = models.DateTimeField()
|
||||
all_day = models.BooleanField(default=False)
|
||||
location = models.CharField(max_length=255, blank=True)
|
||||
color = models.CharField(max_length=32, blank=True)
|
||||
recurrence_type = models.CharField(max_length=32, default="none")
|
||||
recurrence_end_date = models.DateField(null=True, blank=True)
|
||||
created_by = models.ForeignKey("accounts.User", null=True, blank=True, on_delete=models.SET_NULL)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
|
||||
class ShiftRole(LegacyTrackedModel):
|
||||
business = models.ForeignKey("core.Business", on_delete=models.CASCADE, related_name="shift_roles")
|
||||
name = models.CharField(max_length=255)
|
||||
color = models.CharField(max_length=32, blank=True)
|
||||
sort_order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
|
||||
class ShiftTemplate(LegacyTrackedModel):
|
||||
business = models.ForeignKey("core.Business", on_delete=models.CASCADE, related_name="shift_templates")
|
||||
name = models.CharField(max_length=255)
|
||||
start_datetime = models.DateTimeField()
|
||||
end_datetime = models.DateTimeField()
|
||||
min_staff = models.PositiveIntegerField(default=1)
|
||||
max_staff = models.PositiveIntegerField(default=3)
|
||||
color = models.CharField(max_length=32, blank=True)
|
||||
recurrence_type = models.CharField(max_length=32, default="none")
|
||||
recurrence_end_date = models.DateField(null=True, blank=True)
|
||||
created_by = models.ForeignKey("accounts.User", null=True, blank=True, on_delete=models.SET_NULL)
|
||||
shift_role = models.ForeignKey(ShiftRole, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
|
||||
class ShiftAssignment(LegacyTrackedModel):
|
||||
shift_template = models.ForeignKey(ShiftTemplate, on_delete=models.CASCADE, related_name="assignments")
|
||||
user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, related_name="shift_assignments")
|
||||
occurrence_date = models.DateField()
|
||||
start_override = models.DateTimeField(null=True, blank=True)
|
||||
end_override = models.DateTimeField(null=True, blank=True)
|
||||
status = models.CharField(max_length=32, default="assigned")
|
||||
notes = models.TextField(blank=True)
|
||||
notification_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("shift_template", "user", "occurrence_date")
|
||||
|
||||
|
||||
class WorkerAvailability(LegacyTrackedModel):
|
||||
user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, related_name="availabilities")
|
||||
business = models.ForeignKey("core.Business", on_delete=models.CASCADE, related_name="availabilities")
|
||||
date = models.DateField()
|
||||
status = models.CharField(max_length=32, default="available")
|
||||
note = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "date")
|
||||
187
backend/apps/operations/services.py
Normal file
187
backend/apps/operations/services.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date
|
||||
|
||||
from apps.core.models import Category, Product
|
||||
from apps.operations.models import Invoice, InvoiceCategory, InvoiceLineItem
|
||||
|
||||
TWOPLACES = Decimal("0.01")
|
||||
|
||||
|
||||
def _decimal(value: object, default: str = "0") -> Decimal:
|
||||
if value in (None, ""):
|
||||
return Decimal(default)
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _round_money(value: Decimal) -> Decimal:
|
||||
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InvoiceLineInput:
|
||||
product_id: int
|
||||
quantity: Decimal
|
||||
unit_price: Decimal
|
||||
|
||||
|
||||
def build_invoice_payload(invoice: Invoice) -> dict:
|
||||
return {
|
||||
"id": invoice.id,
|
||||
"legacy_id": invoice.legacy_id,
|
||||
"business_id": invoice.business_id,
|
||||
"business_name": invoice.business.name if invoice.business_id else None,
|
||||
"vendor_id": invoice.vendor_id,
|
||||
"vendor_name": invoice.vendor.name,
|
||||
"invoice_number": invoice.invoice_number,
|
||||
"invoice_date": invoice.invoice_date.isoformat() if invoice.invoice_date else None,
|
||||
"order_date": invoice.order_date.isoformat() if invoice.order_date else None,
|
||||
"payment_status": invoice.payment_status,
|
||||
"paid_date": invoice.paid_date.isoformat() if invoice.paid_date else None,
|
||||
"due_date": invoice.due_date.isoformat() if invoice.due_date else None,
|
||||
"subtotal": float(invoice.subtotal),
|
||||
"discount_pct": float(invoice.discount_pct),
|
||||
"discount_amount": float(invoice.discount_amount),
|
||||
"total_after_discount": float(invoice.total_after_discount),
|
||||
"vat_amount": float(invoice.vat_amount),
|
||||
"gross_total": float(invoice.gross_total),
|
||||
"currency": invoice.currency,
|
||||
"goods_received_status": invoice.goods_received_status,
|
||||
"goods_date": invoice.goods_date.isoformat() if invoice.goods_date else None,
|
||||
"notes": invoice.notes,
|
||||
"inventory_updated": invoice.inventory_updated,
|
||||
"vat_exempt": invoice.vat_exempt,
|
||||
"category_ids": list(invoice.category_links.values_list("category_id", flat=True)),
|
||||
"line_items": [
|
||||
{
|
||||
"id": line.id,
|
||||
"product_id": line.product_id,
|
||||
"product_name": line.product.name,
|
||||
"quantity": float(line.quantity),
|
||||
"unit_price": float(line.unit_price),
|
||||
"total_price": float(line.total_price),
|
||||
"vat_rate": float(line.vat_rate),
|
||||
"vat_amount": float(line.vat_amount),
|
||||
"line_order": line.line_order,
|
||||
}
|
||||
for line in invoice.line_items.all().order_by("line_order")
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _resolve_lines(line_items: list[dict]) -> tuple[list[tuple[Product, InvoiceLineInput]], Decimal, Decimal]:
|
||||
resolved: list[tuple[Product, InvoiceLineInput]] = []
|
||||
subtotal = Decimal("0")
|
||||
total_vat = Decimal("0")
|
||||
product_ids = [int(item["product_id"]) for item in line_items]
|
||||
products = Product.objects.in_bulk(product_ids)
|
||||
|
||||
for item in line_items:
|
||||
product_id = int(item["product_id"])
|
||||
product = products.get(product_id)
|
||||
if product is None:
|
||||
raise ValueError(f"Unknown product_id: {product_id}")
|
||||
line = InvoiceLineInput(
|
||||
product_id=product_id,
|
||||
quantity=_decimal(item.get("quantity"), "0"),
|
||||
unit_price=_decimal(item.get("unit_price"), "0"),
|
||||
)
|
||||
line_total = _round_money(line.quantity * line.unit_price)
|
||||
line_vat = _round_money(line_total * _decimal(product.vat_rate) / Decimal("100"))
|
||||
subtotal += line_total
|
||||
total_vat += line_vat
|
||||
resolved.append((product, line))
|
||||
|
||||
return resolved, _round_money(subtotal), _round_money(total_vat)
|
||||
|
||||
|
||||
def _persist_invoice_payload(invoice: Invoice, data: dict, *, is_create: bool) -> Invoice:
|
||||
line_items = data.get("line_items") or []
|
||||
if not line_items:
|
||||
raise ValueError("At least one line item is required")
|
||||
|
||||
resolved_lines, subtotal, computed_vat = _resolve_lines(line_items)
|
||||
discount_pct = _decimal(data.get("discount_pct"), "0")
|
||||
discount_amount = _decimal(data.get("discount_amount"), "0")
|
||||
if discount_pct > 0 and discount_amount == 0:
|
||||
discount_amount = _round_money(subtotal * discount_pct / Decimal("100"))
|
||||
total_after_discount = _round_money(subtotal - discount_amount)
|
||||
vat_exempt = bool(data.get("vat_exempt", False))
|
||||
total_vat = Decimal("0") if vat_exempt else computed_vat
|
||||
gross_total = _round_money(total_after_discount + total_vat)
|
||||
payment_status = data.get("payment_status") or "unpaid"
|
||||
goods_received_status = data.get("goods_received_status") or "not_received"
|
||||
today = timezone.localdate()
|
||||
|
||||
invoice.business_id = data.get("business_id") or None
|
||||
invoice.vendor_id = int(data["vendor_id"])
|
||||
invoice.vendor_not_configured = bool(data.get("vendor_not_configured", False))
|
||||
invoice.vendor_config_note = data.get("vendor_config_note", "")
|
||||
invoice.invoice_number = data.get("invoice_number", "")
|
||||
invoice.invoice_date = parse_date(data["invoice_date"]) if data.get("invoice_date") else None
|
||||
invoice.order_date = parse_date(data["order_date"]) if data.get("order_date") else None
|
||||
if is_create:
|
||||
invoice.entered_at = timezone.now()
|
||||
invoice.payment_status = payment_status
|
||||
invoice.paid_date = parse_date(data["paid_date"]) if data.get("paid_date") else (today if payment_status == "paid" else None)
|
||||
invoice.due_date = parse_date(data["due_date"]) if data.get("due_date") else None
|
||||
invoice.subtotal = subtotal
|
||||
invoice.discount_pct = discount_pct
|
||||
invoice.discount_amount = discount_amount
|
||||
invoice.total_after_discount = total_after_discount
|
||||
invoice.vat_amount = total_vat
|
||||
invoice.gross_total = gross_total
|
||||
invoice.currency = data.get("currency", "CZK")
|
||||
invoice.goods_received_status = goods_received_status
|
||||
invoice.goods_date = (
|
||||
parse_date(data["goods_date"]) if data.get("goods_date") else (today if goods_received_status == "received" else None)
|
||||
)
|
||||
invoice.notes = data.get("notes", "")
|
||||
invoice.vat_exempt = vat_exempt
|
||||
invoice.save()
|
||||
|
||||
invoice_lines: list[InvoiceLineItem] = []
|
||||
for index, (product, line) in enumerate(resolved_lines):
|
||||
total_price = _round_money(line.quantity * line.unit_price)
|
||||
vat_amount = Decimal("0") if vat_exempt else _round_money(total_price * _decimal(product.vat_rate) / Decimal("100"))
|
||||
invoice_lines.append(
|
||||
InvoiceLineItem(
|
||||
invoice=invoice,
|
||||
product=product,
|
||||
quantity=line.quantity,
|
||||
unit_price=line.unit_price,
|
||||
total_price=total_price,
|
||||
vat_rate=product.vat_rate,
|
||||
vat_amount=vat_amount,
|
||||
line_order=index,
|
||||
)
|
||||
)
|
||||
invoice.line_items.all().delete()
|
||||
InvoiceLineItem.objects.bulk_create(invoice_lines)
|
||||
|
||||
invoice.category_links.all().delete()
|
||||
if category_ids := data.get("category_ids"):
|
||||
categories = Category.objects.in_bulk(category_ids)
|
||||
links = [
|
||||
InvoiceCategory(invoice=invoice, category=category)
|
||||
for category_id in category_ids
|
||||
if (category := categories.get(int(category_id))) is not None
|
||||
]
|
||||
InvoiceCategory.objects.bulk_create(links, ignore_conflicts=True)
|
||||
|
||||
return invoice
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_invoice_from_payload(data: dict) -> Invoice:
|
||||
return _persist_invoice_payload(Invoice(), data, is_create=True)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_invoice_from_payload(invoice: Invoice, data: dict) -> Invoice:
|
||||
return _persist_invoice_payload(invoice, data, is_create=False)
|
||||
1
backend/apps/reporting/__init__.py
Normal file
1
backend/apps/reporting/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
5
backend/apps/reporting/admin.py
Normal file
5
backend/apps/reporting/admin.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import DailyRevenueSummary
|
||||
|
||||
admin.site.register(DailyRevenueSummary)
|
||||
7
backend/apps/reporting/apps.py
Normal file
7
backend/apps/reporting/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReportingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.reporting"
|
||||
label = "reporting"
|
||||
38
backend/apps/reporting/migrations/0001_initial.py
Normal file
38
backend/apps/reporting/migrations/0001_initial.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-01 00:09
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DailyRevenueSummary',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('business_date', models.DateField()),
|
||||
('sales_revenue', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('food_revenue', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('alcohol_revenue', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('tips_payable', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('card_receivable', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('cash', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('vat_total', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('vat_reduced_12', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('vat_standard_21', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('accounts_receivable_pending', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('ecom_payment_receivable', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='daily_revenue', to='core.business')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('business', 'business_date')},
|
||||
},
|
||||
),
|
||||
]
|
||||
1
backend/apps/reporting/migrations/__init__.py
Normal file
1
backend/apps/reporting/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
20
backend/apps/reporting/models.py
Normal file
20
backend/apps/reporting/models.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class DailyRevenueSummary(models.Model):
|
||||
business = models.ForeignKey("core.Business", on_delete=models.CASCADE, related_name="daily_revenue")
|
||||
business_date = models.DateField()
|
||||
sales_revenue = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
food_revenue = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
alcohol_revenue = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
tips_payable = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
card_receivable = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
cash = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
vat_total = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
vat_reduced_12 = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
vat_standard_21 = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
accounts_receivable_pending = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
ecom_payment_receivable = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("business", "business_date")
|
||||
1
backend/config/__init__.py
Normal file
1
backend/config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
6
backend/config/asgi.py
Normal file
6
backend/config/asgi.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import os
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
114
backend/config/settings.py
Normal file
114
backend/config/settings.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
DEV_FRONTEND_ORIGINS = [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
]
|
||||
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
|
||||
DEBUG = os.getenv("DJANGO_DEBUG", "1") == "1"
|
||||
ALLOWED_HOSTS = [host for host in os.getenv("DJANGO_ALLOWED_HOSTS", "*").split(",") if host]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"corsheaders",
|
||||
"apps.accounts",
|
||||
"apps.core",
|
||||
"apps.operations",
|
||||
"apps.reporting",
|
||||
"apps.notifications",
|
||||
"apps.api",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
ASGI_APPLICATION = "config.asgi.application"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_USER_MODEL = "accounts.User"
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Budapest")
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
LOGIN_URL = "/api/auth/login/"
|
||||
SESSION_COOKIE_NAME = "hub_sessionid"
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
SESSION_COOKIE_SECURE = False
|
||||
CSRF_COOKIE_NAME = "hub_csrftoken"
|
||||
CSRF_COOKIE_SAMESITE = "Lax"
|
||||
CSRF_COOKIE_SECURE = False
|
||||
APPEND_SLASH = True
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
origin
|
||||
for origin in (
|
||||
[origin for origin in os.getenv("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if origin]
|
||||
or DEV_FRONTEND_ORIGINS
|
||||
)
|
||||
]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
origin
|
||||
for origin in (
|
||||
[origin for origin in os.getenv("DJANGO_CORS_ALLOWED_ORIGINS", "").split(",") if origin]
|
||||
or DEV_FRONTEND_ORIGINS
|
||||
)
|
||||
]
|
||||
|
||||
LEGACY_CINCIN_DB = Path(os.getenv("LEGACY_CINCIN_DB", BASE_DIR.parent.parent / "cincin_phase1.sqlite"))
|
||||
LEGACY_DALCORSO_DB = Path(os.getenv("LEGACY_DALCORSO_DB", BASE_DIR.parent.parent / "dalcorso.sqlite"))
|
||||
7
backend/config/urls.py
Normal file
7
backend/config/urls.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/", include("apps.api.urls")),
|
||||
]
|
||||
6
backend/config/wsgi.py
Normal file
6
backend/config/wsgi.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import os
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
14
backend/manage.py
Normal file
14
backend/manage.py
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
23
backend/pyproject.toml
Normal file
23
backend/pyproject.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[project]
|
||||
name = "hub-django-port"
|
||||
version = "0.1.0"
|
||||
description = "Django rewrite of the hospitality operations hub"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"Django>=5.1,<6",
|
||||
"django-cors-headers>=4.4",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["config", "apps"]
|
||||
|
||||
[tool.setuptools.package-dir]
|
||||
"" = "."
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
Reference in New Issue
Block a user