diff --git a/SALES_AND_AGGREGATION_PLAN.md b/SALES_AND_AGGREGATION_PLAN.md new file mode 100644 index 0000000..7352550 --- /dev/null +++ b/SALES_AND_AGGREGATION_PLAN.md @@ -0,0 +1,138 @@ +# Sales and Aggregation Implementation Plan + +## 1. Data Model Proposals + +### 1.1 `sag_salgsvarer` Improvements +We will enhance the existing `sag_salgsvarer` table to support full billing requirements, margin calculation, and product linking. + +**Current Fields:** +- `id`, `sag_id`, `type` (sale), `description`, `quantity`, `unit`, `unit_price`, `amount`, `currency`, `status`, `line_date` + +**Proposed Additions:** +| Field | Type | Description | +|-------|------|-------------| +| `product_id` | INT (FK) | Link to new `products` catalog (nullable) | +| `cost_price` | DECIMAL | For calculating Gross Profit (DB) per line | +| `discount_percent` | DECIMAL | Discount given on standard price | +| `vat_rate` | DECIMAL | Default 25.00 for DK | +| `supplier_id` | INT (FK) | Reference to `vendors` table (if exists) or string | +| `billing_method` | VARCHAR | `invoice`, `prepaid`, `internal` (matches `tmodule_times`) | +| `is_subscription` | BOOLEAN | If true, pushes to subscription system instead of one-off invoice | + +### 1.2 New `products` Table +A central catalog for standard items (Hardware, Licenses, Fees) to speed up entry and standardize reporting. + +```sql +CREATE TABLE products ( + id SERIAL PRIMARY KEY, + sku VARCHAR(50) UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + category VARCHAR(50), -- 'hardware', 'license', 'consulting' + cost_price DECIMAL(10,2), + sales_price DECIMAL(10,2), -- Suggested RRP + unit VARCHAR(20) DEFAULT 'stk', + supplier_id INTEGER, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### 1.3 Aggregation Rules +The system will distinguish between **Direct** costs/revenue (on the case itself) and **Aggregated** (from sub-cases). + +- **Direct Revenue** = (Sum of `sag_salgsvarer.amount`) + (Sum of `tmodule_times` where `billable=true` * `hourly_rate`) +- **Total Revenue** = Direct Revenue + Sum(Child Cases Total Revenue) + +## 2. UI Structure for "Varer" (Items) Tab + +The "Varer" tab on the Case Detail page will have a split entry/view design. + +### 2.1 Top Section: Quick Add +A horizontal form to quickly add lines: +- **Product Lookup**: Searchable dropdown. +- **Manual Override**: Description field auto-filled but editable. +- **Numbers**: Qty, Unit, Price. +- **Result**: Total Price auto-calculated. +- **Action**: "Add Line" button. + +### 2.2 Main List: Combined Billing View +A unified table showing everything billable on this case: + +| Type | Date | Description | Qty | Price | Disc | Total | Status | Actions | +|------|------|-------------|-----|-------|------|-------|--------|---------| +| 🕒 Time | 02-02 | Konsulentbistand | 2.5 | 1200 | 0% | 3000 | `Approved` | [Edit Time] | +| 📦 Item | 02-02 | Ubiquiti Switch | 1 | 2500 | 10% | 2250 | `Draft` | [Edit] [Del] | +| 🔄 Sub | -- | *Sub-case: Installation i Aarhus* | -- | -- | -- | 5400 | `Calculated` | [Go to Case] | + +### 2.3 Summary Footer (Sticky) +- **Materials**: Total of Items. +- **Labor**: Total of Time. +- **Sub-cases**: Total of Children. +- **Grand Total**: Ex VAT and Inc VAT. +- **Margin**: (Sales - Cost) / Sales %. +- **Action**: "Create Invoice Proposal" button. + +## 3. Aggregation Logic (Recursive) + +We will implement a `SalesAggregator` service that traverses the case tree. + +**Algorithm:** +1. **Inputs**: `case_id`. +2. **Fetch Direct Items**: Query `sag_salgsvarer` for this case. +3. **Fetch Direct Time**: Query `tmodule_times` for this case. Calculate value using `hourly_rate`. +4. **Fetch Children**: Query `sag_relationer` (or `sag_sager` parent_id) to find children. +5. **Recursion**: For each child, recursively call `get_case_totals(child_id)`. +6. **Summation**: Return object with `own_total` and `sub_total`. + +**Python Service Method:** +```python +def get_case_financials(case_id: int) -> CaseFinancials: + # 1. Own items + items = db.query(SagSalgsvarer).filter(sag_id=case_id).all() + item_total = sum(i.amount for i in items) + item_cost = sum(i.cost_price * i.quantity for i in items) + + # 2. Own time + times = db.query(TmoduleTimes).filter(case_id=case_id, billable=True).all() + time_total = sum(t.original_hours * get_hourly_rate(case_id) for t in times) + + # 3. Children + children = db.query(SagRelationer).filter(kilde_sag_id=case_id).all() + child_total = 0 + child_cost = 0 + + for child in children: + child_fin = get_case_financials(child.malsag_id) + child_total += child_fin.total_revenue + child_cost += child_fin.total_cost + + return CaseFinancials( + revenue=item_total + time_total + child_total, + cost=item_cost + child_cost, + # ... breakdown fields + ) +``` + +## 4. Preparation for Billing (Status Flow) + +We define a strict lifecycle for items to prevent double-billing. + +### 4.1 Status Lifecycle for Items (`sag_salgsvarer`) +1. **`draft`**: Default. Editable. Included in Preliminary Total. +2. **`approved`**: Locked by Project Manager. Ready for Finance. + - *Action*: Lock for Billing. + - *Effect*: Rows become read-only. +3. **`billed`**: Processed by Finance (exported to e-conomic). + - *Action*: Integration Job runs. + - *Effect*: Linked to `invoice_id` (new column). + +### 4.2 Billing Triggers +- **Partial Billing**: Checkbox select specific `approved` lines -> Create Invoice Draft. +- **Full Billing**: Bill All Approved -> Generates invoice for all `approved` items and time. +- **Aggregation Billing**: + - The invoicing engine must accept a `case_structure` to decide if it prints one line per sub-case or expands all lines. Default to **One line per sub-case** for cleanliness. + +### 4.3 Validation +- Ensure all Approved items have a valid `cost_price` (warn if 0). +- Ensure Time Registrations are `approved` before they can be billed. diff --git a/app/auth/backend/admin.py b/app/auth/backend/admin.py index 77c65af..e44de9b 100644 --- a/app/auth/backend/admin.py +++ b/app/auth/backend/admin.py @@ -2,7 +2,7 @@ Auth Admin API - Users, Groups, Permissions management """ from fastapi import APIRouter, HTTPException, status, Depends -from app.core.auth_dependencies import require_superadmin +from app.core.auth_dependencies import require_permission from app.core.auth_service import AuthService from app.core.database import execute_query, execute_query_single, execute_insert, execute_update from app.models.schemas import UserAdminCreate, UserGroupsUpdate, GroupCreate, GroupPermissionsUpdate @@ -13,12 +13,13 @@ logger = logging.getLogger(__name__) router = APIRouter() -@router.get("/admin/users", dependencies=[Depends(require_superadmin)]) +@router.get("/admin/users", dependencies=[Depends(require_permission("users.manage"))]) async def list_users(): users = execute_query( """ - SELECT u.user_id, u.username, u.email, u.full_name, - u.is_active, u.is_superadmin, u.is_2fa_enabled, + SELECT u.user_id, u.username, u.email, u.full_name, + u.is_active, u.is_superadmin, u.is_2fa_enabled, + u.created_at, u.last_login_at, COALESCE(array_remove(array_agg(g.name), NULL), ARRAY[]::varchar[]) AS groups FROM users u LEFT JOIN user_groups ug ON u.user_id = ug.user_id @@ -30,7 +31,7 @@ async def list_users(): return users -@router.post("/admin/users", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_superadmin)]) +@router.post("/admin/users", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("users.manage"))]) async def create_user(payload: UserAdminCreate): existing = execute_query_single( "SELECT user_id FROM users WHERE username = %s OR email = %s", @@ -42,7 +43,14 @@ async def create_user(payload: UserAdminCreate): detail="Username or email already exists" ) - password_hash = AuthService.hash_password(payload.password) + try: + password_hash = AuthService.hash_password(payload.password) + except Exception as exc: + logger.error("❌ Password hash failed: %s", exc) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Kunne ikke hashe adgangskoden" + ) from exc user_id = execute_insert( """ INSERT INTO users (username, email, password_hash, full_name, is_superadmin, is_active) @@ -53,7 +61,7 @@ async def create_user(payload: UserAdminCreate): if payload.group_ids: for group_id in payload.group_ids: - execute_insert( + execute_update( """ INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s) ON CONFLICT DO NOTHING @@ -65,7 +73,7 @@ async def create_user(payload: UserAdminCreate): return {"user_id": user_id} -@router.put("/admin/users/{user_id}/groups", dependencies=[Depends(require_superadmin)]) +@router.put("/admin/users/{user_id}/groups", dependencies=[Depends(require_permission("users.manage"))]) async def update_user_groups(user_id: int, payload: UserGroupsUpdate): user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,)) if not user: @@ -74,7 +82,7 @@ async def update_user_groups(user_id: int, payload: UserGroupsUpdate): execute_update("DELETE FROM user_groups WHERE user_id = %s", (user_id,)) for group_id in payload.group_ids: - execute_insert( + execute_update( """ INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s) ON CONFLICT DO NOTHING @@ -85,7 +93,7 @@ async def update_user_groups(user_id: int, payload: UserGroupsUpdate): return {"message": "Groups updated"} -@router.get("/admin/groups", dependencies=[Depends(require_superadmin)]) +@router.get("/admin/groups", dependencies=[Depends(require_permission("users.manage"))]) async def list_groups(): groups = execute_query( """ @@ -101,7 +109,7 @@ async def list_groups(): return groups -@router.post("/admin/groups", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_superadmin)]) +@router.post("/admin/groups", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("permissions.manage"))]) async def create_group(payload: GroupCreate): existing = execute_query_single("SELECT id FROM groups WHERE name = %s", (payload.name,)) if existing: @@ -118,7 +126,7 @@ async def create_group(payload: GroupCreate): return {"group_id": group_id} -@router.put("/admin/groups/{group_id}/permissions", dependencies=[Depends(require_superadmin)]) +@router.put("/admin/groups/{group_id}/permissions", dependencies=[Depends(require_permission("permissions.manage"))]) async def update_group_permissions(group_id: int, payload: GroupPermissionsUpdate): group = execute_query_single("SELECT id FROM groups WHERE id = %s", (group_id,)) if not group: @@ -127,7 +135,7 @@ async def update_group_permissions(group_id: int, payload: GroupPermissionsUpdat execute_update("DELETE FROM group_permissions WHERE group_id = %s", (group_id,)) for permission_id in payload.permission_ids: - execute_insert( + execute_update( """ INSERT INTO group_permissions (group_id, permission_id) VALUES (%s, %s) ON CONFLICT DO NOTHING @@ -138,7 +146,7 @@ async def update_group_permissions(group_id: int, payload: GroupPermissionsUpdat return {"message": "Permissions updated"} -@router.get("/admin/permissions", dependencies=[Depends(require_superadmin)]) +@router.get("/admin/permissions", dependencies=[Depends(require_permission("permissions.manage"))]) async def list_permissions(): permissions = execute_query( """ diff --git a/app/contacts/backend/router_simple.py b/app/contacts/backend/router_simple.py index 91c6928..e4660f5 100644 --- a/app/contacts/backend/router_simple.py +++ b/app/contacts/backend/router_simple.py @@ -23,6 +23,12 @@ class ContactCreate(BaseModel): company_id: Optional[int] = None +class ContactCompanyLink(BaseModel): + customer_id: int + is_primary: bool = True + role: Optional[str] = None + + @router.get("/contacts-debug") async def debug_contacts(): @@ -167,6 +173,9 @@ async def create_contact(contact: ContactCreate): link_query = """ INSERT INTO contact_companies (contact_id, customer_id, is_primary, role) VALUES (%s, %s, true, 'primary') + ON CONFLICT (contact_id, customer_id) + DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role + RETURNING id """ execute_insert(link_query, (contact_id, contact.company_id)) except Exception as e: @@ -221,3 +230,34 @@ async def get_contact(contact_id: int): except Exception as e: logger.error(f"Failed to get contact {contact_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/contacts/{contact_id}/companies") +async def link_contact_to_company(contact_id: int, link: ContactCompanyLink): + """Link a contact to a company""" + try: + # Ensure contact exists + contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,)) + if not contact: + raise HTTPException(status_code=404, detail="Contact not found") + + # Ensure customer exists + customer = execute_query("SELECT id FROM customers WHERE id = %s", (link.customer_id,)) + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + query = """ + INSERT INTO contact_companies (contact_id, customer_id, is_primary, role) + VALUES (%s, %s, %s, %s) + ON CONFLICT (contact_id, customer_id) + DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role + RETURNING id + """ + execute_insert(query, (contact_id, link.customer_id, link.is_primary, link.role)) + + return {"message": "Contact linked to company successfully"} + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to link contact to company: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/core/auth_service.py b/app/core/auth_service.py index 8ccb52e..24f02e1 100644 --- a/app/core/auth_service.py +++ b/app/core/auth_service.py @@ -20,7 +20,7 @@ SECRET_KEY = getattr(settings, 'JWT_SECRET_KEY', 'your-secret-key-change-in-prod ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt_sha256", "bcrypt"], deprecated="auto") class AuthService: @@ -38,9 +38,9 @@ class AuthService: """Verify password against hash""" if not hashed_password: return False - if not hashed_password.startswith("$2"): - return False try: + if not hashed_password.startswith("$"): + return False return pwd_context.verify(plain_password, hashed_password) except Exception: return False @@ -235,7 +235,9 @@ class AuthService: User dict if successful, None otherwise """ # Shadow Admin shortcut - if settings.SHADOW_ADMIN_ENABLED and username == settings.SHADOW_ADMIN_USERNAME: + shadow_username = (settings.SHADOW_ADMIN_USERNAME or "shadowadmin").strip().lower() + request_username = (username or "").strip().lower() + if settings.SHADOW_ADMIN_ENABLED and request_username == shadow_username: if not settings.SHADOW_ADMIN_PASSWORD or not settings.SHADOW_ADMIN_TOTP_SECRET: logger.error("❌ Shadow admin enabled but not configured") return None, "Shadow admin not configured" diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index d5d2445..afadca9 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -126,17 +126,21 @@ async def create_sag(data: dict): if not data.get("customer_id"): raise HTTPException(status_code=400, detail="customer_id is required") + status = data.get("status", "åben") + if status not in {"åben", "lukket"}: + status = "åben" + query = """ INSERT INTO sag_sager - (titel, beskrivelse, type, status, customer_id, ansvarlig_bruger_id, created_by_user_id, deadline) + (titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id, deadline) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """ params = ( data.get("titel"), data.get("beskrivelse", ""), - data.get("type", "ticket"), - data.get("status", "åben"), + data.get("template_key") or data.get("type", "ticket"), + status, data.get("customer_id"), data.get("ansvarlig_bruger_id"), data.get("created_by_user_id", 1), @@ -177,7 +181,7 @@ async def update_sag(sag_id: int, updates: dict): raise HTTPException(status_code=404, detail="Case not found") # Build dynamic update query - allowed_fields = ["titel", "beskrivelse", "type", "status", "ansvarlig_bruger_id", "deadline"] + allowed_fields = ["titel", "beskrivelse", "template_key", "status", "ansvarlig_bruger_id", "deadline"] set_clauses = [] params = [] diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index 8140df6..071791f 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -330,7 +330,13 @@ } if (data.length === 0) { - resultsDiv.innerHTML = '
Opret og administrer brugere og deres grupper
+| Bruger | +Grupper | +Status | +Oprettet | +Handlinger | +|
|---|---|---|---|---|---|
| + + | +|||||
| Bruger | -Status | -Sidst Login | -Oprettet | -Handlinger | -|
|---|---|---|---|---|---|
| - - | -|||||
Opret grupper og tildel rettigheder
+| Gruppe | +Rettigheder | +Handlinger | +
|---|---|---|
| + + | +||